Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
+11
-4
@@ -4,9 +4,15 @@
|
|||||||
|
|
||||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
||||||
|
|
||||||
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
|
||||||
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
|
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
|
||||||
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
|
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
|
||||||
|
Creation persists it with the project, and normal boot reconciliation fills it for
|
||||||
|
older records where the field is absent—there is no migration.
|
||||||
|
|
||||||
|
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
||||||
|
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
|
||||||
|
names, and workspace foreign keys. Attached workspaces are independently refreshed
|
||||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
||||||
are observed too.
|
are observed too.
|
||||||
|
|
||||||
@@ -450,7 +456,8 @@ Array of project records.
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
|
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
|
||||||
|
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
|
||||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
||||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
||||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||||
|
|
||||||
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
|
||||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
|
||||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
|
||||||
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
|
||||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||||
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
addProjectFlow,
|
addProjectFlow,
|
||||||
addProjectFlowBack,
|
addProjectFlowBack,
|
||||||
@@ -13,36 +13,21 @@ import {
|
|||||||
expectAddProjectPage,
|
expectAddProjectPage,
|
||||||
expectNewWorkspaceForAddedProject,
|
expectNewWorkspaceForAddedProject,
|
||||||
openAddProjectFlow,
|
openAddProjectFlow,
|
||||||
waitForConnectedHost,
|
|
||||||
} from "./helpers/add-project-flow";
|
} from "./helpers/add-project-flow";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
import {
|
||||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
addConnectedHostAndReload,
|
||||||
|
addOfflineHostAndReload,
|
||||||
|
waitForConnectedHost,
|
||||||
|
} from "./helpers/hosts";
|
||||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||||
import { connectSeedClient } from "./helpers/seed-client";
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
|
||||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
|
||||||
const SECONDARY_HOST_ID = "add-project-flow-secondary";
|
const SECONDARY_HOST_ID = "add-project-flow-secondary";
|
||||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||||
|
|
||||||
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
|
|
||||||
const registryEntry = buildSeededHost({
|
|
||||||
serverId: host.serverId,
|
|
||||||
label: SECONDARY_HOST_LABEL,
|
|
||||||
endpoint: `127.0.0.1:${host.port}`,
|
|
||||||
nowIso: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
await page.evaluate(
|
|
||||||
({ key, entry }) => {
|
|
||||||
localStorage.setItem(key, JSON.stringify([entry]));
|
|
||||||
},
|
|
||||||
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
|
|
||||||
);
|
|
||||||
await page.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectProjectDirectory(pathname: string): Promise<void> {
|
async function expectProjectDirectory(pathname: string): Promise<void> {
|
||||||
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
|
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
|
|
||||||
test("keyboard selection chooses the second host", async ({ page }) => {
|
test("keyboard selection chooses the second host", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addConnectedHostAndReload(page, secondaryHost);
|
await addConnectedHostAndReload(page, {
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
});
|
||||||
await waitForConnectedHost(page, {
|
await waitForConnectedHost(page, {
|
||||||
serverId: SECONDARY_HOST_ID,
|
serverId: SECONDARY_HOST_ID,
|
||||||
endpoint: `localhost:${secondaryHost.port}`,
|
endpoint: `localhost:${secondaryHost.port}`,
|
||||||
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addConnectedHostAndReload(page, secondaryHost);
|
await addConnectedHostAndReload(page, {
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
});
|
||||||
await waitForConnectedHost(page, {
|
await waitForConnectedHost(page, {
|
||||||
serverId: SECONDARY_HOST_ID,
|
serverId: SECONDARY_HOST_ID,
|
||||||
endpoint: `localhost:${secondaryHost.port}`,
|
endpoint: `localhost:${secondaryHost.port}`,
|
||||||
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addConnectedHostAndReload(page, secondaryHost);
|
await addConnectedHostAndReload(page, {
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
});
|
||||||
await waitForConnectedHost(page, {
|
await waitForConnectedHost(page, {
|
||||||
serverId: SECONDARY_HOST_ID,
|
serverId: SECONDARY_HOST_ID,
|
||||||
endpoint: `localhost:${secondaryHost.port}`,
|
endpoint: `localhost:${secondaryHost.port}`,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: workspace.projectId,
|
projectKey: workspace.projectKey,
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: workspace.projectId,
|
projectKey: workspace.projectKey,
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await fillComposerDraft(page, "lock test prompt");
|
await fillComposerDraft(page, "lock test prompt");
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
|
|||||||
await archiveItem.click();
|
await archiveItem.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
|
||||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await projectRow.hover();
|
await projectRow.hover();
|
||||||
|
|
||||||
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
|
||||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||||
await kebab.click();
|
await kebab.click();
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
|||||||
// user-confirmed removal proceeds deterministically.
|
// user-confirmed removal proceeds deterministically.
|
||||||
page.once("dialog", (dialog) => void dialog.accept());
|
page.once("dialog", (dialog) => void dialog.accept());
|
||||||
|
|
||||||
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
|
||||||
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
||||||
await removeItem.click();
|
await removeItem.click();
|
||||||
}
|
}
|
||||||
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
|
|||||||
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||||
const newWorkspaceRow = page.getByTestId(
|
const newWorkspaceRow = page.getByTestId(
|
||||||
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
|
||||||
);
|
);
|
||||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||||
|
|
||||||
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
|
|||||||
test.describe("Project remove", () => {
|
test.describe("Project remove", () => {
|
||||||
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
||||||
|
let readdedProjectId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await removeProjectFromSidebar(page, workspace.projectId);
|
await removeProjectFromSidebar(page, workspace.projectKey);
|
||||||
|
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
|
|||||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||||
expect(readded.error).toBeNull();
|
expect(readded.error).toBeNull();
|
||||||
expect(readded.project).not.toBeNull();
|
expect(readded.project).not.toBeNull();
|
||||||
const readdedProjectId = readded.project?.projectId ?? "";
|
readdedProjectId = readded.project?.projectId ?? "";
|
||||||
|
const readdedProjectKey = readded.project?.projectKey ?? "";
|
||||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||||
|
expect(readdedProjectKey).toBe(workspace.projectKey);
|
||||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
|
||||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
|
if (readdedProjectId) {
|
||||||
|
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
|
||||||
|
}
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
|
|||||||
return page.getByTestId(`add-project-flow-method-${method}`);
|
return page.getByTestId(`add-project-flow-method-${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForConnectedHost(
|
|
||||||
page: Page,
|
|
||||||
input: { serverId: string; endpoint: string },
|
|
||||||
): Promise<void> {
|
|
||||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
|
||||||
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
|
||||||
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(host).not.toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
|
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
|
||||||
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
|
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
|
||||||
await expect(currentPage).toBeVisible({ timeout: 30_000 });
|
await expect(currentPage).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addConnectedHostAndReload(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; label: string; port: number; primaryLabel?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addConnectedHostsAndReload(
|
||||||
|
page: Page,
|
||||||
|
inputs: Array<{ serverId: string; label: string; port: number }>,
|
||||||
|
options?: { primaryLabel?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const connectedHosts = inputs.map((input) =>
|
||||||
|
buildSeededHost({
|
||||||
|
serverId: input.serverId,
|
||||||
|
label: input.label,
|
||||||
|
endpoint: `127.0.0.1:${input.port}`,
|
||||||
|
nowIso: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.evaluate(
|
||||||
|
({ hosts, keys, primaryLabel }) => {
|
||||||
|
const nonce = localStorage.getItem(keys.nonce);
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(keys.registry);
|
||||||
|
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||||
|
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||||
|
registry.push(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||||
|
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: connectedHosts,
|
||||||
|
keys: {
|
||||||
|
registry: REGISTRY_KEY,
|
||||||
|
nonce: SEED_NONCE_KEY,
|
||||||
|
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||||
|
},
|
||||||
|
primaryLabel: options?.primaryLabel,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForConnectedHost(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; endpoint: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||||
|
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
|
||||||
|
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(host).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
|
|||||||
export interface IsolatedHostDaemon {
|
export interface IsolatedHostDaemon {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
paseoHome: string;
|
||||||
restart(): Promise<void>;
|
restart(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
|||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
port,
|
port,
|
||||||
|
paseoHome,
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||||
await stopProcess(child);
|
await stopProcess(child);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "getPaseoWorktreeList"
|
| "getPaseoWorktreeList"
|
||||||
| "getDaemonConfig"
|
| "getDaemonConfig"
|
||||||
| "inspectWorkspaceRecovery"
|
| "inspectWorkspaceRecovery"
|
||||||
|
| "listProjects"
|
||||||
| "on"
|
| "on"
|
||||||
| "patchDaemonConfig"
|
| "patchDaemonConfig"
|
||||||
| "removeProject"
|
| "removeProject"
|
||||||
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
|||||||
|
|
||||||
export interface OpenedProject {
|
export interface OpenedProject {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
projectId: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
|
|||||||
return payload.workspace;
|
return payload.workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
|
async function openedProjectFromWorkspace(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspace: WorkspaceDescriptor,
|
||||||
|
): Promise<OpenedProject> {
|
||||||
|
const payload = await client.listProjects();
|
||||||
|
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
|
||||||
|
if (!project?.projectKey) {
|
||||||
|
throw new Error(`Project ${workspace.projectId} has no project key`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
projectKey: workspace.projectId,
|
projectId: workspace.projectId,
|
||||||
|
projectKey: project.projectKey,
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
workspaceName: workspace.name,
|
workspaceName: workspace.name,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
|
|||||||
source: { kind: "directory", path: repoPath },
|
source: { kind: "directory", path: repoPath },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return openedProjectFromWorkspace(workspace);
|
return openedProjectFromWorkspace(client, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveWorkspaceFromDaemon(
|
export async function archiveWorkspaceFromDaemon(
|
||||||
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
|
|||||||
worktreeSlug: input.slug,
|
worktreeSlug: input.slug,
|
||||||
});
|
});
|
||||||
const workspace = requireWorkspace(payload);
|
const workspace = requireWorkspace(payload);
|
||||||
return openedProjectFromWorkspace(workspace);
|
return openedProjectFromWorkspace(client, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openNewWorkspaceComposer(
|
export async function openNewWorkspaceComposer(
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { gotoAppShell } from "./app";
|
||||||
|
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
|
||||||
|
import { openProjectSettings } from "./project-settings";
|
||||||
|
import { waitForSidebarHydration } from "./workspace-ui";
|
||||||
|
|
||||||
|
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
|
||||||
|
|
||||||
|
function projectGroup(page: Page, projectName: string): Locator {
|
||||||
|
return page.getByRole("group", { name: projectName, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectGroupWithWorkspace(
|
||||||
|
page: Page,
|
||||||
|
projectName: string,
|
||||||
|
workspaceName: string,
|
||||||
|
): Locator {
|
||||||
|
return projectGroup(page, projectName).filter({ hasText: workspaceName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectDirectory(page: Page): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectDirectoryWithHosts(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
hosts: Array<{ serverId: string; label: string; port: number }>;
|
||||||
|
primaryLabel?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
|
||||||
|
for (const host of input.hosts) {
|
||||||
|
await waitForConnectedHost(page, {
|
||||||
|
serverId: host.serverId,
|
||||||
|
endpoint: `localhost:${host.port}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectProjectContainsWorkspaces(
|
||||||
|
page: Page,
|
||||||
|
input: { projectName: string; workspaceNames: string[] },
|
||||||
|
): Promise<void> {
|
||||||
|
const group = projectGroup(page, input.projectName);
|
||||||
|
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||||
|
for (const workspaceName of input.workspaceNames) {
|
||||||
|
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
|
||||||
|
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSeparateProjects(
|
||||||
|
page: Page,
|
||||||
|
projects: Array<{ projectName: string; workspaceName: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const project of projects) {
|
||||||
|
await expect(
|
||||||
|
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
|
||||||
|
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
|
||||||
|
const group = projectGroup(page, projectName);
|
||||||
|
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
|
||||||
|
await group.hover();
|
||||||
|
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
|
||||||
|
projectName,
|
||||||
|
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Host", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectProjectWorkspaceCountForHost(
|
||||||
|
page: Page,
|
||||||
|
input: { projectName: string; hostName: string; count: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const group = projectGroup(page, input.projectName);
|
||||||
|
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
|
||||||
|
input.count,
|
||||||
|
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameProject(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Rename project", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
|
||||||
|
await page.getByRole("button", { name: "Save project name", exact: true }).click();
|
||||||
|
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
|
||||||
|
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||||
|
await openProjectSettings(page, projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
|
||||||
|
const projectSettings = page.getByRole("main");
|
||||||
|
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
|
||||||
|
).toContainText(hostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
|
||||||
|
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
|
||||||
|
projectName,
|
||||||
|
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
|
|||||||
.first();
|
.first();
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
const testId = await projectRow.getAttribute("data-testid");
|
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
|
||||||
expect(testId).not.toBeNull();
|
const projectId = new URL(page.url()).searchParams.get("projectId");
|
||||||
return testId!.replace("sidebar-project-row-", "");
|
expect(projectId).not.toBeNull();
|
||||||
|
return projectId!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
|
|||||||
export interface FakeScheduleHostWorkspace {
|
export interface FakeScheduleHostWorkspace {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectKey: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
workspace: Record<string, unknown>;
|
workspace: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
|
|||||||
return {
|
return {
|
||||||
serverId: "schedule-fake-host",
|
serverId: "schedule-fake-host",
|
||||||
projectId,
|
projectId,
|
||||||
|
projectKey: workspace.projectKey,
|
||||||
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
|
||||||
workspace: {
|
workspace: {
|
||||||
...baseWorkspace,
|
...baseWorkspace,
|
||||||
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
|
|||||||
port: string;
|
port: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: Record<string, unknown>;
|
workspace: Record<string, unknown>;
|
||||||
|
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
|
||||||
schedules?: FakeScheduleSummary[];
|
schedules?: FakeScheduleSummary[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
|
||||||
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
|
|||||||
features: {
|
features: {
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
workspaceMultiplicity: true,
|
workspaceMultiplicity: true,
|
||||||
|
projectList: true,
|
||||||
projectAdd: true,
|
projectAdd: true,
|
||||||
projectRemove: true,
|
projectRemove: true,
|
||||||
workspaceRecovery: true,
|
workspaceRecovery: true,
|
||||||
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
case "project.list.request":
|
||||||
|
ws.send(
|
||||||
|
buildSessionMessage("project.list.response", {
|
||||||
|
requestId,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
projectId: input.project.projectId,
|
||||||
|
projectKey: input.project.projectKey,
|
||||||
|
projectDisplayName: input.project.projectDisplayName,
|
||||||
|
projectRootPath: String(input.workspace.projectRootPath),
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
case "fetch_agents_request":
|
case "fetch_agents_request":
|
||||||
ws.send(
|
ws.send(
|
||||||
buildSessionMessage("fetch_agents_response", {
|
buildSessionMessage("fetch_agents_response", {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
|
|||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SeedProjectDescriptor {
|
||||||
|
projectId: string;
|
||||||
|
projectKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||||
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
|
|||||||
addProject(cwd: string): Promise<{
|
addProject(cwd: string): Promise<{
|
||||||
project: {
|
project: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectKey?: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
} | null;
|
} | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||||
|
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||||
entries: SeedWorkspaceDescriptor[];
|
entries: SeedWorkspaceDescriptor[];
|
||||||
}>;
|
}>;
|
||||||
|
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||||
createWorkspace(input: {
|
createWorkspace(input: {
|
||||||
source:
|
source:
|
||||||
| { kind: "directory"; path: string; projectId?: string }
|
| { kind: "directory"; path: string; projectId?: string }
|
||||||
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
/** Stable project identity the daemon groups workspaces under. */
|
/** Host-local identity used by daemon project operations. */
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
/** Opaque cross-host key used by grouped project UI. */
|
||||||
|
projectKey: string;
|
||||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
cleanup(): Promise<void>;
|
cleanup(): Promise<void>;
|
||||||
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
|
|||||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||||
}
|
}
|
||||||
const workspace = created.workspace;
|
const workspace = created.workspace;
|
||||||
|
const listed = await client.listProjects();
|
||||||
|
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
||||||
|
if (!descriptor?.projectKey) {
|
||||||
|
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
repoPath: project.path,
|
repoPath: project.path,
|
||||||
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
|
|||||||
workspaceName: workspace.name,
|
workspaceName: workspace.name,
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
projectId: workspace.projectId,
|
projectId: workspace.projectId,
|
||||||
|
projectKey: descriptor.projectKey,
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
|||||||
if (!createdWorkspace.workspace) {
|
if (!createdWorkspace.workspace) {
|
||||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||||
}
|
}
|
||||||
|
const descriptor = (await client.listProjects()).projects.find(
|
||||||
|
(project) => project.projectId === createdWorkspace.workspace?.projectId,
|
||||||
|
);
|
||||||
|
const projectKey = descriptor?.projectKey;
|
||||||
|
if (!projectKey) {
|
||||||
|
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
promptPreview,
|
promptPreview,
|
||||||
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
|||||||
workspaceName: createdWorkspace.workspace.name,
|
workspaceName: createdWorkspace.workspace.name,
|
||||||
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||||
projectId: createdWorkspace.workspace.projectId,
|
projectId: createdWorkspace.workspace.projectId,
|
||||||
|
projectKey,
|
||||||
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await openGlobalNewWorkspaceComposer(page);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
await selectNewWorkspaceProject(page, {
|
await selectNewWorkspaceProject(page, {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
|
|||||||
|
|
||||||
await openGlobalNewWorkspaceComposer(page);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
await selectNewWorkspaceProject(page, {
|
await selectNewWorkspaceProject(page, {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: firstProject.projectId,
|
projectKey: firstProject.projectKey,
|
||||||
projectDisplayName: firstProject.projectDisplayName,
|
projectDisplayName: firstProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||||
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
|
|||||||
await fillNewWorkspaceDraft(page, DRAFT);
|
await fillNewWorkspaceDraft(page, DRAFT);
|
||||||
|
|
||||||
await selectNewWorkspaceProject(page, {
|
await selectNewWorkspaceProject(page, {
|
||||||
projectKey: secondProject.projectId,
|
projectKey: secondProject.projectKey,
|
||||||
projectDisplayName: secondProject.projectDisplayName,
|
projectDisplayName: secondProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => {
|
|||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Project A's row icon opens New Workspace with A preselected.
|
// Project A's row icon opens New Workspace with A preselected.
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: projectA.projectId,
|
projectKey: projectA.projectKey,
|
||||||
projectDisplayName: projectA.projectDisplayName,
|
projectDisplayName: projectA.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||||
@@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => {
|
|||||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||||
// route-driven navigation targets a different project.
|
// route-driven navigation targets a different project.
|
||||||
await page.getByTestId("new-workspace-project-picker-trigger").click();
|
await page.getByTestId("new-workspace-project-picker-trigger").click();
|
||||||
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
|
const optionC = page.getByTestId(
|
||||||
|
`new-workspace-project-picker-option-${projectC.projectKey}`,
|
||||||
|
);
|
||||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||||
await optionC.click();
|
await optionC.click();
|
||||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||||
@@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => {
|
|||||||
// because the stale manual choice (C) was reset on the route change. If the
|
// because the stale manual choice (C) was reset on the route change. If the
|
||||||
// reset were missing, the trigger would still read C.
|
// reset were missing, the trigger would still read C.
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: projectB.projectId,
|
projectKey: projectB.projectKey,
|
||||||
projectDisplayName: projectB.projectDisplayName,
|
projectDisplayName: projectB.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||||
@@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => {
|
|||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Open New Workspace for the non-git project via the global button, then
|
// Open New Workspace for the non-git project via the global button, then
|
||||||
// select it in the picker (the per-row icon would preselect it too).
|
// select it in the picker (the per-row icon would preselect it too).
|
||||||
@@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => {
|
|||||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const nonGitOption = page.getByTestId(
|
const nonGitOption = page.getByTestId(
|
||||||
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
|
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
|
||||||
);
|
);
|
||||||
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
|
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
|
||||||
await nonGitOption.click();
|
await nonGitOption.click();
|
||||||
@@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => {
|
|||||||
// Switching to the git project on the same screen reveals the Isolation row.
|
// Switching to the git project on the same screen reveals the Isolation row.
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
const gitOption = page.getByTestId(
|
const gitOption = page.getByTestId(
|
||||||
`new-workspace-project-picker-option-${gitProject.projectId}`,
|
`new-workspace-project-picker-option-${gitProject.projectKey}`,
|
||||||
);
|
);
|
||||||
await expect(gitOption).toBeVisible({ timeout: 30_000 });
|
await expect(gitOption).toBeVisible({ timeout: 30_000 });
|
||||||
await gitOption.click();
|
await gitOption.click();
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
|
|||||||
// so its handler is still registered when we cycle here.
|
// so its handler is still registered when we cycle here.
|
||||||
await openGlobalNewWorkspaceComposer(page);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
await selectNewWorkspaceProject(page, {
|
await selectNewWorkspaceProject(page, {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
|
|||||||
test.describe("New workspace flow", () => {
|
test.describe("New workspace flow", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
|
const localProjectIds = new Set<string>();
|
||||||
const createdWorktreeDirectories = new Set<string>();
|
const createdWorktreeDirectories = new Set<string>();
|
||||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||||
|
|
||||||
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
|
|||||||
for (const workspaceId of localWorkspaceIds) {
|
for (const workspaceId of localWorkspaceIds) {
|
||||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
for (const projectId of localProjectIds) {
|
||||||
|
await client.removeProject(projectId).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createdWorktreeDirectories.clear();
|
createdWorktreeDirectories.clear();
|
||||||
localWorkspaceIds.clear();
|
localWorkspaceIds.clear();
|
||||||
|
localProjectIds.clear();
|
||||||
await client?.close().catch(() => undefined);
|
await client?.close().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
|
|||||||
|
|
||||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
localWorkspaceIds.add(openedProject.workspaceId);
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
localProjectIds.add(openedProject.projectId);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
|
|||||||
|
|
||||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
localWorkspaceIds.add(openedProject.workspaceId);
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
localProjectIds.add(openedProject.projectId);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||||
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
|
import { waitForConnectedHost } from "./helpers/hosts";
|
||||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||||
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
|
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { connectSeedClient } from "./helpers/seed-client";
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
|
|
||||||
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
|
|||||||
dialogOpenResult: projectPickerFixture.projectPath,
|
dialogOpenResult: projectPickerFixture.projectPath,
|
||||||
});
|
});
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
await waitForConnectedHost(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||||
|
});
|
||||||
|
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await openAddProjectFlow(page);
|
||||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||||
await browse.click();
|
await browse.click();
|
||||||
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
|
|||||||
dialogOpenResult: null,
|
dialogOpenResult: null,
|
||||||
});
|
});
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
await waitForConnectedHost(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
endpoint: `localhost:${getE2EDaemonPort()}`,
|
||||||
|
});
|
||||||
|
|
||||||
await page.getByTestId("sidebar-add-project").click();
|
await openAddProjectFlow(page);
|
||||||
const browse = page.getByRole("button", { name: /^Browse/ });
|
const browse = page.getByRole("button", { name: /^Browse/ });
|
||||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||||
await browse.click();
|
await browse.click();
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
|
|||||||
port: fakePort,
|
port: fakePort,
|
||||||
serverId: fakeHost.serverId,
|
serverId: fakeHost.serverId,
|
||||||
workspace: fakeHost.workspace,
|
workspace: fakeHost.workspace,
|
||||||
|
project: fakeHost,
|
||||||
schedules: [
|
schedules: [
|
||||||
buildFakeHostSchedule({
|
buildFakeHostSchedule({
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
|
|||||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||||
await expectSettled(projectTrigger);
|
await expectSettled(projectTrigger);
|
||||||
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
|
|||||||
port: fakePort,
|
port: fakePort,
|
||||||
serverId: fakeHost.serverId,
|
serverId: fakeHost.serverId,
|
||||||
workspace: fakeHost.workspace,
|
workspace: fakeHost.workspace,
|
||||||
|
project: fakeHost,
|
||||||
});
|
});
|
||||||
await addFakeScheduleHostAndReload({
|
await addFakeScheduleHostAndReload({
|
||||||
page,
|
page,
|
||||||
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
|
|||||||
port: fakePort,
|
port: fakePort,
|
||||||
serverId: fakeHost.serverId,
|
serverId: fakeHost.serverId,
|
||||||
workspace: fakeHost.workspace,
|
workspace: fakeHost.workspace,
|
||||||
|
project: fakeHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
|
|||||||
await expectSettled(hostTrigger);
|
await expectSettled(hostTrigger);
|
||||||
|
|
||||||
await projectTrigger.click();
|
await projectTrigger.click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
|
||||||
await expectSettled(projectTrigger);
|
await expectSettled(projectTrigger);
|
||||||
|
|
||||||
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
|
|||||||
await expectSettled(projectTrigger);
|
await expectSettled(projectTrigger);
|
||||||
|
|
||||||
await projectTrigger.click();
|
await projectTrigger.click();
|
||||||
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
|
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
|
||||||
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
|
|
||||||
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
|
||||||
await expectSettled(projectTrigger);
|
await expectSettled(projectTrigger);
|
||||||
await expect(modelTrigger).toContainText(/select model/i);
|
await expect(modelTrigger).toContainText(/select model/i);
|
||||||
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
|
|||||||
await page.getByTestId("schedules-empty-new").click();
|
await page.getByTestId("schedules-empty-new").click();
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
|
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
|
||||||
await selectModelByLabel(page, "Ten second stream");
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
|
|||||||
|
|
||||||
// Both projects are expandable parents — the non-git one is NOT flattened
|
// Both projects are expandable parents — the non-git one is NOT flattened
|
||||||
// into a bare workspace link.
|
// into a bare workspace link.
|
||||||
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Each parent shows both of its workspace rows underneath.
|
// Each parent shows both of its workspace rows underneath.
|
||||||
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
|
|||||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||||
// git project can branch off a worktree, and the non-git project can add
|
// git project can branch off a worktree, and the non-git project can add
|
||||||
// another workspace because the host supports workspaceMultiplicity.
|
// another workspace because the host supports workspaceMultiplicity.
|
||||||
await projectRow(page, gitProject.projectId).hover();
|
await projectRow(page, gitProject.projectKey).hover();
|
||||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await projectRow(page, nonGitProject.projectId).hover();
|
await projectRow(page, nonGitProject.projectKey).hover();
|
||||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test as base } from "./fixtures";
|
||||||
|
import {
|
||||||
|
beginWorkspaceFromProject,
|
||||||
|
createWorkspaceWithoutAgent,
|
||||||
|
expectProjectContainsWorkspaces,
|
||||||
|
expectProjectSettingsName,
|
||||||
|
expectProjectWorkspaceCountForHost,
|
||||||
|
expectSeparateProjects,
|
||||||
|
openGroupedProjectSettings,
|
||||||
|
openProjectDirectory,
|
||||||
|
openProjectDirectoryWithHosts,
|
||||||
|
renameProject,
|
||||||
|
selectWorkspaceHost,
|
||||||
|
switchProjectSettingsHost,
|
||||||
|
} from "./helpers/project-grouping";
|
||||||
|
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||||
|
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
|
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||||
|
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||||
|
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
|
||||||
|
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
|
||||||
|
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
|
||||||
|
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
|
||||||
|
const SUBDIRECTORY = path.join("packages", "app");
|
||||||
|
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
|
||||||
|
|
||||||
|
interface HostConnection {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDirectoryScenario {
|
||||||
|
hosts: HostConnection[];
|
||||||
|
primaryLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedProject {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(
|
||||||
|
client: SeedDaemonClient,
|
||||||
|
input: {
|
||||||
|
projectPath: string;
|
||||||
|
serverId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
projectName?: string;
|
||||||
|
},
|
||||||
|
): Promise<CreatedProject> {
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: input.projectPath },
|
||||||
|
title: input.workspaceName,
|
||||||
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
|
||||||
|
}
|
||||||
|
if (input.projectName) {
|
||||||
|
await client.renameProject(created.workspace.projectId, input.projectName);
|
||||||
|
}
|
||||||
|
return { projectId: created.workspace.projectId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
|
||||||
|
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
|
||||||
|
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
for (const project of projects) delete project.projectKey;
|
||||||
|
await writeFile(projectsPath, JSON.stringify(projects));
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = base.extend<{
|
||||||
|
crossHostProject: ProjectDirectoryScenario;
|
||||||
|
reconciledCrossHostProject: ProjectDirectoryScenario;
|
||||||
|
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
|
||||||
|
crossHostSubdirectoryProject: ProjectDirectoryScenario;
|
||||||
|
sameHostClones: ProjectDirectoryScenario;
|
||||||
|
}>({
|
||||||
|
crossHostProject: async ({ page: _page }, provide) => {
|
||||||
|
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
|
||||||
|
const primaryRepo = await createTempGitRepo("grouped-primary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const primaryClient = await connectSeedClient();
|
||||||
|
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||||
|
let primary: CreatedProject | null = null;
|
||||||
|
let secondary: CreatedProject | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
primary = await createProject(primaryClient, {
|
||||||
|
projectPath: primaryRepo.path,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "Primary workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
secondary = await createProject(secondaryClient, {
|
||||||
|
projectPath: secondaryRepo.path,
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
workspaceName: "Secondary workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
await provide({
|
||||||
|
primaryLabel: PRIMARY_HOST_LABEL,
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||||
|
if (secondary)
|
||||||
|
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||||
|
await primaryClient.close().catch(() => undefined);
|
||||||
|
await secondaryClient.close().catch(() => undefined);
|
||||||
|
await primaryRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondaryRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondaryHost.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reconciledCrossHostProject: async ({ page: _page }, provide) => {
|
||||||
|
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
|
||||||
|
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
|
||||||
|
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const primaryClient = await connectSeedClient({ port: primaryHost.port });
|
||||||
|
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createProject(primaryClient, {
|
||||||
|
projectPath: primaryRepo.path,
|
||||||
|
serverId: primaryHost.serverId,
|
||||||
|
workspaceName: "Recovered primary workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
await createProject(secondaryClient, {
|
||||||
|
projectPath: secondaryRepo.path,
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
workspaceName: "Recovered secondary workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
await primaryClient.close();
|
||||||
|
await secondaryClient.close();
|
||||||
|
await removePersistedProjectKeys(primaryHost);
|
||||||
|
await removePersistedProjectKeys(secondaryHost);
|
||||||
|
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
|
||||||
|
|
||||||
|
await provide({
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: primaryHost.serverId,
|
||||||
|
label: LEGACY_PRIMARY_HOST_LABEL,
|
||||||
|
port: primaryHost.port,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: LEGACY_SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await primaryClient.close().catch(() => undefined);
|
||||||
|
await secondaryClient.close().catch(() => undefined);
|
||||||
|
await primaryHost.close().catch(() => undefined);
|
||||||
|
await secondaryHost.close().catch(() => undefined);
|
||||||
|
await primaryRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondaryRepo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
|
||||||
|
const repo = await createTempGitRepo("grouped-root-subdir-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
files: REPO_FILES,
|
||||||
|
});
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
const projects: CreatedProject[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
projects.push(
|
||||||
|
await createProject(client, {
|
||||||
|
projectPath: repo.path,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "Repository root workspace",
|
||||||
|
projectName: "Repository root project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
projects.push(
|
||||||
|
await createProject(client, {
|
||||||
|
projectPath: path.join(repo.path, SUBDIRECTORY),
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "App package workspace",
|
||||||
|
projectName: "App package project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await provide({ hosts: [] });
|
||||||
|
} finally {
|
||||||
|
for (const project of projects) {
|
||||||
|
await client.removeProject(project.projectId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
|
||||||
|
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
|
||||||
|
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
files: REPO_FILES,
|
||||||
|
});
|
||||||
|
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
files: REPO_FILES,
|
||||||
|
});
|
||||||
|
const primaryClient = await connectSeedClient();
|
||||||
|
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
|
||||||
|
let primary: CreatedProject | null = null;
|
||||||
|
let secondary: CreatedProject | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
primary = await createProject(primaryClient, {
|
||||||
|
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "Primary app workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
secondary = await createProject(secondaryClient, {
|
||||||
|
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
workspaceName: "Secondary app workspace",
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
});
|
||||||
|
await provide({
|
||||||
|
primaryLabel: PRIMARY_HOST_LABEL,
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: secondaryHost.serverId,
|
||||||
|
label: SECONDARY_HOST_LABEL,
|
||||||
|
port: secondaryHost.port,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
|
||||||
|
if (secondary)
|
||||||
|
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
|
||||||
|
await primaryClient.close().catch(() => undefined);
|
||||||
|
await secondaryClient.close().catch(() => undefined);
|
||||||
|
await primaryRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondaryRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondaryHost.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sameHostClones: async ({ page: _page }, provide) => {
|
||||||
|
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
|
||||||
|
originUrl: SHARED_REMOTE_URL,
|
||||||
|
});
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
const projects: CreatedProject[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
projects.push(
|
||||||
|
await createProject(client, {
|
||||||
|
projectPath: firstRepo.path,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "First clone workspace",
|
||||||
|
projectName: "First clone",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
projects.push(
|
||||||
|
await createProject(client, {
|
||||||
|
projectPath: secondRepo.path,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceName: "Second clone workspace",
|
||||||
|
projectName: "Second clone",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await provide({ hosts: [] });
|
||||||
|
} finally {
|
||||||
|
for (const project of projects) {
|
||||||
|
await client.removeProject(project.projectId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await firstRepo.cleanup().catch(() => undefined);
|
||||||
|
await secondRepo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openScenario(
|
||||||
|
page: Parameters<typeof openProjectDirectory>[0],
|
||||||
|
scenario: ProjectDirectoryScenario,
|
||||||
|
): Promise<void> {
|
||||||
|
if (scenario.hosts.length === 0) {
|
||||||
|
await openProjectDirectory(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openProjectDirectoryWithHosts(page, scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Sidebar project grouping", () => {
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
|
test("groups projects with the same Git remote across hosts", async ({
|
||||||
|
page,
|
||||||
|
crossHostProject,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, crossHostProject);
|
||||||
|
await expectProjectContainsWorkspaces(page, {
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
workspaceNames: ["Primary workspace", "Secondary workspace"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups persisted projects missing project keys after boot", async ({
|
||||||
|
page,
|
||||||
|
reconciledCrossHostProject,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, reconciledCrossHostProject);
|
||||||
|
await expectProjectContainsWorkspaces(page, {
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a repository root and its subdirectory as separate projects", async ({
|
||||||
|
page,
|
||||||
|
rootAndSubdirectoryProjects,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, rootAndSubdirectoryProjects);
|
||||||
|
await expectSeparateProjects(page, [
|
||||||
|
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
|
||||||
|
{ projectName: "App package project", workspaceName: "App package workspace" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups the same repository subdirectory across hosts", async ({
|
||||||
|
page,
|
||||||
|
crossHostSubdirectoryProject,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, crossHostSubdirectoryProject);
|
||||||
|
await expectProjectContainsWorkspaces(page, {
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps two clones of the same repository on one host separate", async ({
|
||||||
|
page,
|
||||||
|
sameHostClones,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, sameHostClones);
|
||||||
|
await expectSeparateProjects(page, [
|
||||||
|
{ projectName: "First clone", workspaceName: "First clone workspace" },
|
||||||
|
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
|
||||||
|
await openScenario(page, crossHostProject);
|
||||||
|
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
|
||||||
|
await renameProject(page, "Primary-only project name");
|
||||||
|
await expectProjectSettingsName(page, "Primary-only project name");
|
||||||
|
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
|
||||||
|
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a workspace on the selected host from a grouped project", async ({
|
||||||
|
page,
|
||||||
|
crossHostProject,
|
||||||
|
}) => {
|
||||||
|
await openScenario(page, crossHostProject);
|
||||||
|
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||||
|
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||||
|
await createWorkspaceWithoutAgent(page);
|
||||||
|
await expectProjectWorkspaceCountForHost(page, {
|
||||||
|
projectName: GROUPED_PROJECT_NAME,
|
||||||
|
hostName: SECONDARY_HOST_LABEL,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
|
||||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||||
|
|
||||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
projectId: string;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
listProjects(): Promise<{
|
||||||
|
projects: Array<{ projectId: string; projectKey?: string }>;
|
||||||
|
}>;
|
||||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||||
entries: Array<{
|
entries: Array<{
|
||||||
agent: {
|
agent: {
|
||||||
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
|
|||||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||||
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
||||||
|
|
||||||
|
const reconciledProjectKey = (await client.listProjects()).projects.find(
|
||||||
|
(project) => project.projectId === seeded.projectId,
|
||||||
|
);
|
||||||
|
if (!reconciledProjectKey?.projectKey) {
|
||||||
|
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
|
||||||
|
}
|
||||||
|
|
||||||
await openGlobalNewWorkspaceComposer(page);
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
await selectNewWorkspaceProject(page, {
|
await selectNewWorkspaceProject(page, {
|
||||||
projectKey: seeded.projectId,
|
projectKey: reconciledProjectKey.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const project = {
|
const project = {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const project = {
|
const project = {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const project = {
|
const project = {
|
||||||
projectKey: seeded.projectId,
|
projectKey: seeded.projectKey,
|
||||||
projectDisplayName: seeded.projectDisplayName,
|
projectDisplayName: seeded.projectDisplayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
// Model B: a non-git project is an expandable parent like any other, with
|
// Model B: a non-git project is an expandable parent like any other, with
|
||||||
// its single workspace already rendered as its own row underneath.
|
// its single workspace already rendered as its own row underneath.
|
||||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||||
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
|||||||
|
|
||||||
// Both the original and the new workspace render as distinct rows under
|
// Both the original and the new workspace render as distinct rows under
|
||||||
// the same expandable parent.
|
// the same expandable parent.
|
||||||
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
|
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="settings/index" />
|
<Stack.Screen name="settings/index" />
|
||||||
<Stack.Screen name="settings/[section]" />
|
<Stack.Screen name="settings/[section]" />
|
||||||
<Stack.Screen name="settings/projects/index" />
|
<Stack.Screen name="settings/projects/index" />
|
||||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
|
||||||
<Stack.Screen name="new" />
|
<Stack.Screen name="new" />
|
||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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,19 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export default function SettingsProjectDetailRoute() {
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
serverId?: string | string[];
|
||||||
|
projectId?: string | string[];
|
||||||
|
}>();
|
||||||
|
const serverId = normalizeProjectSettingsRouteId(params.serverId);
|
||||||
|
const projectId = normalizeProjectSettingsRouteId(params.projectId);
|
||||||
|
const view = useMemo(
|
||||||
|
() => ({ kind: "project" as const, serverId, projectId }),
|
||||||
|
[projectId, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SettingsScreen view={view} />;
|
||||||
|
}
|
||||||
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
||||||
const openProject = useOpenProject(hostId);
|
const openProject = useOpenProject(hostId);
|
||||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||||
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
|
const upsertProject = useSessionStore((store) => store.upsertProject);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const submissionInFlightRef = useRef(false);
|
const submissionInFlightRef = useRef(false);
|
||||||
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
registerProjectDescriptor({
|
registerProjectDescriptor({
|
||||||
serverId: page.hostId,
|
serverId: page.hostId,
|
||||||
project: payload.project,
|
project: payload.project,
|
||||||
addEmptyProject,
|
upsertProject,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
openNewWorkspaceForProject(page.hostId, payload.project);
|
openNewWorkspaceForProject(page.hostId, payload.project);
|
||||||
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
submissionInFlightRef.current = false;
|
submissionInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
|
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
|
||||||
|
|
||||||
const submitActive = useCallback(() => {
|
const submitActive = useCallback(() => {
|
||||||
if (page.kind === "new-directory-name") {
|
if (page.kind === "new-directory-name") {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
|||||||
act(() => {
|
act(() => {
|
||||||
setHostProfiles([makeHost()]);
|
setHostProfiles([makeHost()]);
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
useSessionStore
|
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||||
.getState()
|
|
||||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||||
useSidebarOrderStore.setState({
|
useSidebarOrderStore.setState({
|
||||||
projectOrder: ["project-a", "project-b"],
|
projectOrder: ["project-a", "project-b"],
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
|||||||
import {
|
import {
|
||||||
buildSidebarProjectRowModel,
|
buildSidebarProjectRowModel,
|
||||||
resolveSidebarProjectIconTarget,
|
resolveSidebarProjectIconTarget,
|
||||||
|
resolveSidebarProjectLocalPath,
|
||||||
type SidebarProjectHostTarget,
|
type SidebarProjectHostTarget,
|
||||||
} from "@/utils/sidebar-project-row-model";
|
} from "@/utils/sidebar-project-row-model";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
@@ -132,6 +133,7 @@ import {
|
|||||||
} from "@/constants/platform";
|
} from "@/constants/platform";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||||
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||||
|
|
||||||
@@ -505,6 +507,9 @@ function ProjectRowTrailingActions({
|
|||||||
removeProjectStatus: "idle" | "pending" | "success";
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
}) {
|
}) {
|
||||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||||
|
const localDaemonServerId = useLocalDaemonServerId();
|
||||||
|
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
|
||||||
|
const settingsTarget = project.hosts[0] ?? null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectTrailingActions}>
|
<View style={styles.projectTrailingActions}>
|
||||||
{worktreeTarget ? (
|
{worktreeTarget ? (
|
||||||
@@ -523,7 +528,8 @@ function ProjectRowTrailingActions({
|
|||||||
>
|
>
|
||||||
<ProjectKebabMenu
|
<ProjectKebabMenu
|
||||||
projectKey={project.projectKey}
|
projectKey={project.projectKey}
|
||||||
projectPath={project.iconWorkingDir}
|
settingsTarget={settingsTarget}
|
||||||
|
projectPath={localProjectPath}
|
||||||
onRemoveProject={onRemoveProject}
|
onRemoveProject={onRemoveProject}
|
||||||
removeProjectStatus={removeProjectStatus}
|
removeProjectStatus={removeProjectStatus}
|
||||||
/>
|
/>
|
||||||
@@ -550,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
|||||||
|
|
||||||
function ProjectKebabMenu({
|
function ProjectKebabMenu({
|
||||||
projectKey,
|
projectKey,
|
||||||
|
settingsTarget,
|
||||||
projectPath,
|
projectPath,
|
||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
removeProjectStatus,
|
removeProjectStatus,
|
||||||
}: {
|
}: {
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
|
settingsTarget: { serverId: string; projectId: string } | null;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onRemoveProject: () => void;
|
onRemoveProject: () => void;
|
||||||
removeProjectStatus: "idle" | "pending" | "success";
|
removeProjectStatus: "idle" | "pending" | "success";
|
||||||
@@ -562,10 +570,10 @@ function ProjectKebabMenu({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const handleOpenProjectSettings = useCallback(() => {
|
const handleOpenProjectSettings = useCallback(() => {
|
||||||
if (projectKey.trim().length === 0) return;
|
if (!settingsTarget) return;
|
||||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
|
||||||
}, [projectKey]);
|
}, [settingsTarget]);
|
||||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
const canOpenProjectSettings = settingsTarget !== null;
|
||||||
// Desktop-only: open a second window that lands on this project via the same
|
// Desktop-only: open a second window that lands on this project via the same
|
||||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||||
// ownership, no move.
|
// ownership, no move.
|
||||||
@@ -908,10 +916,10 @@ function NewWorkspaceGhostRow({
|
|||||||
serverId: worktreeTarget.serverId,
|
serverId: worktreeTarget.serverId,
|
||||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||||
displayName,
|
displayName,
|
||||||
projectId: project.projectKey,
|
projectId: worktreeTarget.projectId,
|
||||||
}) as Href,
|
}) as Href,
|
||||||
);
|
);
|
||||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||||
const rowStyle = useCallback(
|
const rowStyle = useCallback(
|
||||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.newWorkspaceGhostRow,
|
styles.newWorkspaceGhostRow,
|
||||||
@@ -988,10 +996,10 @@ function ProjectHeaderRow({
|
|||||||
serverId: worktreeTarget.serverId,
|
serverId: worktreeTarget.serverId,
|
||||||
sourceDirectory: worktreeTarget.iconWorkingDir,
|
sourceDirectory: worktreeTarget.iconWorkingDir,
|
||||||
displayName,
|
displayName,
|
||||||
projectId: project.projectKey,
|
projectId: worktreeTarget.projectId,
|
||||||
}) as Href,
|
}) as Href,
|
||||||
);
|
);
|
||||||
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
|
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController,
|
||||||
@@ -1755,7 +1763,6 @@ function ProjectBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
void removeProjectFromHosts({
|
void removeProjectFromHosts({
|
||||||
projectKey: project.projectKey,
|
|
||||||
targets: readiness.targets,
|
targets: readiness.targets,
|
||||||
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
||||||
})
|
})
|
||||||
@@ -1824,7 +1831,7 @@ function ProjectBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectBlock}>
|
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
|
||||||
<ProjectHeaderRow
|
<ProjectHeaderRow
|
||||||
project={project}
|
project={project}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
|
|||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
|
projectId: "project",
|
||||||
iconWorkingDir: "/repo",
|
iconWorkingDir: "/repo",
|
||||||
canCreateWorktree: true,
|
canCreateWorktree: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -299,7 +299,14 @@ export function ComboboxItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
|
<Pressable
|
||||||
|
testID={testID}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={itemPressableStyle}
|
||||||
|
>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
<View style={itemContentStyle}>
|
<View style={itemContentStyle}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
setHostProfiles([hostProfile()]);
|
setHostProfiles([hostProfile()]);
|
||||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().setWorkspaces(
|
seedSessionWorkspaces(
|
||||||
"srv",
|
"srv",
|
||||||
new Map([
|
new Map([
|
||||||
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||||
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
useSidebarViewStore.getState().setGroupMode("status");
|
useSidebarViewStore.getState().setGroupMode("status");
|
||||||
useSessionStore.getState().setWorkspaces(
|
seedSessionWorkspaces(
|
||||||
"srv",
|
"srv",
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||||
useSessionStore
|
seedSessionWorkspaces(
|
||||||
.getState()
|
"host-a",
|
||||||
.setWorkspaces(
|
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||||
"host-a",
|
);
|
||||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
seedSessionWorkspaces(
|
||||||
);
|
"host-b",
|
||||||
useSessionStore
|
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||||
.getState()
|
);
|
||||||
.setWorkspaces(
|
|
||||||
"host-b",
|
|
||||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
|
||||||
);
|
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||||
|
|||||||
@@ -3,101 +3,56 @@ import {
|
|||||||
buildWorktreeSetupCalloutPolicy,
|
buildWorktreeSetupCalloutPolicy,
|
||||||
selectActiveGitWorkspaceProject,
|
selectActiveGitWorkspaceProject,
|
||||||
shouldShowWorktreeSetupCallout,
|
shouldShowWorktreeSetupCallout,
|
||||||
type WorktreeSetupWorkspaceInput,
|
|
||||||
} from "./worktree-setup-callout-policy";
|
} from "./worktree-setup-callout-policy";
|
||||||
|
|
||||||
function gitWorkspace(
|
|
||||||
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
|
|
||||||
): WorktreeSetupWorkspaceInput {
|
|
||||||
return {
|
|
||||||
projectId: "project-1",
|
|
||||||
projectKind: "git",
|
|
||||||
projectRootPath: "/repo/project-1",
|
|
||||||
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("selectActiveGitWorkspaceProject", () => {
|
describe("selectActiveGitWorkspaceProject", () => {
|
||||||
it("selects the active git workspace project from checkout metadata", () => {
|
it("selects the host-local project id for a git workspace", () => {
|
||||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
|
|
||||||
serverId: "server-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
repoRoot: "/repo/main-project-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the workspace project root when checkout metadata has no main root", () => {
|
|
||||||
expect(
|
expect(
|
||||||
selectActiveGitWorkspaceProject(
|
selectActiveGitWorkspaceProject("server-1", {
|
||||||
"server-1",
|
projectId: "prj_local",
|
||||||
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
|
projectKind: "git",
|
||||||
),
|
projectRootPath: "/repo/project",
|
||||||
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
projectKey: "project-1",
|
projectId: "prj_local",
|
||||||
repoRoot: "/repo/project-1",
|
repoRoot: "/repo/project",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores non-git workspaces and blank project coordinates", () => {
|
it("ignores non-git workspaces and blank coordinates", () => {
|
||||||
expect(
|
expect(
|
||||||
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
|
selectActiveGitWorkspaceProject("server-1", {
|
||||||
).toBe(null);
|
projectId: "project",
|
||||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
|
projectKind: "directory",
|
||||||
null,
|
projectRootPath: "/repo",
|
||||||
);
|
}),
|
||||||
expect(
|
).toBeNull();
|
||||||
selectActiveGitWorkspaceProject(
|
|
||||||
"server-1",
|
|
||||||
gitWorkspace({ projectRootPath: " ", project: null }),
|
|
||||||
),
|
|
||||||
).toBe(null);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldShowWorktreeSetupCallout", () => {
|
describe("shouldShowWorktreeSetupCallout", () => {
|
||||||
it("shows the callout when paseo config was read and setup commands are missing", () => {
|
it("shows only after a successful config read with no setup command", () => {
|
||||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
||||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show the callout when setup commands are present", () => {
|
|
||||||
expect(
|
expect(
|
||||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
|
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
|
||||||
shouldShowWorktreeSetupCallout({
|
|
||||||
ok: true,
|
|
||||||
config: { worktree: { setup: [" ", "npm install"] } },
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show the callout when reading paseo config fails or has not completed", () => {
|
|
||||||
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
|
|
||||||
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorktreeSetupCalloutPolicy", () => {
|
describe("buildWorktreeSetupCalloutPolicy", () => {
|
||||||
it("builds the stable sidebar callout identity and action route", () => {
|
it("routes by server id and project id", () => {
|
||||||
expect(
|
expect(
|
||||||
buildWorktreeSetupCalloutPolicy({
|
buildWorktreeSetupCalloutPolicy({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
projectKey: "project-1",
|
projectId: "prj_local",
|
||||||
repoRoot: "/repo/project-1",
|
repoRoot: "/repo/project",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toMatchObject({
|
||||||
id: "worktree-setup-missing:project-1",
|
id: "worktree-setup-missing:server-1:prj_local",
|
||||||
dismissalKey: "worktree-setup-missing:project-1",
|
projectSettingsRoute: "/settings/projects/server-1/prj_local",
|
||||||
priority: 100,
|
testID: "worktree-setup-callout-prj_local",
|
||||||
title: "Set up worktree scripts",
|
|
||||||
description:
|
|
||||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
|
||||||
actionLabel: "Open project settings",
|
|
||||||
projectSettingsRoute: "/settings/projects/project-1",
|
|
||||||
testID: "worktree-setup-callout-project-1",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectKind: string;
|
projectKind: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
project?: {
|
|
||||||
checkout?: {
|
|
||||||
mainRepoRoot?: string | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveGitWorkspaceProject {
|
export interface ActiveGitWorkspaceProject {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
projectKey: string;
|
projectId: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectKey = workspace.projectId.trim();
|
const projectId = workspace.projectId;
|
||||||
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
|
const repoRoot = workspace.projectRootPath.trim();
|
||||||
if (!projectKey || !repoRoot) {
|
if (!projectId.trim() || !repoRoot) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { serverId, projectKey, repoRoot };
|
return { serverId, projectId, repoRoot };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
||||||
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
|
|||||||
export function buildWorktreeSetupCalloutPolicy(
|
export function buildWorktreeSetupCalloutPolicy(
|
||||||
project: ActiveGitWorkspaceProject,
|
project: ActiveGitWorkspaceProject,
|
||||||
): WorktreeSetupCalloutPolicy {
|
): WorktreeSetupCalloutPolicy {
|
||||||
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
|
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: calloutKey,
|
id: calloutKey,
|
||||||
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
|
|||||||
title: i18n.t("sidebar.worktreeSetup.title"),
|
title: i18n.t("sidebar.worktreeSetup.title"),
|
||||||
description: i18n.t("sidebar.worktreeSetup.description"),
|
description: i18n.t("sidebar.worktreeSetup.description"),
|
||||||
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
|
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
|
||||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
|
||||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
testID: `worktree-setup-callout-${project.projectId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import {
|
|||||||
|
|
||||||
export function WorktreeSetupCalloutSource() {
|
export function WorktreeSetupCalloutSource() {
|
||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const activeProject = useWorkspaceFields(
|
const selectedWorkspaceProject = useWorkspaceFields(
|
||||||
selection?.serverId ?? null,
|
selection?.serverId ?? null,
|
||||||
selection?.workspaceId ?? null,
|
selection?.workspaceId ?? null,
|
||||||
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
|
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
|
||||||
);
|
);
|
||||||
|
const activeProject = selectedWorkspaceProject;
|
||||||
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
|
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
|
||||||
const callouts = useSidebarCallouts();
|
const callouts = useSidebarCallouts();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import type {
|
|||||||
ProjectAddResponse,
|
ProjectAddResponse,
|
||||||
WorkspaceProjectDescriptorPayload,
|
WorkspaceProjectDescriptorPayload,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import {
|
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
|
||||||
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
|
|
||||||
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
|
|
||||||
} from "@/stores/session-store";
|
|
||||||
|
|
||||||
type OpenProjectPayload = ProjectAddResponse["payload"];
|
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||||
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
canAddProject: boolean;
|
canAddProject: boolean;
|
||||||
client: Pick<DaemonClient, "addProject"> | null;
|
client: Pick<DaemonClient, "addProject"> | null;
|
||||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectRegistrationCallbacks {
|
interface ProjectRegistrationCallbacks {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterProjectDescriptorInput {
|
export interface RegisterProjectDescriptorInput {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
project: WorkspaceProjectDescriptorPayload;
|
project: WorkspaceProjectDescriptorPayload;
|
||||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
|
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
if (!serverId) return false;
|
if (!serverId) return false;
|
||||||
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
|
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
|
||||||
input.setHasHydratedWorkspaces(serverId, true);
|
input.setHasHydratedWorkspaces(serverId, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
|
|||||||
const registered = registerProjectDescriptor({
|
const registered = registerProjectDescriptor({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
project: payload.project,
|
project: payload.project,
|
||||||
addEmptyProject: input.addEmptyProject,
|
upsertProject: input.upsertProject,
|
||||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
return registered
|
return registered
|
||||||
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
|
|||||||
const registered = registerProjectDescriptor({
|
const registered = registerProjectDescriptor({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
project: payload.project,
|
project: payload.project,
|
||||||
addEmptyProject: input.addEmptyProject,
|
upsertProject: input.upsertProject,
|
||||||
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
return registered
|
return registered
|
||||||
|
|||||||
@@ -78,20 +78,25 @@ function project(input: {
|
|||||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||||
iconWorkingDir?: string;
|
iconWorkingDir?: string;
|
||||||
workspaceKeys: string[];
|
workspaceKeys: string[];
|
||||||
hosts?: WorkspaceStructureProject["hosts"];
|
hosts?: Array<
|
||||||
|
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
|
||||||
|
>;
|
||||||
}): WorkspaceStructureProject {
|
}): WorkspaceStructureProject {
|
||||||
return {
|
return {
|
||||||
projectKey: input.projectKey,
|
projectKey: input.projectKey,
|
||||||
projectName: input.projectName ?? input.projectKey,
|
projectName: input.projectName ?? input.projectKey,
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||||
hosts: input.hosts ?? [
|
hosts: Array.from(
|
||||||
{
|
input.hosts ?? [
|
||||||
serverId: "srv",
|
{
|
||||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
serverId: "srv",
|
||||||
canCreateWorktree: true,
|
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||||
},
|
canCreateWorktree: true,
|
||||||
],
|
},
|
||||||
|
],
|
||||||
|
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
|
||||||
|
),
|
||||||
workspaceKeys: input.workspaceKeys,
|
workspaceKeys: input.workspaceKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
projectKey: "getpaseo/paseo",
|
projectKey: "getpaseo/paseo",
|
||||||
hosts: [
|
hosts: [
|
||||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
{
|
||||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
serverId: "host-a",
|
||||||
|
projectId: "getpaseo/paseo",
|
||||||
|
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||||
|
canCreateWorktree: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
projectId: "getpaseo/paseo",
|
||||||
|
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||||
|
canCreateWorktree: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
workspaces: [
|
workspaces: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
|
|||||||
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
|
||||||
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a structurally disambiguated project key in status entries", () => {
|
||||||
|
const projectKey = "host:srv:project:prj_a";
|
||||||
|
const model = buildSidebarWorkspacePlacementModel({
|
||||||
|
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
|
||||||
|
});
|
||||||
|
const entries = buildSidebarWorkspaceEntries({
|
||||||
|
placements: model.workspaces,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
|
workspaces: new Map([
|
||||||
|
[
|
||||||
|
"clone-a",
|
||||||
|
workspace({
|
||||||
|
id: "clone-a",
|
||||||
|
name: "main",
|
||||||
|
projectId: "prj_a",
|
||||||
|
projectDisplayName: "acme/app",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldShowSidebarHostLabels", () => {
|
describe("shouldShowSidebarHostLabels", () => {
|
||||||
|
|||||||
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
|
|||||||
export function createSidebarWorkspaceEntry(input: {
|
export function createSidebarWorkspaceEntry(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
|
projectKey?: string;
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||||
}): SidebarWorkspaceEntry {
|
}): SidebarWorkspaceEntry {
|
||||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
const projectKey = input.projectKey ?? input.workspace.projectId;
|
||||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||||
return {
|
return {
|
||||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||||
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
|||||||
const entry = createSidebarWorkspaceEntry({
|
const entry = createSidebarWorkspaceEntry({
|
||||||
serverId: placement.serverId,
|
serverId: placement.serverId,
|
||||||
workspace,
|
workspace,
|
||||||
|
projectKey: placement.projectKey,
|
||||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
getOpenProjectFailureReason,
|
getOpenProjectFailureReason,
|
||||||
openProjectDirectly,
|
openProjectDirectly,
|
||||||
} from "@/hooks/open-project";
|
} from "@/hooks/open-project";
|
||||||
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
const PROJECT_PATH = "/repo/project";
|
const PROJECT_PATH = "/repo/project";
|
||||||
@@ -20,7 +20,7 @@ function buildProjectPayload() {
|
|||||||
|
|
||||||
interface RecordedProject {
|
interface RecordedProject {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
project: ProjectWithoutWorkspacesDescriptor;
|
project: ProjectDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordedHydrated {
|
interface RecordedHydrated {
|
||||||
@@ -40,7 +40,7 @@ function createFakeSession() {
|
|||||||
return {
|
return {
|
||||||
projects,
|
projects,
|
||||||
hydrated,
|
hydrated,
|
||||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
|
upsertProject: (serverId: string, project: ProjectDescriptor) => {
|
||||||
projects.push({ serverId, project });
|
projects.push({ serverId, project });
|
||||||
},
|
},
|
||||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||||
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
|
|||||||
project: projectPayload,
|
project: projectPayload,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
addEmptyProject: session.addEmptyProject,
|
upsertProject: session.upsertProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
|
|||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
project: {
|
project: {
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
|
projectKey: null,
|
||||||
projectDisplayName: "project",
|
projectDisplayName: "project",
|
||||||
projectCustomName: null,
|
projectCustomName: null,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
|
|||||||
project: buildProjectPayload(),
|
project: buildProjectPayload(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
addEmptyProject: session.addEmptyProject,
|
upsertProject: session.upsertProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
|
|||||||
project: null,
|
project: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
addEmptyProject: session.addEmptyProject,
|
upsertProject: session.upsertProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
|
|||||||
cloneProtocol: "https",
|
cloneProtocol: "https",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
client: github,
|
client: github,
|
||||||
addEmptyProject: session.addEmptyProject,
|
upsertProject: session.upsertProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
|
|||||||
project: {
|
project: {
|
||||||
...projectPayload,
|
...projectPayload,
|
||||||
projectCustomName: null,
|
projectCustomName: null,
|
||||||
|
projectKey: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
|
|||||||
cloneProtocol: "https",
|
cloneProtocol: "https",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
client: github,
|
client: github,
|
||||||
addEmptyProject: session.addEmptyProject,
|
upsertProject: session.upsertProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useOpenProject(
|
|||||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -31,13 +31,13 @@ export function useOpenProject(
|
|||||||
isConnected,
|
isConnected,
|
||||||
canAddProject,
|
canAddProject,
|
||||||
client,
|
client,
|
||||||
addEmptyProject,
|
upsertProject,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
addEmptyProject,
|
upsertProject,
|
||||||
canAddProject,
|
canAddProject,
|
||||||
client,
|
client,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
|
|||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
const upsertProject = useSessionStore((state) => state.upsertProject);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
|
|||||||
...(cloneProtocol ? { cloneProtocol } : {}),
|
...(cloneProtocol ? { cloneProtocol } : {}),
|
||||||
isConnected,
|
isConnected,
|
||||||
client,
|
client,
|
||||||
addEmptyProject,
|
upsertProject,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
|
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,242 +1,90 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { ProjectDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
deriveProjectsFromReplica,
|
deriveProjectsFromReplica,
|
||||||
type ProjectHostReplica,
|
type ProjectHostReplica,
|
||||||
type ProjectHostRuntimeState,
|
type ProjectHostRuntimeState,
|
||||||
} from "@/hooks/use-projects";
|
} from "@/hooks/use-projects";
|
||||||
|
|
||||||
function runtimeState(input: {
|
function project(id: string, name: string, root: string): ProjectDescriptor {
|
||||||
serverId: string;
|
return {
|
||||||
isOnline: boolean;
|
projectId: id,
|
||||||
isLoading?: boolean;
|
projectKey: `remote:github.com/${name}`,
|
||||||
isFetching?: boolean;
|
projectDisplayName: name,
|
||||||
error?: string | null;
|
projectCustomName: null,
|
||||||
}): ProjectHostRuntimeState {
|
projectRootPath: root,
|
||||||
|
projectKind: "git",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
|
||||||
return {
|
return {
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
isOnline: input.isOnline,
|
isOnline: input.isOnline ?? true,
|
||||||
isLoading: input.isLoading ?? false,
|
isLoading: input.isLoading ?? false,
|
||||||
isFetching: input.isFetching ?? false,
|
isFetching: input.isFetching ?? false,
|
||||||
error: input.error ?? null,
|
error: input.error ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(input: {
|
|
||||||
id: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
remoteUrl: string | null;
|
|
||||||
}): WorkspaceDescriptor {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
projectId: input.projectKey,
|
|
||||||
projectDisplayName: input.projectName,
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: input.cwd,
|
|
||||||
workspaceDirectory: input.cwd,
|
|
||||||
projectKind: "git",
|
|
||||||
workspaceKind: "local_checkout",
|
|
||||||
name: input.id,
|
|
||||||
title: null,
|
|
||||||
archivingAt: null,
|
|
||||||
status: "done",
|
|
||||||
statusEnteredAt: 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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyProject(input: {
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
}): EmptyProjectDescriptor {
|
|
||||||
return {
|
|
||||||
projectId: input.projectKey,
|
|
||||||
projectDisplayName: input.projectName,
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: input.cwd,
|
|
||||||
projectKind: "git",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("deriveProjectsFromReplica", () => {
|
describe("deriveProjectsFromReplica", () => {
|
||||||
it("aggregates store workspaces and empty projects sorted by display name", () => {
|
it("derives projects from the project descriptor replica", () => {
|
||||||
const replicas: ProjectHostReplica[] = [
|
const replicas: ProjectHostReplica[] = [
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
serverName: "Local",
|
serverName: "Local",
|
||||||
workspaces: [
|
workspaces: [],
|
||||||
workspace({
|
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
|
||||||
id: "z-main",
|
|
||||||
projectKey: "remote:github.com/acme/zeta",
|
|
||||||
projectName: "acme/zeta",
|
|
||||||
cwd: "/repo/zeta",
|
|
||||||
remoteUrl: "https://github.com/acme/zeta.git",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
emptyProjects: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: "laptop",
|
serverId: "laptop",
|
||||||
serverName: "Laptop",
|
serverName: "Laptop",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
emptyProjects: [
|
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
|
||||||
emptyProject({
|
|
||||||
projectKey: "remote:github.com/acme/alpha",
|
|
||||||
projectName: "acme/alpha",
|
|
||||||
cwd: "/repo/alpha",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = deriveProjectsFromReplica({
|
const result = deriveProjectsFromReplica({
|
||||||
replicas,
|
replicas,
|
||||||
runtimeStates: [
|
runtimeStates: [
|
||||||
runtimeState({ serverId: "local", isOnline: true }),
|
runtimeState({ serverId: "local" }),
|
||||||
runtimeState({ serverId: "laptop", isOnline: false }),
|
runtimeState({ serverId: "laptop", isOnline: false }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
|
||||||
"acme/alpha",
|
expect(result.projects[0]?.hosts[0]).toMatchObject({
|
||||||
"acme/zeta",
|
serverId: "laptop",
|
||||||
]);
|
projectId: "prj_alpha",
|
||||||
expect(result.projects[0]?.hosts).toEqual([
|
repoRoot: "/repo/alpha",
|
||||||
expect.objectContaining({
|
workspaceCount: 0,
|
||||||
serverId: "laptop",
|
|
||||||
isOnline: false,
|
|
||||||
repoRoot: "/repo/alpha",
|
|
||||||
workspaceCount: 0,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(result.hostErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps runtime directory status into loading and host errors", () => {
|
|
||||||
const replicas: ProjectHostReplica[] = [
|
|
||||||
{
|
|
||||||
serverId: "local",
|
|
||||||
serverName: "Local",
|
|
||||||
workspaces: [
|
|
||||||
workspace({
|
|
||||||
id: "main",
|
|
||||||
projectKey: "remote:github.com/acme/app",
|
|
||||||
projectName: "acme/app",
|
|
||||||
cwd: "/repo/app",
|
|
||||||
remoteUrl: "https://github.com/acme/app.git",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
emptyProjects: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: "laptop",
|
|
||||||
serverName: "Laptop",
|
|
||||||
workspaces: [],
|
|
||||||
emptyProjects: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = deriveProjectsFromReplica({
|
|
||||||
replicas,
|
|
||||||
runtimeStates: [
|
|
||||||
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
|
|
||||||
runtimeState({
|
|
||||||
serverId: "laptop",
|
|
||||||
isOnline: true,
|
|
||||||
isLoading: true,
|
|
||||||
error: "laptop unavailable",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.projects).toEqual([
|
|
||||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
|
||||||
]);
|
|
||||||
expect(result.hostErrors).toEqual([
|
|
||||||
{
|
|
||||||
serverId: "laptop",
|
|
||||||
serverName: "Laptop",
|
|
||||||
message: "laptop unavailable",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(result.isLoading).toBe(true);
|
|
||||||
expect(result.isFetching).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns only the stable public project and host entry shapes", () => {
|
it("preserves runtime loading and error state", () => {
|
||||||
const result = deriveProjectsFromReplica({
|
const result = deriveProjectsFromReplica({
|
||||||
replicas: [
|
replicas: [
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
serverName: "Local",
|
serverName: "Local",
|
||||||
workspaces: [
|
workspaces: [],
|
||||||
workspace({
|
projects: [project("prj_app", "acme/app", "/repo/app")],
|
||||||
id: "main",
|
|
||||||
projectKey: "remote:github.com/acme/app",
|
|
||||||
projectName: "acme/app",
|
|
||||||
cwd: "/repo/app",
|
|
||||||
remoteUrl: "https://github.com/acme/app.git",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
emptyProjects: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
|
runtimeStates: [
|
||||||
|
runtimeState({
|
||||||
|
serverId: "local",
|
||||||
|
isLoading: true,
|
||||||
|
isFetching: true,
|
||||||
|
error: "unavailable",
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
expect(result.isLoading).toBe(true);
|
||||||
"githubUrl",
|
expect(result.isFetching).toBe(true);
|
||||||
"hostCount",
|
expect(result.hostErrors).toEqual([
|
||||||
"hosts",
|
{ serverId: "local", serverName: "Local", message: "unavailable" },
|
||||||
"onlineHostCount",
|
|
||||||
"projectCustomName",
|
|
||||||
"projectKey",
|
|
||||||
"projectName",
|
|
||||||
"totalWorkspaceCount",
|
|
||||||
]);
|
|
||||||
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
|
||||||
"gitRuntime",
|
|
||||||
"githubRuntime",
|
|
||||||
"isOnline",
|
|
||||||
"repoRoot",
|
|
||||||
"serverId",
|
|
||||||
"serverName",
|
|
||||||
"workspaceCount",
|
|
||||||
"workspaces",
|
|
||||||
]);
|
|
||||||
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
|
||||||
"currentBranch",
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"status",
|
|
||||||
"workspaceKind",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type EmptyProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||||
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
workspaces: WorkspaceDescriptor[];
|
workspaces: WorkspaceDescriptor[];
|
||||||
emptyProjects: EmptyProjectDescriptor[];
|
projects: ProjectDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectHostRuntimeState {
|
export interface ProjectHostRuntimeState {
|
||||||
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
|
|||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
serverName: host.label,
|
serverName: host.label,
|
||||||
workspaces: Array.from(session?.workspaces.values() ?? []),
|
workspaces: Array.from(session?.workspaces.values() ?? []),
|
||||||
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
|
projects: Array.from(session?.projects.values() ?? []),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
|
|||||||
serverName: replica.serverName,
|
serverName: replica.serverName,
|
||||||
isOnline: runtimeState?.isOnline ?? false,
|
isOnline: runtimeState?.isOnline ?? false,
|
||||||
workspaces: replica.workspaces,
|
workspaces: replica.workspaces,
|
||||||
emptyProjects: replica.emptyProjects,
|
projects: replica.projects,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const hostErrors = input.replicas.flatMap((replica) => {
|
const hostErrors = input.replicas.flatMap((replica) => {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
|
||||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||||
import { useHostProjects } from "@/projects/host-projects";
|
import { useHostProjects } from "@/projects/host-projects";
|
||||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
|
||||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
|
||||||
import {
|
import {
|
||||||
buildSidebarWorkspacePlacementModel,
|
buildSidebarWorkspacePlacementModel,
|
||||||
computeSidebarOrderUpdates,
|
computeSidebarOrderUpdates,
|
||||||
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
const snapshot = runtime.getSnapshot(serverId);
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
if (snapshot?.connectionStatus !== "online") continue;
|
if (snapshot?.connectionStatus !== "online") continue;
|
||||||
const client = runtime.getClient(serverId);
|
void runtime.refreshDirectories(serverId).catch((error) => {
|
||||||
if (!client) continue;
|
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||||
void (async () => {
|
serverId,
|
||||||
const next = new Map<string, WorkspaceDescriptor>();
|
error,
|
||||||
try {
|
});
|
||||||
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
|
});
|
||||||
client,
|
|
||||||
sort: [{ key: "activity_at", direction: "desc" }],
|
|
||||||
});
|
|
||||||
for (const workspace of workspaces) {
|
|
||||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.set(workspace.id, workspace);
|
|
||||||
}
|
|
||||||
const store = useSessionStore.getState();
|
|
||||||
store.setWorkspaces(serverId, next);
|
|
||||||
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
|
|
||||||
// a freshly-added project from the sidebar.
|
|
||||||
store.setEmptyProjects(serverId, emptyProjects);
|
|
||||||
store.setHasHydratedWorkspaces(serverId, true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
|
||||||
serverId,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
// ignore explicit refresh failures; hook keeps existing data
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
}, [isActive, runtime, serverIds]);
|
}, [isActive, runtime, serverIds]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import type {
|
|||||||
WorkspaceStructureProject,
|
WorkspaceStructureProject,
|
||||||
} from "@/projects/workspace-structure";
|
} from "@/projects/workspace-structure";
|
||||||
|
|
||||||
export interface HostProjectListItem {
|
export type HostProjectListItem = WorkspaceStructureProject;
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
|
||||||
iconWorkingDir: string;
|
|
||||||
hosts: WorkspaceStructureHostPlacement[];
|
|
||||||
workspaceKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostProjectRouteContext {
|
export interface HostProjectRouteContext {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
|
|||||||
return projectKind === "git";
|
return projectKind === "git";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostProjectList(input: {
|
|
||||||
projects: readonly WorkspaceStructureProject[];
|
|
||||||
}): HostProjectListItem[] {
|
|
||||||
return input.projects.map((project) => ({
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
projectKind: project.projectKind,
|
|
||||||
iconWorkingDir: project.iconWorkingDir,
|
|
||||||
hosts: project.hosts,
|
|
||||||
workspaceKeys: project.workspaceKeys,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
|
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
|
||||||
const projectKey = trimOptional(route.projectId);
|
const projectId = route.projectId?.trim() || undefined;
|
||||||
const iconWorkingDir = trimOptional(route.sourceDirectory);
|
const iconWorkingDir = trimOptional(route.sourceDirectory);
|
||||||
if (!projectKey || !iconWorkingDir) {
|
if (!projectId || !iconWorkingDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
projectKey,
|
projectKey: projectId,
|
||||||
projectName: trimOptional(route.displayName) ?? projectKey,
|
projectName: trimOptional(route.displayName) || projectId,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
iconWorkingDir,
|
iconWorkingDir,
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: route.serverId,
|
serverId: route.serverId,
|
||||||
|
projectId,
|
||||||
iconWorkingDir,
|
iconWorkingDir,
|
||||||
canCreateWorktree: true,
|
canCreateWorktree: true,
|
||||||
},
|
},
|
||||||
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
|
|||||||
if (!input.workspace) {
|
if (!input.workspace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const projectKey = input.workspace.projectId.trim();
|
const projectId = input.workspace.projectId.trim() || undefined;
|
||||||
|
if (!projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
const iconWorkingDir = input.workspace.projectRootPath.trim();
|
||||||
if (!projectKey || !iconWorkingDir) {
|
if (!iconWorkingDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
|
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
|
||||||
return {
|
return {
|
||||||
projectKey,
|
projectKey: projectId,
|
||||||
projectName: input.workspace.projectDisplayName || projectKey,
|
projectName: input.workspace.projectDisplayName || projectId,
|
||||||
projectKind: input.workspace.projectKind,
|
projectKind: input.workspace.projectKind,
|
||||||
iconWorkingDir,
|
iconWorkingDir,
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
|
projectId: input.workspace.projectId,
|
||||||
iconWorkingDir,
|
iconWorkingDir,
|
||||||
canCreateWorktree: canCreate,
|
canCreateWorktree: canCreate,
|
||||||
},
|
},
|
||||||
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
|
|||||||
return project.hosts.some((h) => h.canCreateWorktree);
|
return project.hosts.some((h) => h.canCreateWorktree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHostProjectPlacement(
|
||||||
|
project: HostProjectListItem,
|
||||||
|
serverId: string,
|
||||||
|
): WorkspaceStructureHostPlacement | null {
|
||||||
|
for (const host of project.hosts) {
|
||||||
|
if (host.serverId === serverId) return host;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getHostProjectSourceDirectory(
|
export function getHostProjectSourceDirectory(
|
||||||
project: HostProjectListItem,
|
project: HostProjectListItem,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
|
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
|
||||||
|
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canCreateWorkspaceForHostProject(input: {
|
export function canCreateWorkspaceForHostProject(input: {
|
||||||
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
allowAllProjects: boolean;
|
allowAllProjects: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
|
const host = getHostProjectPlacement(input.project, input.serverId);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
|
|||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const candidatePlacement = candidate.hosts.find(
|
||||||
|
(host) => host.serverId === input.serverId && host.projectId,
|
||||||
|
);
|
||||||
const hydratedProject =
|
const hydratedProject =
|
||||||
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
|
(candidatePlacement
|
||||||
|
? input.projects.find((project) =>
|
||||||
|
project.hosts.some(
|
||||||
|
(host) =>
|
||||||
|
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined) ??
|
||||||
|
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
|
||||||
|
candidate;
|
||||||
if (
|
if (
|
||||||
canCreateWorkspaceForHostProject({
|
canCreateWorkspaceForHostProject({
|
||||||
project: hydratedProject,
|
project: hydratedProject,
|
||||||
|
|||||||
@@ -1,312 +1,64 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { HostProjectListItem } from "./host-project-model";
|
||||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
|
||||||
import {
|
import {
|
||||||
buildHostProjectList,
|
|
||||||
canCreateWorkspaceForHostProject,
|
canCreateWorkspaceForHostProject,
|
||||||
canCreateWorktreeForProjectKind,
|
getHostProjectId,
|
||||||
filterWorkspaceProjectsForHost,
|
|
||||||
getHostProjectSourceDirectory,
|
getHostProjectSourceDirectory,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
hostProjectFromWorkspace,
|
|
||||||
resolveInitialWorkspaceProject,
|
|
||||||
resolveInitialWorktreeProject,
|
|
||||||
resolveSelectedHostProject,
|
|
||||||
type HostProjectListItem,
|
|
||||||
} from "./host-project-model";
|
} from "./host-project-model";
|
||||||
|
|
||||||
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
|
function project(): HostProjectListItem {
|
||||||
return {
|
return {
|
||||||
projectKey: input.projectKey ?? "project-a",
|
projectKey: "remote:github.com/acme/app",
|
||||||
projectName: input.projectName ?? "Project A",
|
projectName: "acme/app",
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: "git",
|
||||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
iconWorkingDir: "/repo/a",
|
||||||
hosts: input.hosts ?? [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
projectId: "prj_a",
|
||||||
canCreateWorktree: input.projectKind !== "directory",
|
iconWorkingDir: "/repo/a",
|
||||||
|
canCreateWorktree: true,
|
||||||
},
|
},
|
||||||
],
|
|
||||||
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
|
|
||||||
return {
|
|
||||||
projectKey: input.projectKey ?? "project-a",
|
|
||||||
projectName: input.projectName ?? "Project A",
|
|
||||||
projectKind: input.projectKind ?? "git",
|
|
||||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
|
||||||
hosts: input.hosts ?? [
|
|
||||||
{
|
{
|
||||||
serverId: "host-a",
|
serverId: "host-b",
|
||||||
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
|
projectId: "prj_b",
|
||||||
|
iconWorkingDir: "/repo/b",
|
||||||
canCreateWorktree: true,
|
canCreateWorktree: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
|
workspaceKeys: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
describe("host project lookups", () => {
|
||||||
return {
|
test("returns host-local ids and roots without falling back to the grouping key", () => {
|
||||||
id: input.id ?? "workspace-a",
|
expect(getHostProjectId(project(), "host-b")).toBe("prj_b");
|
||||||
projectId: input.projectId ?? "project-a",
|
expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b");
|
||||||
projectDisplayName: input.projectDisplayName ?? "Project A",
|
expect(getHostProjectId(project(), "missing")).toBeNull();
|
||||||
projectRootPath: input.projectRootPath ?? "/repo/a",
|
|
||||||
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
|
|
||||||
projectKind: input.projectKind ?? "git",
|
|
||||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
|
||||||
name: input.name ?? "main",
|
|
||||||
status: input.status ?? "done",
|
|
||||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
|
||||||
archivingAt: input.archivingAt ?? null,
|
|
||||||
diffStat: input.diffStat ?? null,
|
|
||||||
scripts: input.scripts ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeProject = hostProject({
|
|
||||||
projectKey: "route-project",
|
|
||||||
projectName: "Route Project",
|
|
||||||
iconWorkingDir: "/repo/route",
|
|
||||||
});
|
|
||||||
const lastActiveProject = hostProject({
|
|
||||||
projectKey: "last-project",
|
|
||||||
projectName: "Last Project",
|
|
||||||
iconWorkingDir: "/repo/last",
|
|
||||||
});
|
|
||||||
const firstProject = hostProject({
|
|
||||||
projectKey: "first-project",
|
|
||||||
projectName: "First Project",
|
|
||||||
iconWorkingDir: "/repo/first",
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("host project list", () => {
|
|
||||||
it("preserves workspace-structure order and project metadata", () => {
|
|
||||||
expect(
|
|
||||||
buildHostProjectList({
|
|
||||||
projects: [
|
|
||||||
structureProject({
|
|
||||||
projectKey: "project-b",
|
|
||||||
projectName: "Project B",
|
|
||||||
projectKind: "directory",
|
|
||||||
iconWorkingDir: "/repo/b",
|
|
||||||
workspaceKeys: ["workspace-b"],
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
|
|
||||||
}),
|
|
||||||
structureProject({
|
|
||||||
projectKey: "project-a",
|
|
||||||
projectName: "Project A",
|
|
||||||
projectKind: "git",
|
|
||||||
iconWorkingDir: "/repo/a",
|
|
||||||
workspaceKeys: ["workspace-a"],
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
projectKey: "project-b",
|
|
||||||
projectName: "Project B",
|
|
||||||
projectKind: "directory",
|
|
||||||
iconWorkingDir: "/repo/b",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
|
|
||||||
workspaceKeys: ["workspace-b"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
projectKey: "project-a",
|
|
||||||
projectName: "Project A",
|
|
||||||
projectKind: "git",
|
|
||||||
iconWorkingDir: "/repo/a",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
|
||||||
workspaceKeys: ["workspace-a"],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps worktree capability separate from project listability", () => {
|
test("checks workspace creation against the selected host placement", () => {
|
||||||
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
|
|
||||||
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses route project before last active project when it can create worktrees", () => {
|
|
||||||
expect(
|
expect(
|
||||||
resolveInitialWorktreeProject({
|
canCreateWorkspaceForHostProject({
|
||||||
routeProject,
|
project: project(),
|
||||||
lastActiveProject,
|
|
||||||
projects: [firstProject],
|
|
||||||
}),
|
|
||||||
).toEqual(routeProject);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips non-worktree route and last-active projects", () => {
|
|
||||||
expect(
|
|
||||||
resolveInitialWorktreeProject({
|
|
||||||
routeProject: {
|
|
||||||
...routeProject,
|
|
||||||
projectKind: "directory",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
|
|
||||||
},
|
|
||||||
lastActiveProject: {
|
|
||||||
...lastActiveProject,
|
|
||||||
projectKind: "directory",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
...firstProject,
|
|
||||||
projectKind: "directory",
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toMatchObject({ projectKey: "git-project" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the project empty when no worktree-capable project is available", () => {
|
|
||||||
expect(
|
|
||||||
resolveInitialWorktreeProject({
|
|
||||||
routeProject: null,
|
|
||||||
lastActiveProject: null,
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
...firstProject,
|
|
||||||
projectKind: "directory",
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters new-workspace projects to the selected host", () => {
|
|
||||||
const hostAOnly = hostProject({
|
|
||||||
projectKey: "host-a-project",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
|
||||||
});
|
|
||||||
const hostBOnly = hostProject({
|
|
||||||
projectKey: "host-b-project",
|
|
||||||
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterWorkspaceProjectsForHost({
|
|
||||||
projects: [hostAOnly, hostBOnly],
|
|
||||||
serverId: "host-b",
|
serverId: "host-b",
|
||||||
allowAllProjects: false,
|
allowAllProjects: false,
|
||||||
}).map((project) => project.projectKey),
|
|
||||||
).toEqual(["host-b-project"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows directory projects only when workspace multiplicity is supported", () => {
|
|
||||||
const directoryProject = hostProject({
|
|
||||||
projectKey: "directory-project",
|
|
||||||
projectKind: "directory",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
canCreateWorkspaceForHostProject({
|
|
||||||
project: directoryProject,
|
|
||||||
serverId: "host-a",
|
|
||||||
allowAllProjects: false,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canCreateWorkspaceForHostProject({
|
|
||||||
project: directoryProject,
|
|
||||||
serverId: "host-a",
|
|
||||||
allowAllProjects: true,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back when the route project is not available on the selected host", () => {
|
test("builds an unhydrated route project around the routed project id", () => {
|
||||||
const selectedHostProject = hostProject({
|
|
||||||
projectKey: "selected-host-project",
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveInitialWorkspaceProject({
|
|
||||||
routeProject,
|
|
||||||
lastActiveProject: null,
|
|
||||||
projects: [selectedHostProject],
|
|
||||||
serverId: "host-b",
|
|
||||||
allowAllProjects: false,
|
|
||||||
}),
|
|
||||||
).toEqual(selectedHostProject);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves the selected host project source directory", () => {
|
|
||||||
const project = hostProject({
|
|
||||||
hosts: [
|
|
||||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
|
|
||||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
|
|
||||||
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a selected route project available before project hydration", () => {
|
|
||||||
expect(
|
|
||||||
resolveSelectedHostProject({
|
|
||||||
selectedProjectKey: routeProject.projectKey,
|
|
||||||
projects: [],
|
|
||||||
routeProject,
|
|
||||||
lastActiveProject: null,
|
|
||||||
}),
|
|
||||||
).toEqual(routeProject);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts route project only when it has a key and source directory", () => {
|
|
||||||
expect(
|
expect(
|
||||||
hostProjectFromRoute({
|
hostProjectFromRoute({
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
projectId: "project-a",
|
projectId: "prj_a",
|
||||||
displayName: "Project A",
|
displayName: "App",
|
||||||
sourceDirectory: "/repo/a",
|
sourceDirectory: "/repo/a",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
|
||||||
projectKey: "project-a",
|
|
||||||
projectName: "Project A",
|
|
||||||
projectKind: "git",
|
|
||||||
iconWorkingDir: "/repo/a",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
|
||||||
workspaceKeys: [],
|
|
||||||
});
|
|
||||||
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts last active workspaces with matching worktree capability", () => {
|
|
||||||
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
|
|
||||||
projectKey: "project-a",
|
|
||||||
projectName: "Project A",
|
|
||||||
projectKind: "git",
|
|
||||||
iconWorkingDir: "/repo/a",
|
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
|
|
||||||
workspaceKeys: ["host-a:workspace-a"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
hostProjectFromWorkspace({
|
|
||||||
serverId: "host-a",
|
|
||||||
workspace: workspace({ projectKind: "directory" }),
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
projectKind: "directory",
|
projectKey: "prj_a",
|
||||||
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
|
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
|
||||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildHostProjectList,
|
|
||||||
canCreateWorkspaceForHostProject,
|
canCreateWorkspaceForHostProject,
|
||||||
canCreateWorktreeForProjectKind,
|
canCreateWorktreeForProjectKind,
|
||||||
filterWorkspaceProjectsForHost,
|
filterWorkspaceProjectsForHost,
|
||||||
getHostProjectSourceDirectory,
|
getHostProjectSourceDirectory,
|
||||||
|
getHostProjectId,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
hostProjectFromWorkspace,
|
hostProjectFromWorkspace,
|
||||||
resolveInitialWorkspaceProject,
|
resolveInitialWorkspaceProject,
|
||||||
@@ -19,10 +18,5 @@ export {
|
|||||||
|
|
||||||
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
|
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
|
||||||
const workspaceStructure = useWorkspaceStructure(serverIds);
|
const workspaceStructure = useWorkspaceStructure(serverIds);
|
||||||
return useMemo(() => {
|
return workspaceStructure.projects;
|
||||||
if (workspaceStructure.projects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return buildHostProjectList({ projects: workspaceStructure.projects });
|
|
||||||
}, [workspaceStructure.projects]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
|
|
||||||
const project: ProjectRemoveProject = {
|
const project: ProjectRemoveProject = {
|
||||||
projectKey: "remote:github.com/acme/app",
|
projectKey: "remote:github.com/acme/app",
|
||||||
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
hosts: [
|
||||||
|
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||||
|
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createProjectRemoveClient() {
|
function createProjectRemoveClient() {
|
||||||
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
|
|||||||
|
|
||||||
expect(readiness).toEqual({
|
expect(readiness).toEqual({
|
||||||
kind: "ready",
|
kind: "ready",
|
||||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
targets: [
|
||||||
|
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||||
|
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const outcome = await removeProjectFromHosts({
|
const outcome = await removeProjectFromHosts({
|
||||||
projectKey: project.projectKey,
|
targets: readiness.kind === "ready" ? readiness.targets : [],
|
||||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
|
||||||
getClient: (serverId) => {
|
getClient: (serverId) => {
|
||||||
if (serverId === "host-a") return hostA.client;
|
if (serverId === "host-a") return hostA.client;
|
||||||
if (serverId === "host-b") return hostB.client;
|
if (serverId === "host-b") return hostB.client;
|
||||||
@@ -60,16 +65,18 @@ describe("project remove policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
|
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
|
||||||
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
|
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
|
||||||
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
|
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports disconnected hosts before sending any remove request", async () => {
|
it("reports disconnected hosts before sending any remove request", async () => {
|
||||||
const hostA = createProjectRemoveClient();
|
const hostA = createProjectRemoveClient();
|
||||||
|
|
||||||
const outcome = await removeProjectFromHosts({
|
const outcome = await removeProjectFromHosts({
|
||||||
projectKey: project.projectKey,
|
targets: [
|
||||||
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
|
{ serverId: "host-a", projectId: "prj_host_a" },
|
||||||
|
{ serverId: "host-b", projectId: "prj_host_b" },
|
||||||
|
],
|
||||||
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
|
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
|
|||||||
|
|
||||||
interface ProjectRemoveHost {
|
interface ProjectRemoveHost {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectRemoveProject {
|
export interface ProjectRemoveProject {
|
||||||
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
|
|||||||
|
|
||||||
export interface ProjectRemoveTarget {
|
export interface ProjectRemoveTarget {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectRemoveReadiness =
|
export type ProjectRemoveReadiness =
|
||||||
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
|
|||||||
unsupportedServerIds.push(host.serverId);
|
unsupportedServerIds.push(host.serverId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
targets.push({ serverId: host.serverId });
|
targets.push({
|
||||||
|
serverId: host.serverId,
|
||||||
|
projectId: host.projectId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unsupportedServerIds.length > 0) {
|
if (unsupportedServerIds.length > 0) {
|
||||||
@@ -59,11 +64,10 @@ export function getCurrentProjectRemoveReadiness(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeProjectFromHosts(input: {
|
export async function removeProjectFromHosts(input: {
|
||||||
projectKey: string;
|
|
||||||
targets: readonly ProjectRemoveTarget[];
|
targets: readonly ProjectRemoveTarget[];
|
||||||
getClient: (serverId: string) => ProjectRemoveClient | null;
|
getClient: (serverId: string) => ProjectRemoveClient | null;
|
||||||
}): Promise<ProjectRemoveOutcome> {
|
}): Promise<ProjectRemoveOutcome> {
|
||||||
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
|
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
|
||||||
const disconnectedServerIds: string[] = [];
|
const disconnectedServerIds: string[] = [];
|
||||||
|
|
||||||
for (const target of input.targets) {
|
for (const target of input.targets) {
|
||||||
@@ -72,7 +76,7 @@ export async function removeProjectFromHosts(input: {
|
|||||||
disconnectedServerIds.push(target.serverId);
|
disconnectedServerIds.push(target.serverId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
clients.push({ serverId: target.serverId, client });
|
clients.push({ serverId: target.serverId, projectId: target.projectId, client });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disconnectedServerIds.length > 0) {
|
if (disconnectedServerIds.length > 0) {
|
||||||
@@ -80,8 +84,8 @@ export async function removeProjectFromHosts(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
clients.map(async ({ client }) => {
|
clients.map(async ({ client, projectId }) => {
|
||||||
await client.removeProject(input.projectKey);
|
await client.removeProject(projectId);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const failedServerIds: string[] = [];
|
const failedServerIds: string[] = [];
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
||||||
import {
|
|
||||||
type EmptyProjectDescriptor,
|
|
||||||
normalizeEmptyProjectDescriptor,
|
|
||||||
normalizeWorkspaceDescriptor,
|
|
||||||
type WorkspaceDescriptor,
|
|
||||||
} from "@/stores/session-store";
|
|
||||||
|
|
||||||
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
|
|
||||||
export type FetchWorkspacesSort = NonNullable<
|
|
||||||
Parameters<DaemonClient["fetchWorkspaces"]>[0]
|
|
||||||
>["sort"];
|
|
||||||
|
|
||||||
export interface FetchWorkspaceDescriptorsResult {
|
|
||||||
workspaces: WorkspaceDescriptor[];
|
|
||||||
/**
|
|
||||||
* Project parents with no active workspaces. The daemon only rides these on
|
|
||||||
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
|
|
||||||
* workspace yet shows up here and nowhere else — drop it and the project is
|
|
||||||
* invisible to anything that derives projects from workspaces alone.
|
|
||||||
*/
|
|
||||||
emptyProjects: EmptyProjectDescriptor[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAllWorkspaceDescriptors(input: {
|
|
||||||
client: FetchWorkspacesClient;
|
|
||||||
sort: FetchWorkspacesSort;
|
|
||||||
}): Promise<FetchWorkspaceDescriptorsResult> {
|
|
||||||
const workspaces: WorkspaceDescriptor[] = [];
|
|
||||||
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
|
|
||||||
let cursor: string | null = null;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const payload = await input.client.fetchWorkspaces({
|
|
||||||
sort: input.sort,
|
|
||||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
|
||||||
});
|
|
||||||
workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
|
||||||
for (const project of payload.emptyProjects ?? []) {
|
|
||||||
const descriptor = normalizeEmptyProjectDescriptor(project);
|
|
||||||
emptyProjects.set(descriptor.projectId, descriptor);
|
|
||||||
}
|
|
||||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cursor = payload.pageInfo.nextCursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { buildWorkspaceStructureProjects } from "./workspace-structure";
|
||||||
|
|
||||||
|
function project(input: {
|
||||||
|
id: string;
|
||||||
|
key: string | null;
|
||||||
|
root: string;
|
||||||
|
name?: string;
|
||||||
|
}): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: input.id,
|
||||||
|
projectKey: input.key,
|
||||||
|
projectDisplayName: input.name ?? "acme/app",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: input.root,
|
||||||
|
projectKind: "git",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
projectDisplayName: "acme/app",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: root,
|
||||||
|
workspaceDirectory: root,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildWorkspaceStructureProjects", () => {
|
||||||
|
test("groups the same project key across hosts and keeps host-local ids", () => {
|
||||||
|
const key = "remote:github.com/acme/app";
|
||||||
|
const result = buildWorkspaceStructureProjects({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
projects: [project({ id: "prj_a", key, root: "/a/app" })],
|
||||||
|
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
projects: [project({ id: "prj_b", key, root: "/b/app" })],
|
||||||
|
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
projectKey: key,
|
||||||
|
hosts: [
|
||||||
|
{ serverId: "host-a", projectId: "prj_a" },
|
||||||
|
{ serverId: "host-b", projectId: "prj_b" },
|
||||||
|
],
|
||||||
|
workspaceKeys: ["host-a:ws-a", "host-b:ws-b"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps two clones with the same key on one host separate", () => {
|
||||||
|
const key = "remote:github.com/acme/app";
|
||||||
|
const result = buildWorkspaceStructureProjects({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
projects: [
|
||||||
|
project({ id: "prj_one", key, root: "/repos/one" }),
|
||||||
|
project({ id: "prj_two", key, root: "/repos/two" }),
|
||||||
|
],
|
||||||
|
workspaces: [
|
||||||
|
workspace("ws-one", "prj_one", "/repos/one"),
|
||||||
|
workspace("ws-two", "prj_two", "/repos/two"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
|
||||||
|
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
|
||||||
|
"host-a:ws-one",
|
||||||
|
"host-a:ws-two",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not let project order choose which same-host clone groups with another host", () => {
|
||||||
|
const key = "remote:github.com/acme/app";
|
||||||
|
const hostAProjects = [
|
||||||
|
project({ id: "prj_one", key, root: "/repos/one" }),
|
||||||
|
project({ id: "prj_two", key, root: "/repos/two" }),
|
||||||
|
];
|
||||||
|
const build = (projects: ProjectDescriptor[]) =>
|
||||||
|
buildWorkspaceStructureProjects({
|
||||||
|
sessions: [
|
||||||
|
{ serverId: "host-a", projects, workspaces: [] },
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
projects: [project({ id: "prj_remote", key, root: "/repos/remote" })],
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
|
||||||
|
const identities: Record<string, string> = {};
|
||||||
|
for (const group of projects) {
|
||||||
|
for (const host of group.hosts) identities[host.projectId] = group.projectKey;
|
||||||
|
}
|
||||||
|
return identities;
|
||||||
|
};
|
||||||
|
|
||||||
|
const forward = build(hostAProjects);
|
||||||
|
const reversed = build(hostAProjects.toReversed());
|
||||||
|
|
||||||
|
expect(forward).toHaveLength(3);
|
||||||
|
expect(identitiesByProjectId(forward)).toEqual(identitiesByProjectId(reversed));
|
||||||
|
expect(new Set(Object.values(identitiesByProjectId(forward))).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps projects without persisted keys scoped to their host", () => {
|
||||||
|
const result = buildWorkspaceStructureProjects({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||||
|
|
||||||
export interface WorkspaceStructureHostPlacement {
|
export interface WorkspaceStructureHostPlacement {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
iconWorkingDir: string;
|
iconWorkingDir: string;
|
||||||
canCreateWorktree: boolean;
|
canCreateWorktree: boolean;
|
||||||
}
|
}
|
||||||
@@ -20,126 +21,55 @@ export interface WorkspaceStructure {
|
|||||||
projects: WorkspaceStructureProject[];
|
projects: WorkspaceStructureProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareWorkspaceStructureItems(
|
|
||||||
left: { workspaceId: string; workspaceName: string },
|
|
||||||
right: { workspaceId: string; workspaceName: string },
|
|
||||||
): number {
|
|
||||||
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
if (nameDelta !== 0) {
|
|
||||||
return nameDelta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareWorkspaceStructureProjects(
|
|
||||||
left: WorkspaceStructureProject,
|
|
||||||
right: WorkspaceStructureProject,
|
|
||||||
): number {
|
|
||||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
|
|
||||||
return projectKind === "git";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceStructureSession {
|
interface WorkspaceStructureSession {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projects: Iterable<ProjectDescriptor>;
|
||||||
workspaces: Iterable<WorkspaceDescriptor>;
|
workspaces: Iterable<WorkspaceDescriptor>;
|
||||||
emptyProjects?: Iterable<EmptyProjectDescriptor>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectDraft {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
hasCustomName: boolean;
|
||||||
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
|
iconWorkingDir: string;
|
||||||
|
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
||||||
|
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single app boundary that turns host-local projects into grouped display projects. */
|
||||||
export function buildWorkspaceStructureProjects(input: {
|
export function buildWorkspaceStructureProjects(input: {
|
||||||
sessions: WorkspaceStructureSession[];
|
sessions: WorkspaceStructureSession[];
|
||||||
}): WorkspaceStructureProject[] {
|
}): WorkspaceStructureProject[] {
|
||||||
const byProject = new Map<
|
const byProject = new Map<string, ProjectDraft>();
|
||||||
string,
|
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
|
||||||
{
|
const keyCountsByServer = new Map<string, Map<string, number>>();
|
||||||
projectKey: string;
|
const viewKeyByServerProjectId = new Map<string, Map<string, string>>();
|
||||||
projectName: string;
|
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
|
||||||
iconWorkingDir: string;
|
|
||||||
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
|
||||||
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const session of input.sessions) {
|
for (const session of input.sessions) {
|
||||||
for (const emptyProject of session.emptyProjects ?? []) {
|
for (const project of session.projects) {
|
||||||
const projectKey = emptyProject.projectId;
|
projectEntries.push({ serverId: session.serverId, project });
|
||||||
const placement = {
|
const sharedKey = project.projectKey ?? null;
|
||||||
serverId: session.serverId,
|
if (sharedKey) {
|
||||||
iconWorkingDir: emptyProject.projectRootPath,
|
const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map());
|
||||||
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
|
counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1);
|
||||||
};
|
|
||||||
const existing = byProject.get(projectKey);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
byProject.set(projectKey, {
|
|
||||||
projectKey,
|
|
||||||
projectName:
|
|
||||||
emptyProject.projectCustomName ??
|
|
||||||
emptyProject.projectDisplayName ??
|
|
||||||
projectDisplayNameFromProjectId(projectKey),
|
|
||||||
projectKind: emptyProject.projectKind,
|
|
||||||
iconWorkingDir: emptyProject.projectRootPath,
|
|
||||||
hosts: new Map([[session.serverId, placement]]),
|
|
||||||
workspaces: [],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.hosts.set(session.serverId, placement);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { serverId, project } of projectEntries) {
|
||||||
|
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project });
|
||||||
|
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
|
||||||
|
project.projectId,
|
||||||
|
projectKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of input.sessions) {
|
||||||
for (const workspace of session.workspaces) {
|
for (const workspace of session.workspaces) {
|
||||||
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
|
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
|
||||||
const existing = byProject.get(projectKey);
|
if (!projectKey) continue;
|
||||||
|
byProject.get(projectKey)?.workspaces.push({
|
||||||
if (!existing) {
|
|
||||||
byProject.set(projectKey, {
|
|
||||||
projectKey,
|
|
||||||
projectName:
|
|
||||||
workspace.projectCustomName ??
|
|
||||||
workspace.projectDisplayName ??
|
|
||||||
projectDisplayNameFromProjectId(projectKey),
|
|
||||||
projectKind: workspace.projectKind,
|
|
||||||
iconWorkingDir: workspace.projectRootPath,
|
|
||||||
hosts: new Map([
|
|
||||||
[
|
|
||||||
session.serverId,
|
|
||||||
{
|
|
||||||
serverId: session.serverId,
|
|
||||||
iconWorkingDir: workspace.projectRootPath,
|
|
||||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
workspaces: [
|
|
||||||
{
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
workspaceName: workspace.name,
|
|
||||||
workspaceKey: `${session.serverId}:${workspace.id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.hosts.set(session.serverId, {
|
|
||||||
serverId: session.serverId,
|
|
||||||
iconWorkingDir: workspace.projectRootPath,
|
|
||||||
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
|
|
||||||
});
|
|
||||||
existing.workspaces.push({
|
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
workspaceName: workspace.name,
|
workspaceName: workspace.name,
|
||||||
workspaceKey: `${session.serverId}:${workspace.id}`,
|
workspaceKey: `${session.serverId}:${workspace.id}`,
|
||||||
@@ -147,19 +77,82 @@ export function buildWorkspaceStructureProjects(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects: WorkspaceStructureProject[] = [];
|
return Array.from(byProject.values())
|
||||||
for (const raw of byProject.values()) {
|
.map((draft) => ({
|
||||||
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
|
projectKey: draft.projectKey,
|
||||||
projects.push({
|
projectName: draft.projectName,
|
||||||
projectKey: raw.projectKey,
|
projectKind: draft.projectKind,
|
||||||
projectName: raw.projectName,
|
iconWorkingDir: draft.iconWorkingDir,
|
||||||
projectKind: raw.projectKind,
|
hosts: Array.from(draft.hosts.values()),
|
||||||
iconWorkingDir: raw.iconWorkingDir,
|
workspaceKeys: draft.workspaces
|
||||||
hosts: Array.from(raw.hosts.values()),
|
.sort(compareWorkspaceStructureItems)
|
||||||
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
|
.map((workspace) => workspace.workspaceKey),
|
||||||
|
}))
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.projectName.localeCompare(right.projectName, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProjectToView(input: {
|
||||||
|
byProject: Map<string, ProjectDraft>;
|
||||||
|
keyCountsByServer: Map<string, Map<string, number>>;
|
||||||
|
serverId: string;
|
||||||
|
project: ProjectDescriptor;
|
||||||
|
}): string {
|
||||||
|
const { byProject, keyCountsByServer, serverId, project } = input;
|
||||||
|
const sharedKey = project.projectKey ?? null;
|
||||||
|
const canUseSharedKey =
|
||||||
|
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
|
||||||
|
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]);
|
||||||
|
const placement: WorkspaceStructureHostPlacement = {
|
||||||
|
serverId,
|
||||||
|
projectId: project.projectId,
|
||||||
|
iconWorkingDir: project.projectRootPath,
|
||||||
|
canCreateWorktree: project.projectKind === "git",
|
||||||
|
};
|
||||||
|
const draft = byProject.get(projectKey);
|
||||||
|
if (!draft) {
|
||||||
|
byProject.set(projectKey, {
|
||||||
|
projectKey,
|
||||||
|
projectName:
|
||||||
|
project.projectCustomName ??
|
||||||
|
project.projectDisplayName ??
|
||||||
|
projectDisplayNameFromProjectId(project.projectId),
|
||||||
|
hasCustomName: Boolean(project.projectCustomName),
|
||||||
|
projectKind: project.projectKind,
|
||||||
|
iconWorkingDir: project.projectRootPath,
|
||||||
|
hosts: new Map([[serverId, placement]]),
|
||||||
|
workspaces: [],
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
if (project.projectCustomName && !draft.hasCustomName) {
|
||||||
|
draft.projectName = project.projectCustomName;
|
||||||
|
draft.hasCustomName = true;
|
||||||
|
}
|
||||||
|
draft.hosts.set(serverId, placement);
|
||||||
}
|
}
|
||||||
|
return projectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing !== undefined) return existing;
|
||||||
|
const value = create();
|
||||||
|
map.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
projects.sort(compareWorkspaceStructureProjects);
|
function compareWorkspaceStructureItems(
|
||||||
return projects;
|
left: { workspaceId: string; workspaceName: string },
|
||||||
|
right: { workspaceId: string; workspaceName: string },
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
||||||
|
|
||||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||||
|
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||||
|
|
||||||
class FakeDirectoryClient {
|
class FakeDirectoryClient {
|
||||||
fetchAgentsCalls = 0;
|
fetchAgentsCalls = 0;
|
||||||
fetchWorkspacesCalls = 0;
|
fetchWorkspacesCalls = 0;
|
||||||
|
listProjectsCalls = 0;
|
||||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||||
private readonly handlers = new Map<
|
private readonly handlers = new Map<
|
||||||
SessionOutboundMessage["type"],
|
SessionOutboundMessage["type"],
|
||||||
@@ -63,6 +65,22 @@ class FakeDirectoryClient {
|
|||||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listProjects(): Promise<ProjectListResult> {
|
||||||
|
this.listProjectsCalls += 1;
|
||||||
|
return {
|
||||||
|
requestId: "projects",
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
|
projectDisplayName: "acme/app",
|
||||||
|
projectRootPath: "/repo/app",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIds = new Set<string>();
|
const serverIds = new Set<string>();
|
||||||
@@ -119,6 +137,28 @@ describe("DirectorySync session readiness", () => {
|
|||||||
directory.dispose();
|
directory.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches the project descriptor channel when the daemon advertises it", async () => {
|
||||||
|
const serverId = "project-list";
|
||||||
|
const { client, directory } = createDirectory(serverId);
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||||
|
store.updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: null,
|
||||||
|
version: "test",
|
||||||
|
features: { workspaceMultiplicity: true, projectList: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await directory.refreshWorkspaces();
|
||||||
|
|
||||||
|
expect(client.listProjectsCalls).toBe(1);
|
||||||
|
expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
|
});
|
||||||
|
directory.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||||
const serverId = "session-wait-reconnect";
|
const serverId = "session-wait-reconnect";
|
||||||
const { client, directory } = createDirectory(serverId);
|
const { client, directory } = createDirectory(serverId);
|
||||||
@@ -200,17 +240,14 @@ describe("DirectorySync session readiness", () => {
|
|||||||
});
|
});
|
||||||
await refresh;
|
await refresh;
|
||||||
|
|
||||||
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
|
const projects = useSessionStore.getState().sessions[serverId]?.projects;
|
||||||
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
|
expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]);
|
||||||
"snapshot-project",
|
expect(projects?.get("snapshot-project")).toMatchObject({
|
||||||
"workspace-project",
|
|
||||||
]);
|
|
||||||
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
|
|
||||||
projectDisplayName: "Renamed during hydration",
|
projectDisplayName: "Renamed during hydration",
|
||||||
projectRootPath: "/moved/snapshot-project",
|
projectRootPath: "/moved/snapshot-project",
|
||||||
projectKind: "directory",
|
projectKind: "directory",
|
||||||
});
|
});
|
||||||
expect(emptyProjects?.get("workspace-project")).toMatchObject({
|
expect(projects?.get("workspace-project")).toMatchObject({
|
||||||
projectDisplayName: "Project from workspace update",
|
projectDisplayName: "Project from workspace update",
|
||||||
});
|
});
|
||||||
directory.dispose();
|
directory.dispose();
|
||||||
@@ -247,7 +284,7 @@ describe("DirectorySync session readiness", () => {
|
|||||||
|
|
||||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
|
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
|
useSessionStore.getState().sessions[serverId]?.projects.get("early-project"),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
projectDisplayName: "Early project",
|
projectDisplayName: "Early project",
|
||||||
projectRootPath: "/repo/early-project",
|
projectRootPath: "/repo/early-project",
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import type {
|
|||||||
} from "@getpaseo/client/internal/daemon-client";
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
||||||
import {
|
import {
|
||||||
normalizeEmptyProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type Agent,
|
type Agent,
|
||||||
|
type ProjectDescriptor,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
readLegacyDaemonWorkspaceDirectory,
|
readLegacyDaemonWorkspaceDirectory,
|
||||||
@@ -109,7 +111,7 @@ export class DirectorySync {
|
|||||||
const source = connection.source;
|
const source = connection.source;
|
||||||
this.workspaceTransactions.begin(source, () => ({
|
this.workspaceTransactions.begin(source, () => ({
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
projects: new Map(),
|
||||||
}));
|
}));
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
client.on("agent_update", (message) => {
|
client.on("agent_update", (message) => {
|
||||||
@@ -205,8 +207,12 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
if (completion.snapshot.legacy) {
|
if (completion.snapshot.legacy) {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
|
const workspaces = buildLegacyWorkspaces(completion.snapshot.entries);
|
||||||
store.setEmptyProjects(this.serverId, []);
|
store.setWorkspaces(this.serverId, workspaces);
|
||||||
|
store.setProjects(
|
||||||
|
this.serverId,
|
||||||
|
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
|
||||||
|
);
|
||||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
store.setHasHydratedWorkspaces(this.serverId, true);
|
||||||
}
|
}
|
||||||
const deltas = completion.snapshot.legacy
|
const deltas = completion.snapshot.legacy
|
||||||
@@ -233,7 +239,7 @@ export class DirectorySync {
|
|||||||
const { client, source } = this.requireOnline();
|
const { client, source } = this.requireOnline();
|
||||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
projects: new Map(),
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await this.waitForSessionMetadata(client, source);
|
await this.waitForSessionMetadata(client, source);
|
||||||
@@ -243,7 +249,17 @@ export class DirectorySync {
|
|||||||
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const supportsProjectList = serverInfo.features?.projectList === true;
|
||||||
|
if (supportsProjectList) {
|
||||||
|
const payload = await client.listProjects();
|
||||||
|
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||||
|
for (const entry of payload.projects) {
|
||||||
|
const project = normalizeProjectDescriptor(entry);
|
||||||
|
transaction.snapshot.projects.set(project.projectId, project);
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
|
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
|
||||||
|
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
|
||||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||||
}
|
}
|
||||||
@@ -279,8 +295,8 @@ export class DirectorySync {
|
|||||||
transaction.snapshot.workspaces.set(workspace.id, workspace);
|
transaction.snapshot.workspaces.set(workspace.id, workspace);
|
||||||
}
|
}
|
||||||
for (const entry of payload.emptyProjects ?? []) {
|
for (const entry of payload.emptyProjects ?? []) {
|
||||||
const project = normalizeEmptyProjectDescriptor(entry);
|
const project = normalizeProjectDescriptor(entry);
|
||||||
transaction.snapshot.emptyProjects.set(project.projectId, project);
|
transaction.snapshot.projects.set(project.projectId, project);
|
||||||
}
|
}
|
||||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
||||||
cursor = payload.pageInfo.nextCursor;
|
cursor = payload.pageInfo.nextCursor;
|
||||||
@@ -288,6 +304,15 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
|
||||||
|
for (const workspace of snapshot.workspaces.values()) {
|
||||||
|
if (!snapshot.projects.has(workspace.projectId)) {
|
||||||
|
const project = legacyProjectDescriptorFromWorkspace(workspace);
|
||||||
|
snapshot.projects.set(project.projectId, project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAll(): Promise<void> {
|
async refreshAll(): Promise<void> {
|
||||||
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
|
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
|
||||||
}
|
}
|
||||||
@@ -421,6 +446,17 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectKey: null,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class DirectoryRefreshSupersededError extends Error {}
|
export class DirectoryRefreshSupersededError extends Error {}
|
||||||
|
|
||||||
export type { DirectorySourceToken } from "./transaction";
|
export type { DirectorySourceToken } from "./transaction";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
normalizeEmptyProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
@@ -33,7 +33,7 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
|||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||||
const empty = normalizeEmptyProjectDescriptor({
|
const empty = normalizeProjectDescriptor({
|
||||||
projectId: "empty",
|
projectId: "empty",
|
||||||
projectDisplayName: "Empty",
|
projectDisplayName: "Empty",
|
||||||
projectRootPath: "/repo/empty",
|
projectRootPath: "/repo/empty",
|
||||||
@@ -45,14 +45,14 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
|||||||
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
|
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
|
||||||
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
|
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
|
||||||
]),
|
]),
|
||||||
emptyProjects: new Map([[empty.projectId, empty]]),
|
projects: new Map([[empty.projectId, empty]]),
|
||||||
},
|
},
|
||||||
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
|
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
|
||||||
);
|
);
|
||||||
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
|
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
|
||||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["empty"]);
|
||||||
store.clearSession(serverId);
|
store.clearSession(serverId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,19 +65,19 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
|||||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||||
const staleAttachedProject = normalizeEmptyProjectDescriptor({
|
const staleAttachedProject = normalizeProjectDescriptor({
|
||||||
projectId: "attached",
|
projectId: "attached",
|
||||||
projectDisplayName: "Stale attached project",
|
projectDisplayName: "Stale attached project",
|
||||||
projectRootPath: "/repo/attached",
|
projectRootPath: "/repo/attached",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
});
|
});
|
||||||
const removedProject = normalizeEmptyProjectDescriptor({
|
const removedProject = normalizeProjectDescriptor({
|
||||||
projectId: "removed",
|
projectId: "removed",
|
||||||
projectDisplayName: "Removed project",
|
projectDisplayName: "Removed project",
|
||||||
projectRootPath: "/repo/removed",
|
projectRootPath: "/repo/removed",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
});
|
});
|
||||||
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
|
const unchangedEmptyProject = normalizeProjectDescriptor({
|
||||||
projectId: "unchanged-empty",
|
projectId: "unchanged-empty",
|
||||||
projectDisplayName: "Unchanged empty project",
|
projectDisplayName: "Unchanged empty project",
|
||||||
projectRootPath: "/repo/unchanged-empty",
|
projectRootPath: "/repo/unchanged-empty",
|
||||||
@@ -92,7 +92,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
|||||||
[removed.id, removed],
|
[removed.id, removed],
|
||||||
[unrelated.id, unrelated],
|
[unrelated.id, unrelated],
|
||||||
]),
|
]),
|
||||||
emptyProjects: new Map([
|
projects: new Map([
|
||||||
[staleAttachedProject.projectId, staleAttachedProject],
|
[staleAttachedProject.projectId, staleAttachedProject],
|
||||||
[removedProject.projectId, removedProject],
|
[removedProject.projectId, removedProject],
|
||||||
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
||||||
@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
|||||||
kind: "upsert",
|
kind: "upsert",
|
||||||
project: {
|
project: {
|
||||||
projectId: "attached",
|
projectId: "attached",
|
||||||
|
projectKey: "remote:github.com/acme/attached",
|
||||||
projectDisplayName: "Renamed attached project",
|
projectDisplayName: "Renamed attached project",
|
||||||
projectCustomName: "Personal name",
|
projectCustomName: "Personal name",
|
||||||
projectRootPath: "/moved/attached",
|
projectRootPath: "/moved/attached",
|
||||||
@@ -135,11 +136,29 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
|||||||
});
|
});
|
||||||
expect(session?.workspaces.has(removed.id)).toBe(false);
|
expect(session?.workspaces.has(removed.id)).toBe(false);
|
||||||
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
||||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
|
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
|
||||||
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
"attached",
|
||||||
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
|
"unchanged-empty",
|
||||||
|
"new-empty",
|
||||||
|
]);
|
||||||
|
expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||||
|
expect(session?.projects.get("new-empty")).toMatchObject({
|
||||||
projectDisplayName: "New empty project",
|
projectDisplayName: "New empty project",
|
||||||
projectRootPath: "/repo/new-empty",
|
projectRootPath: "/repo/new-empty",
|
||||||
});
|
});
|
||||||
store.clearSession(serverId);
|
store.clearSession(serverId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not invent a null-key project from a workspace update", () => {
|
||||||
|
const serverId = "workspace-before-project-update";
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||||
|
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||||
|
|
||||||
|
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
|
||||||
|
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
expect(session?.workspaces.has("main")).toBe(true);
|
||||||
|
expect(session?.projects.has("fresh-project")).toBe(false);
|
||||||
|
store.clearSession(serverId);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
normalizeEmptyProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type EmptyProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
@@ -20,7 +20,7 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
|
|||||||
|
|
||||||
export interface WorkspaceDirectorySnapshot {
|
export interface WorkspaceDirectorySnapshot {
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
projects: Map<string, ProjectDescriptor>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProjectDelta(
|
function applyProjectDelta(
|
||||||
@@ -28,18 +28,17 @@ function applyProjectDelta(
|
|||||||
delta: ProjectDirectoryDelta,
|
delta: ProjectDirectoryDelta,
|
||||||
): void {
|
): void {
|
||||||
if (delta.kind === "remove") {
|
if (delta.kind === "remove") {
|
||||||
snapshot.emptyProjects.delete(delta.projectId);
|
snapshot.projects.delete(delta.projectId);
|
||||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||||
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = normalizeEmptyProjectDescriptor(delta.project);
|
const project = normalizeProjectDescriptor(delta.project);
|
||||||
let hasAttachedWorkspace = false;
|
snapshot.projects.set(project.projectId, project);
|
||||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||||
if (workspace.projectId !== project.projectId) continue;
|
if (workspace.projectId !== project.projectId) continue;
|
||||||
hasAttachedWorkspace = true;
|
|
||||||
snapshot.workspaces.set(workspaceId, {
|
snapshot.workspaces.set(workspaceId, {
|
||||||
...workspace,
|
...workspace,
|
||||||
projectDisplayName: project.projectDisplayName,
|
projectDisplayName: project.projectDisplayName,
|
||||||
@@ -48,8 +47,6 @@ function applyProjectDelta(
|
|||||||
projectKind: project.projectKind,
|
projectKind: project.projectKind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
|
|
||||||
else snapshot.emptyProjects.set(project.projectId, project);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceDirectoryReplica {
|
export class WorkspaceDirectoryReplica {
|
||||||
@@ -75,7 +72,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
const session = useSessionStore.getState().sessions[this.serverId];
|
const session = useSessionStore.getState().sessions[this.serverId];
|
||||||
return {
|
return {
|
||||||
workspaces: new Map(session?.workspaces),
|
workspaces: new Map(session?.workspaces),
|
||||||
emptyProjects: new Map(session?.emptyProjects),
|
projects: new Map(session?.projects),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +81,7 @@ export class WorkspaceDirectoryReplica {
|
|||||||
deltas: readonly WorkspaceDirectoryDelta[],
|
deltas: readonly WorkspaceDirectoryDelta[],
|
||||||
): WorkspaceDirectorySnapshot {
|
): WorkspaceDirectorySnapshot {
|
||||||
const workspaces = new Map(snapshot.workspaces);
|
const workspaces = new Map(snapshot.workspaces);
|
||||||
const emptyProjects = new Map(snapshot.emptyProjects);
|
const projects = new Map(snapshot.projects);
|
||||||
for (const [workspaceId, workspace] of workspaces) {
|
for (const [workspaceId, workspace] of workspaces) {
|
||||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
|
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
|
||||||
workspaces.delete(workspaceId);
|
workspaces.delete(workspaceId);
|
||||||
@@ -92,16 +89,18 @@ export class WorkspaceDirectoryReplica {
|
|||||||
}
|
}
|
||||||
for (const delta of deltas) {
|
for (const delta of deltas) {
|
||||||
if ("projectId" in delta || "project" in delta) {
|
if ("projectId" in delta || "project" in delta) {
|
||||||
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
applyProjectDelta({ workspaces, projects }, delta);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (delta.kind === "remove") {
|
if (delta.kind === "remove") {
|
||||||
workspaces.delete(delta.id);
|
workspaces.delete(delta.id);
|
||||||
if (delta.emptyProject) {
|
if (delta.emptyProject) {
|
||||||
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
|
const project = normalizeProjectDescriptor(delta.emptyProject);
|
||||||
emptyProjects.set(project.projectId, project);
|
projects.set(project.projectId, project);
|
||||||
|
}
|
||||||
|
if (delta.removedProjectId) {
|
||||||
|
projects.delete(delta.removedProjectId);
|
||||||
}
|
}
|
||||||
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
|
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
|
||||||
@@ -109,16 +108,15 @@ export class WorkspaceDirectoryReplica {
|
|||||||
workspaces.delete(workspace.id);
|
workspaces.delete(workspace.id);
|
||||||
} else {
|
} else {
|
||||||
workspaces.set(workspace.id, workspace);
|
workspaces.set(workspace.id, workspace);
|
||||||
emptyProjects.delete(workspace.projectId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { workspaces, emptyProjects };
|
return { workspaces, projects };
|
||||||
}
|
}
|
||||||
|
|
||||||
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
|
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
store.setProjects(this.serverId, snapshot.projects.values());
|
||||||
for (const workspaceId of removedWorkspaceIds) {
|
for (const workspaceId of removedWorkspaceIds) {
|
||||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import {
|
import {
|
||||||
normalizeEmptyProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
@@ -96,8 +96,15 @@ function seedSession(): void {
|
|||||||
SERVER_ID,
|
SERVER_ID,
|
||||||
new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]),
|
new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]),
|
||||||
);
|
);
|
||||||
store.setEmptyProjects(SERVER_ID, [
|
store.setProjects(SERVER_ID, [
|
||||||
normalizeEmptyProjectDescriptor({
|
normalizeProjectDescriptor({
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "remote:github.com/getpaseo/paseo",
|
||||||
|
projectDisplayName: "Paseo",
|
||||||
|
projectRootPath: "/repo/paseo",
|
||||||
|
projectKind: "git",
|
||||||
|
}),
|
||||||
|
normalizeProjectDescriptor({
|
||||||
projectId: "empty-project",
|
projectId: "empty-project",
|
||||||
projectDisplayName: "Empty project",
|
projectDisplayName: "Empty project",
|
||||||
projectRootPath: "/repo/empty",
|
projectRootPath: "/repo/empty",
|
||||||
@@ -162,7 +169,7 @@ describe("ReplicaCache", () => {
|
|||||||
expect(session?.hasHydratedWorkspaces).toBe(false);
|
expect(session?.hasHydratedWorkspaces).toBe(false);
|
||||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]);
|
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]);
|
||||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]);
|
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]);
|
||||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
|
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]);
|
||||||
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
||||||
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
||||||
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
|
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
|
||||||
@@ -213,7 +220,7 @@ describe("ReplicaCache", () => {
|
|||||||
const timelines = session?.agentStreamTail;
|
const timelines = session?.agentStreamTail;
|
||||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
|
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
|
||||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
|
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
|
||||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
|
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
|
||||||
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
||||||
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
type WorkspaceDescriptorPayload,
|
type WorkspaceDescriptorPayload,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
normalizeEmptyProjectDescriptor,
|
normalizeProjectDescriptor,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type Agent,
|
type Agent,
|
||||||
type SessionReplica,
|
type SessionReplica,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
@@ -50,6 +51,7 @@ const StoredHostSchema = z.object({
|
|||||||
serverId: z.string(),
|
serverId: z.string(),
|
||||||
agents: z.array(StoredAgentSchema),
|
agents: z.array(StoredAgentSchema),
|
||||||
workspaces: z.array(WorkspaceDescriptorPayloadSchema),
|
workspaces: z.array(WorkspaceDescriptorPayloadSchema),
|
||||||
|
projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]),
|
||||||
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema),
|
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema),
|
||||||
timeline: StoredTimelineSchema.nullable(),
|
timeline: StoredTimelineSchema.nullable(),
|
||||||
});
|
});
|
||||||
@@ -214,18 +216,47 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeProject(project: ProjectDescriptor) {
|
||||||
|
return {
|
||||||
|
projectId: project.projectId,
|
||||||
|
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
||||||
|
projectDisplayName: project.projectDisplayName,
|
||||||
|
projectCustomName: project.projectCustomName,
|
||||||
|
projectRootPath: project.projectRootPath,
|
||||||
|
projectKind: project.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function deserializeHost(stored: StoredHost): SessionReplica {
|
function deserializeHost(stored: StoredHost): SessionReplica {
|
||||||
const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry));
|
const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry));
|
||||||
const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor);
|
const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor);
|
||||||
const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor);
|
const listedProjects = stored.projects.map(normalizeProjectDescriptor);
|
||||||
|
const legacyProjects = [
|
||||||
|
...stored.emptyProjects.map(normalizeProjectDescriptor),
|
||||||
|
...workspaces.map(legacyProjectDescriptorFromWorkspace),
|
||||||
|
];
|
||||||
|
const projects = new Map(
|
||||||
|
[...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
agents: new Map(agents.map((agent) => [agent.id, agent])),
|
agents: new Map(agents.map((agent) => [agent.id, agent])),
|
||||||
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||||
emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])),
|
projects,
|
||||||
timeline: deserializeTimeline(stored.timeline),
|
timeline: deserializeTimeline(stored.timeline),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectKey: null,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class ReplicaCache {
|
export class ReplicaCache {
|
||||||
private readonly activeServerIds = new Set<string>();
|
private readonly activeServerIds = new Set<string>();
|
||||||
private readonly storedHosts = new Map<string, StoredHost>();
|
private readonly storedHosts = new Map<string, StoredHost>();
|
||||||
@@ -384,6 +415,11 @@ export class ReplicaCache {
|
|||||||
serverId,
|
serverId,
|
||||||
agents: focusedAgent ? [serializeAgent(focusedAgent)] : [],
|
agents: focusedAgent ? [serializeAgent(focusedAgent)] : [],
|
||||||
workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [],
|
workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [],
|
||||||
|
projects: focusedWorkspace
|
||||||
|
? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) =>
|
||||||
|
project ? [serializeProject(project)] : [],
|
||||||
|
)
|
||||||
|
: [],
|
||||||
emptyProjects: [],
|
emptyProjects: [],
|
||||||
timeline,
|
timeline,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -588,6 +588,28 @@ describe("schedule form model", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hydrates an edited schedule's project label when its host targets arrive", () => {
|
||||||
|
const form = open({
|
||||||
|
mode: "edit",
|
||||||
|
schedule: scheduleOnHost({
|
||||||
|
serverId: "host-b",
|
||||||
|
serverName: "Host B",
|
||||||
|
cwd: "/repo/b",
|
||||||
|
model: "model-b",
|
||||||
|
}),
|
||||||
|
defaults: { serverId: null, projectTargets: [], preferences: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(form.getState().projectDisplay).toEqual({ label: "/repo/b" });
|
||||||
|
|
||||||
|
form.applyProjectTargets(PROJECT_TARGETS);
|
||||||
|
|
||||||
|
expect(form.getState()).toMatchObject({
|
||||||
|
projectDisplay: { label: "Project B" },
|
||||||
|
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
|
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
|
||||||
const savedPreferences: FormPreferences = {
|
const savedPreferences: FormPreferences = {
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
|
|||||||
@@ -602,6 +602,11 @@ function updateDerivedState(input: {
|
|||||||
...input.state,
|
...input.state,
|
||||||
hosts: [...input.hosts],
|
hosts: [...input.hosts],
|
||||||
projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId),
|
projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId),
|
||||||
|
projectDisplay: resolveProjectDisplay({
|
||||||
|
targets: input.targets,
|
||||||
|
serverId: input.state.selectedServerId,
|
||||||
|
cwd: input.state.workingDir,
|
||||||
|
}),
|
||||||
selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId,
|
selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId,
|
||||||
selectedModelDisplay: resolveModelDisplay({
|
selectedModelDisplay: resolveModelDisplay({
|
||||||
entries: input.providerEntries,
|
entries: input.providerEntries,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
|||||||
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||||
return {
|
return {
|
||||||
serverId: "host-1",
|
serverId: "host-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectName: "Project",
|
||||||
|
projectCustomName: null,
|
||||||
serverName: "Host 1",
|
serverName: "Host 1",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
repoRoot: "/tmp/project",
|
repoRoot: "/tmp/project",
|
||||||
@@ -35,14 +38,17 @@ describe("buildScheduleProjectTargets", () => {
|
|||||||
const targets = buildScheduleProjectTargets([
|
const targets = buildScheduleProjectTargets([
|
||||||
makeProject({
|
makeProject({
|
||||||
projectName: "Alpha",
|
projectName: "Alpha",
|
||||||
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
hosts: [
|
||||||
|
makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" }),
|
||||||
|
makeHost({ serverId: "host-2", projectName: "Alpha on Host 2" }),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(targets).toHaveLength(2);
|
expect(targets).toHaveLength(2);
|
||||||
expect(targets[0]).toMatchObject({
|
expect(targets[0]).toMatchObject({
|
||||||
serverId: "host-1",
|
serverId: "host-1",
|
||||||
cwd: "/tmp/alpha",
|
cwd: "/tmp/alpha",
|
||||||
projectName: "Alpha",
|
projectName: "Alpha on Host 1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,12 +66,15 @@ describe("describeScheduleCwd", () => {
|
|||||||
it("prefers a matched project name and shortens unmatched paths", () => {
|
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||||
const byCwd = buildProjectNameByCwd(
|
const byCwd = buildProjectNameByCwd(
|
||||||
buildScheduleProjectTargets([
|
buildScheduleProjectTargets([
|
||||||
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
makeProject({
|
||||||
|
projectName: "Grouped Alpha",
|
||||||
|
hosts: [makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" })],
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||||
).toBe("Alpha");
|
).toBe("Alpha on Host 1");
|
||||||
expect(
|
expect(
|
||||||
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||||
).toBe("~/api");
|
).toBe("~/api");
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function buildScheduleProjectTargets(
|
|||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
serverName: host.serverName,
|
serverName: host.serverName,
|
||||||
projectKey: project.projectKey,
|
projectKey: project.projectKey,
|
||||||
projectName: project.projectName,
|
projectName: host.projectName,
|
||||||
cwd,
|
cwd,
|
||||||
isGit: Boolean(host.gitRuntime),
|
isGit: Boolean(host.gitRuntime),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
|
|||||||
projectName: key,
|
projectName: key,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
iconWorkingDir: `/work/${key}`,
|
iconWorkingDir: `/work/${key}`,
|
||||||
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||||
workspaceKeys: [],
|
workspaceKeys: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
|
|||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import {
|
import {
|
||||||
getHostProjectSourceDirectory,
|
getHostProjectSourceDirectory,
|
||||||
|
getHostProjectId,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
hostProjectFromWorkspace,
|
hostProjectFromWorkspace,
|
||||||
useHostProjects,
|
useHostProjects,
|
||||||
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
createFailedMessage: string;
|
createFailedMessage: string;
|
||||||
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
||||||
|
const projectId = getHostProjectId(input.project, input.serverId);
|
||||||
|
if (!projectId) throw new Error("Project is not available on the selected host");
|
||||||
const isWorktree = input.isolation === "worktree";
|
const isWorktree = input.isolation === "worktree";
|
||||||
const checkoutRequest = isWorktree
|
const checkoutRequest = isWorktree
|
||||||
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
|
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
|
||||||
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
? {
|
? {
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
cwd: input.sourceDirectory,
|
cwd: input.sourceDirectory,
|
||||||
projectId: input.project.projectKey,
|
projectId,
|
||||||
worktreeSlug: createNameId(),
|
worktreeSlug: createNameId(),
|
||||||
...checkoutRequest,
|
...checkoutRequest,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
kind: "directory",
|
kind: "directory",
|
||||||
path: input.sourceDirectory,
|
path: input.sourceDirectory,
|
||||||
projectId: input.project.projectKey,
|
projectId,
|
||||||
},
|
},
|
||||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||||
});
|
});
|
||||||
@@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={host.anchorRef}
|
ref={host.anchorRef}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Host"
|
||||||
onPress={host.open}
|
onPress={host.open}
|
||||||
disabled={isPending || host.allHosts.length === 0}
|
disabled={isPending || host.allHosts.length === 0}
|
||||||
style={badgePressableStyle}
|
style={badgePressableStyle}
|
||||||
@@ -1889,16 +1894,20 @@ export function NewWorkspaceScreen({
|
|||||||
}
|
}
|
||||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||||
const firstAgentContext = buildFirstAgentContext(input);
|
const firstAgentContext = buildFirstAgentContext(input);
|
||||||
|
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
|
||||||
|
if (!hostProjectId) {
|
||||||
|
throw new Error("Project is not available on the selected host");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cwd: selectedSourceDirectory,
|
cwd: selectedSourceDirectory,
|
||||||
projectId: selectedProject.projectKey,
|
projectId: hostProjectId,
|
||||||
worktreeSlug: createNameId(),
|
worktreeSlug: createNameId(),
|
||||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||||
...checkoutRequest,
|
...checkoutRequest,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
|
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ensureWorkspace = useCallback(
|
const ensureWorkspace = useCallback(
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
|||||||
projectName: projectKey,
|
projectName: projectKey,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
iconWorkingDir: `/work/${projectKey}`,
|
iconWorkingDir: `/work/${projectKey}`,
|
||||||
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
projectId: projectKey,
|
||||||
|
iconWorkingDir: `/work/${projectKey}`,
|
||||||
|
canCreateWorktree: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
workspaceKeys: [],
|
workspaceKeys: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,6 +118,46 @@ describe("reconcileProjectSelection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adopts a routed project's hydrated cross-host key", () => {
|
||||||
|
const routedProject = {
|
||||||
|
...project("local-project-id"),
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: "host",
|
||||||
|
projectId: "local-project-id",
|
||||||
|
iconWorkingDir: "/work/project",
|
||||||
|
canCreateWorktree: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const hydratedProject = {
|
||||||
|
...project("remote:github.com/acme/project"),
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: "host",
|
||||||
|
projectId: "local-project-id",
|
||||||
|
iconWorkingDir: "/work/project",
|
||||||
|
canCreateWorktree: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const current = createProjectSelection(
|
||||||
|
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
|
||||||
|
);
|
||||||
|
const hydratedContext = context({
|
||||||
|
initialProject: hydratedProject,
|
||||||
|
projects: [hydratedProject],
|
||||||
|
routeProject: routedProject,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||||
|
contextKey: "host:",
|
||||||
|
projectKey: hydratedProject.projectKey,
|
||||||
|
project: hydratedProject,
|
||||||
|
source: "initial",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("stores hydrated project snapshots before archive gaps", () => {
|
it("stores hydrated project snapshots before archive gaps", () => {
|
||||||
const routeProject = project("route-project");
|
const routeProject = project("route-project");
|
||||||
const hydratedProject: HostProjectListItem = {
|
const hydratedProject: HostProjectListItem = {
|
||||||
@@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => {
|
|||||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves an opaque project key ending in whitespace", () => {
|
||||||
|
const selected = project("host:project ");
|
||||||
|
const current = createProjectSelection(
|
||||||
|
context({ initialProject: selected, projects: [selected] }),
|
||||||
|
);
|
||||||
|
const currentContext = context({ initialProject: selected, projects: [selected] });
|
||||||
|
|
||||||
|
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
|
||||||
|
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
|
||||||
|
});
|
||||||
|
|
||||||
it("resets fallback selection when host project capability changes", () => {
|
it("resets fallback selection when host project capability changes", () => {
|
||||||
const fallback = project("git-fallback");
|
const fallback = project("git-fallback");
|
||||||
const remembered = project("remembered-directory");
|
const remembered = project("remembered-directory");
|
||||||
|
|||||||
@@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: {
|
|||||||
if (!input.initialProject) {
|
if (!input.initialProject) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
|
if (
|
||||||
|
input.routeProject?.projectKey === input.initialProject.projectKey ||
|
||||||
|
input.routeProject?.hosts.some((routeHost) =>
|
||||||
|
input.initialProject?.hosts.some(
|
||||||
|
(host) =>
|
||||||
|
host.serverId === routeHost.serverId &&
|
||||||
|
Boolean(host.projectId) &&
|
||||||
|
host.projectId === routeHost.projectId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return "route";
|
return "route";
|
||||||
}
|
}
|
||||||
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
||||||
@@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
||||||
const projectKey = selection.projectKey?.trim() ?? "";
|
const projectKey = selection.projectKey ?? "";
|
||||||
return projectKey || null;
|
return projectKey.trim() ? projectKey : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSelectedProjectFromInitialInputs(
|
function resolveSelectedProjectFromInitialInputs(
|
||||||
@@ -95,14 +105,14 @@ function refreshSelectionProject(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldResetInitialFallbackSelection(
|
function shouldResetHydratedInitialSelection(
|
||||||
selection: ProjectSelection,
|
selection: ProjectSelection,
|
||||||
context: ProjectSelectionContext,
|
context: ProjectSelectionContext,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (
|
if (
|
||||||
selection.source !== "initial" ||
|
selection.source !== "initial" ||
|
||||||
!context.initialProject ||
|
!context.initialProject ||
|
||||||
context.initialProjectSource !== "lastActive"
|
(context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive")
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -149,7 +159,7 @@ export function reconcileProjectSelection(
|
|||||||
return initialSelection;
|
return initialSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldResetInitialFallbackSelection(current, context)) {
|
if (shouldResetHydratedInitialSelection(current, context)) {
|
||||||
return initialSelection;
|
return initialSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
|
||||||
|
|
||||||
|
test("falls back when the selected host is no longer editable", () => {
|
||||||
|
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||||
|
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProjectSettingsHostTarget({
|
||||||
|
routeKey: "host-a:project-a",
|
||||||
|
editableHosts: [hostA],
|
||||||
|
defaultHost: hostA,
|
||||||
|
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||||
|
}),
|
||||||
|
).toEqual(hostA);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the selected host while it remains editable", () => {
|
||||||
|
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||||
|
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveProjectSettingsHostTarget({
|
||||||
|
routeKey: "host-a:project-a",
|
||||||
|
editableHosts: [hostA, hostB],
|
||||||
|
defaultHost: hostA,
|
||||||
|
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||||
|
}),
|
||||||
|
).toEqual(hostB);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface ProjectSettingsHostTarget {
|
||||||
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
|
||||||
|
routeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveProjectSettingsHostTargetInput {
|
||||||
|
routeKey: string;
|
||||||
|
editableHosts: readonly ProjectSettingsHostTarget[];
|
||||||
|
defaultHost: ProjectSettingsHostTarget | undefined;
|
||||||
|
selection: StoredProjectSettingsHostSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectSettingsHostTarget(
|
||||||
|
input: ResolveProjectSettingsHostTargetInput,
|
||||||
|
): ProjectSettingsHostTarget | undefined {
|
||||||
|
const selectedHost =
|
||||||
|
input.selection.routeKey === input.routeKey
|
||||||
|
? getEditableHost(input.editableHosts, input.selection)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
selectedHost ??
|
||||||
|
getEditableHost(input.editableHosts, input.defaultHost) ??
|
||||||
|
input.editableHosts[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditableHost(
|
||||||
|
editableHosts: readonly ProjectSettingsHostTarget[],
|
||||||
|
target: ProjectSettingsHostTarget | undefined,
|
||||||
|
): ProjectSettingsHostTarget | undefined {
|
||||||
|
if (!target) return undefined;
|
||||||
|
for (const host of editableHosts) {
|
||||||
|
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -46,6 +46,10 @@ import {
|
|||||||
} from "@/utils/project-config-form";
|
} from "@/utils/project-config-form";
|
||||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||||
|
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
|
||||||
|
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
|
||||||
|
|
||||||
const SCRIPT_SERVICE_TYPE = "service";
|
const SCRIPT_SERVICE_TYPE = "service";
|
||||||
|
|
||||||
@@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
|
|||||||
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
|
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
|
||||||
|
|
||||||
export interface ProjectSettingsScreenProps {
|
export interface ProjectSettingsScreenProps {
|
||||||
projectKey: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
|
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const project = useMemo(
|
const project = useMemo(
|
||||||
() => projects.find((entry) => entry.projectKey === projectKey),
|
() => getProjectSummaryForHostProject(projects, serverId, projectId),
|
||||||
[projects, projectKey],
|
[projectId, projects, serverId],
|
||||||
);
|
);
|
||||||
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
|
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
|
||||||
|
const routeKey = `${serverId}:${projectId}`;
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>(
|
const routeHost = getProjectHostEntry(project, serverId, projectId);
|
||||||
() => editableHosts[0]?.serverId ?? "",
|
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
|
||||||
|
const [hostSelection, setHostSelection] = useState({
|
||||||
|
routeKey: "",
|
||||||
|
serverId: "",
|
||||||
|
projectId: "",
|
||||||
|
});
|
||||||
|
const selectedTarget = resolveProjectSettingsHostTarget({
|
||||||
|
routeKey,
|
||||||
|
editableHosts,
|
||||||
|
defaultHost,
|
||||||
|
selection: hostSelection,
|
||||||
|
});
|
||||||
|
const selectedServerId = selectedTarget?.serverId ?? "";
|
||||||
|
const selectedProjectId = selectedTarget?.projectId ?? "";
|
||||||
|
const setSelectedServerId = useCallback(
|
||||||
|
(targetServerId: string) => {
|
||||||
|
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
|
||||||
|
sourceServerId: serverId,
|
||||||
|
projectId,
|
||||||
|
targetServerId,
|
||||||
|
});
|
||||||
|
if (targetProjectId) {
|
||||||
|
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, routeKey, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
|
|
||||||
if (!stillValid) {
|
|
||||||
setSelectedServerId(editableHosts[0]?.serverId ?? "");
|
|
||||||
}
|
|
||||||
}, [editableHosts, selectedServerId]);
|
|
||||||
|
|
||||||
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
|
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
|
||||||
const isHostGone =
|
const isHostGone =
|
||||||
Boolean(selectedServerId) &&
|
Boolean(selectedServerId) &&
|
||||||
(selectedSnapshot?.connectionStatus === "offline" ||
|
(selectedSnapshot?.connectionStatus === "offline" ||
|
||||||
selectedSnapshot?.connectionStatus === "error");
|
selectedSnapshot?.connectionStatus === "error");
|
||||||
|
|
||||||
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
|
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
|
||||||
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
|
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
|
||||||
|
|
||||||
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
|
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
|
||||||
@@ -231,7 +254,7 @@ function ProjectSettingsBody({
|
|||||||
const hasMultipleHosts = hosts.length > 1;
|
const hasMultipleHosts = hosts.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.body}>
|
<View role="main" style={styles.body}>
|
||||||
<BackToProjectsButton />
|
<BackToProjectsButton />
|
||||||
|
|
||||||
<View style={styles.headerBlock}>
|
<View style={styles.headerBlock}>
|
||||||
@@ -241,7 +264,13 @@ function ProjectSettingsBody({
|
|||||||
projectName={project.projectName}
|
projectName={project.projectName}
|
||||||
projectKey={project.projectKey}
|
projectKey={project.projectKey}
|
||||||
/>
|
/>
|
||||||
<ProjectNameEditor project={project} client={client} />
|
<ProjectNameEditor
|
||||||
|
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
|
||||||
|
projectName={selectedHost.projectName}
|
||||||
|
projectCustomName={selectedHost.projectCustomName}
|
||||||
|
projectId={selectedHost.projectId}
|
||||||
|
client={client}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||||
</View>
|
</View>
|
||||||
@@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectNameEditorProps {
|
interface ProjectNameEditorProps {
|
||||||
project: ProjectSummary;
|
projectName: string;
|
||||||
|
projectCustomName: string | null;
|
||||||
|
projectId: string;
|
||||||
client: DaemonClient;
|
client: DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
function ProjectNameEditor({
|
||||||
|
projectName,
|
||||||
|
projectCustomName,
|
||||||
|
projectId,
|
||||||
|
client,
|
||||||
|
}: ProjectNameEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [value, setValue] = useState(project.projectCustomName ?? "");
|
const [value, setValue] = useState(projectCustomName ?? "");
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
|
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleStartEdit = useCallback(() => {
|
const handleStartEdit = useCallback(() => {
|
||||||
setValue(project.projectCustomName ?? "");
|
setValue(projectCustomName ?? "");
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}, [project.projectCustomName]);
|
}, [projectCustomName]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setValue(project.projectCustomName ?? "");
|
setValue(projectCustomName ?? "");
|
||||||
}, [project.projectCustomName]);
|
}, [projectCustomName]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
const next = trimmed.length === 0 ? null : trimmed;
|
const next = trimmed.length === 0 ? null : trimmed;
|
||||||
if (next === (project.projectCustomName ?? null)) {
|
if (next === projectCustomName) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renameMutation.mutate(next);
|
renameMutation.mutate(next);
|
||||||
}, [value, project.projectCustomName, renameMutation]);
|
}, [value, projectCustomName, renameMutation]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
renameMutation.mutate(null);
|
renameMutation.mutate(null);
|
||||||
@@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.nameEditorRow}>
|
<View
|
||||||
|
role="group"
|
||||||
|
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||||
|
style={styles.nameEditorRow}
|
||||||
|
>
|
||||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||||
{project.projectName}
|
{projectName}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="project-name-edit-button"
|
testID="project-name-edit-button"
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("settings.project.rename.renameLabel")}
|
accessibilityLabel={t("settings.project.rename.renameLabel")}
|
||||||
onPress={handleStartEdit}
|
onPress={handleStartEdit}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
@@ -853,9 +894,10 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
>
|
>
|
||||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{project.projectCustomName ? (
|
{projectCustomName ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="project-name-reset-button"
|
testID="project-name-reset-button"
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("settings.project.rename.resetLabel")}
|
accessibilityLabel={t("settings.project.rename.resetLabel")}
|
||||||
onPress={handleReset}
|
onPress={handleReset}
|
||||||
disabled={renameMutation.isPending}
|
disabled={renameMutation.isPending}
|
||||||
@@ -870,13 +912,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.nameEditorRow}>
|
<View
|
||||||
|
role="group"
|
||||||
|
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||||
|
style={styles.nameEditorRow}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="project-name-input"
|
testID="project-name-input"
|
||||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||||
value={value}
|
value={value}
|
||||||
onChangeText={setValue}
|
onChangeText={setValue}
|
||||||
placeholder={project.projectName}
|
placeholder={projectName}
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
placeholderTextColor={styles.placeholderColor.color}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={styles.nameEditorInput}
|
style={styles.nameEditorInput}
|
||||||
@@ -886,6 +932,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="project-name-save-button"
|
testID="project-name-save-button"
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("settings.project.rename.saveLabel")}
|
accessibilityLabel={t("settings.project.rename.saveLabel")}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={renameMutation.isPending}
|
disabled={renameMutation.isPending}
|
||||||
@@ -896,6 +943,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="project-name-cancel-button"
|
testID="project-name-cancel-button"
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("settings.project.rename.cancelLabel")}
|
accessibilityLabel={t("settings.project.rename.cancelLabel")}
|
||||||
onPress={handleCancel}
|
onPress={handleCancel}
|
||||||
disabled={renameMutation.isPending}
|
disabled={renameMutation.isPending}
|
||||||
@@ -982,6 +1030,7 @@ function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("settings.project.switchHost")}
|
accessibilityLabel={t("settings.project.switchHost")}
|
||||||
testID="host-picker"
|
testID="host-picker"
|
||||||
style={styles.hostIndicator}
|
style={styles.hostIndicator}
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
|||||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||||
return {
|
return {
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectName: "Project",
|
||||||
|
projectCustomName: null,
|
||||||
serverName: "alpha",
|
serverName: "alpha",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
repoRoot: "/home/me/proj",
|
repoRoot: "/home/me/proj",
|
||||||
@@ -290,7 +293,9 @@ describe("ProjectsScreen", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
function render(
|
||||||
|
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
|
||||||
|
) {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<ProjectsScreen view={view} />);
|
root?.render(<ProjectsScreen view={view} />);
|
||||||
});
|
});
|
||||||
@@ -329,7 +334,7 @@ describe("ProjectsScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(navigate).toHaveBeenCalledTimes(1);
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render a kebab menu on the row", () => {
|
it("does not render a kebab menu on the row", () => {
|
||||||
@@ -392,7 +397,7 @@ describe("ProjectsScreen", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
|
||||||
|
|
||||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||||
const other = findRow(container!, "remote:github.com/acme/other");
|
const other = findRow(container!, "remote:github.com/acme/other");
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
|||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||||
import type { ProjectSummary } from "@/utils/projects";
|
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
|
||||||
|
|
||||||
interface ProjectsScreenProps {
|
interface ProjectsScreenProps {
|
||||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { projects, hostErrors, isLoading } = useProjects();
|
const { projects, hostErrors, isLoading } = useProjects();
|
||||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
const selectedProjectKey =
|
||||||
|
view.kind === "project"
|
||||||
|
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
|
||||||
|
null)
|
||||||
|
: null;
|
||||||
const iconTargets = useMemo(
|
const iconTargets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projects.flatMap((project) => {
|
projects.flatMap((project) => {
|
||||||
@@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { projectKey, projectName } = project;
|
const { projectKey, projectName } = project;
|
||||||
|
const target = project.hosts[0];
|
||||||
const handleNavigate = useCallback(() => {
|
const handleNavigate = useCallback(() => {
|
||||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
|
||||||
}, [projectKey]);
|
}, [target]);
|
||||||
|
|
||||||
const rowStyle = useCallback(
|
const rowStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export type SettingsView =
|
|||||||
| { kind: "section"; section: SettingsSectionSlug }
|
| { kind: "section"; section: SettingsSectionSlug }
|
||||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||||
| { kind: "projects" }
|
| { kind: "projects" }
|
||||||
| { kind: "project"; projectKey: string };
|
| { kind: "project"; serverId: string; projectId: string };
|
||||||
|
|
||||||
interface SidebarSectionItem {
|
interface SidebarSectionItem {
|
||||||
id: SettingsSectionSlug;
|
id: SettingsSectionSlug;
|
||||||
@@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
return <ProjectsScreen view={view} />;
|
return <ProjectsScreen view={view} />;
|
||||||
}
|
}
|
||||||
if (view.kind === "project") {
|
if (view.kind === "project") {
|
||||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
|
||||||
}
|
}
|
||||||
if (view.kind === "section") {
|
if (view.kind === "section") {
|
||||||
switch (view.section) {
|
switch (view.section) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "./selectors";
|
} from "./selectors";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type EmptyProjectDescriptor,
|
type ProjectDescriptor,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "../session-store";
|
} from "../session-store";
|
||||||
|
|
||||||
@@ -45,11 +45,33 @@ function createWorkspace(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
useSessionStore
|
useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setProjects(
|
||||||
|
SERVER_ID,
|
||||||
|
new Map(
|
||||||
|
workspaces.map((workspace) => [
|
||||||
|
workspace.projectId,
|
||||||
|
projectDescriptorFromTestWorkspace(workspace),
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Subscribable<S> {
|
interface Subscribable<S> {
|
||||||
@@ -125,11 +147,26 @@ describe("workspace replica authority", () => {
|
|||||||
useSessionStore
|
useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
|
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setProjects(
|
||||||
|
SERVER_ID,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
authoritativeWorkspace.projectId,
|
||||||
|
projectDescriptorFromTestWorkspace(authoritativeWorkspace),
|
||||||
|
],
|
||||||
|
]).values(),
|
||||||
|
);
|
||||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||||
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
||||||
SERVER_ID,
|
SERVER_ID,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(hydratedServerIds).toEqual([SERVER_ID]);
|
||||||
|
expect(
|
||||||
|
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []),
|
||||||
|
).toEqual([authoritativeWorkspace.projectId]);
|
||||||
expect(
|
expect(
|
||||||
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
|
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
|
||||||
(project) => project.workspaceKeys,
|
(project) => project.workspaceKeys,
|
||||||
@@ -149,10 +186,16 @@ describe("workspace replica authority", () => {
|
|||||||
sessions: {
|
sessions: {
|
||||||
[loadingServerId]: {
|
[loadingServerId]: {
|
||||||
hasHydratedWorkspaces: false,
|
hasHydratedWorkspaces: false,
|
||||||
|
projects: new Map([
|
||||||
|
[loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)],
|
||||||
|
]),
|
||||||
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
|
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
|
||||||
},
|
},
|
||||||
[hydratedServerId]: {
|
[hydratedServerId]: {
|
||||||
hasHydratedWorkspaces: true,
|
hasHydratedWorkspaces: true,
|
||||||
|
projects: new Map([
|
||||||
|
[hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)],
|
||||||
|
]),
|
||||||
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
|
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -295,8 +338,9 @@ describe("workspace structure composition", () => {
|
|||||||
projectRootPath: "/repo/a",
|
projectRootPath: "/repo/a",
|
||||||
workspaceDirectory: "/repo/a",
|
workspaceDirectory: "/repo/a",
|
||||||
});
|
});
|
||||||
const emptyProject: EmptyProjectDescriptor = {
|
const emptyProject: ProjectDescriptor = {
|
||||||
projectId: "project-a",
|
projectId: "project-a",
|
||||||
|
projectKey: "project-a",
|
||||||
projectDisplayName: "Project A",
|
projectDisplayName: "Project A",
|
||||||
projectCustomName: null,
|
projectCustomName: null,
|
||||||
projectRootPath: "/repo/a",
|
projectRootPath: "/repo/a",
|
||||||
@@ -311,7 +355,7 @@ describe("workspace structure composition", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
|
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
|
||||||
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
|
useSessionStore.getState().upsertProject(SERVER_ID, emptyProject);
|
||||||
} finally {
|
} finally {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
@@ -348,15 +392,15 @@ describe("workspace structure composition", () => {
|
|||||||
it("renders a project parent with zero active workspaces", () => {
|
it("renders a project parent with zero active workspaces", () => {
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
const emptyProject: ProjectDescriptor = {
|
||||||
{
|
projectId: "empty-project",
|
||||||
projectId: "empty-project",
|
projectKey: "empty-project",
|
||||||
projectDisplayName: "Empty Project",
|
projectDisplayName: "Empty Project",
|
||||||
projectCustomName: null,
|
projectCustomName: null,
|
||||||
projectRootPath: "/repo/empty",
|
projectRootPath: "/repo/empty",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
},
|
};
|
||||||
]);
|
useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]);
|
||||||
|
|
||||||
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
|
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
|
||||||
expect(projects).toEqual([
|
expect(projects).toEqual([
|
||||||
@@ -368,7 +412,7 @@ describe("workspace structure composition", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("changes when a structure-relevant project identity field changes", () => {
|
it("changes when a project descriptor display field changes", () => {
|
||||||
const workspace = createWorkspace({
|
const workspace = createWorkspace({
|
||||||
id: "workspace-a",
|
id: "workspace-a",
|
||||||
projectDisplayName: "Project 1",
|
projectDisplayName: "Project 1",
|
||||||
@@ -382,9 +426,10 @@ describe("workspace structure composition", () => {
|
|||||||
);
|
);
|
||||||
const before = tracked.current;
|
const before = tracked.current;
|
||||||
|
|
||||||
useSessionStore
|
useSessionStore.getState().upsertProject(SERVER_ID, {
|
||||||
.getState()
|
...projectDescriptorFromTestWorkspace(workspace),
|
||||||
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
|
projectDisplayName: "Project Renamed",
|
||||||
|
});
|
||||||
expect(tracked.current).not.toBe(before);
|
expect(tracked.current).not.toBe(before);
|
||||||
|
|
||||||
tracked.stop();
|
tracked.stop();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@/projects/workspace-structure";
|
} from "@/projects/workspace-structure";
|
||||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||||
|
|
||||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||||
@@ -17,7 +17,7 @@ export interface SessionsSnapshot {
|
|||||||
{
|
{
|
||||||
hasHydratedWorkspaces?: boolean;
|
hasHydratedWorkspaces?: boolean;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
emptyProjects?: Map<string, EmptyProjectDescriptor>;
|
projects?: Map<string, ProjectDescriptor>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
@@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects(
|
|||||||
const sessions: Array<{
|
const sessions: Array<{
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: Iterable<WorkspaceDescriptor>;
|
workspaces: Iterable<WorkspaceDescriptor>;
|
||||||
emptyProjects: Iterable<EmptyProjectDescriptor>;
|
projects: Iterable<ProjectDescriptor>;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
const session = state.sessions[serverId];
|
const session = state.sessions[serverId];
|
||||||
const workspaces = session?.workspaces;
|
const workspaces = session?.workspaces;
|
||||||
const emptyProjects = session?.emptyProjects;
|
const projects = session?.projects;
|
||||||
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
|
if (!projects || projects.size === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sessions.push({
|
sessions.push({
|
||||||
serverId,
|
serverId,
|
||||||
workspaces: workspaces?.values() ?? [],
|
workspaces: workspaces?.values() ?? [],
|
||||||
emptyProjects: emptyProjects?.values() ?? [],
|
projects: projects.values(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects(
|
|||||||
return buildWorkspaceStructureProjects({ sessions });
|
return buildWorkspaceStructureProjects({ sessions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectProject(
|
||||||
|
state: SessionsSnapshot,
|
||||||
|
serverId: string | null,
|
||||||
|
projectId: string | null,
|
||||||
|
): ProjectDescriptor | null {
|
||||||
|
if (!serverId || !projectId) return null;
|
||||||
|
return state.sessions[serverId]?.projects?.get(projectId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectProjectIdForServer(
|
||||||
|
state: SessionsSnapshot,
|
||||||
|
input: {
|
||||||
|
sourceServerId: string;
|
||||||
|
projectId: string;
|
||||||
|
targetServerId: string;
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
if (input.sourceServerId === input.targetServerId) return input.projectId;
|
||||||
|
const source = selectProject(state, input.sourceServerId, input.projectId);
|
||||||
|
if (!source?.projectKey) return null;
|
||||||
|
for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) {
|
||||||
|
if (project.projectKey === source.projectKey) return project.projectId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
|
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
|
||||||
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
|
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ function getTestSessionReferences() {
|
|||||||
sessions: state.sessions,
|
sessions: state.sessions,
|
||||||
session,
|
session,
|
||||||
workspaces: session.workspaces,
|
workspaces: session.workspaces,
|
||||||
emptyProjects: session.emptyProjects,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,48 +404,6 @@ describe("removeWorkspace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removeEmptyProject", () => {
|
|
||||||
it("removes an empty project by project id", () => {
|
|
||||||
const store = useSessionStore.getState();
|
|
||||||
initializeTestSession();
|
|
||||||
store.setEmptyProjects("test-server", [
|
|
||||||
{
|
|
||||||
projectId: "project-empty",
|
|
||||||
projectDisplayName: "Empty",
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: "/repo/empty",
|
|
||||||
projectKind: "git",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
store.removeEmptyProject("test-server", "project-empty");
|
|
||||||
|
|
||||||
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves identity when removing a missing empty project", () => {
|
|
||||||
const store = useSessionStore.getState();
|
|
||||||
initializeTestSession();
|
|
||||||
store.setEmptyProjects("test-server", [
|
|
||||||
{
|
|
||||||
projectId: "project-empty",
|
|
||||||
projectDisplayName: "Empty",
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: "/repo/empty",
|
|
||||||
projectKind: "git",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const before = getTestSessionReferences();
|
|
||||||
|
|
||||||
store.removeEmptyProject("test-server", "project-missing");
|
|
||||||
const after = getTestSessionReferences();
|
|
||||||
|
|
||||||
expect(after.sessions).toBe(before.sessions);
|
|
||||||
expect(after.session).toBe(before.session);
|
|
||||||
expect(after.emptyProjects).toBe(before.emptyProjects);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("patchWorkspaceScripts", () => {
|
describe("patchWorkspaceScripts", () => {
|
||||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||||
const script = {
|
const script = {
|
||||||
|
|||||||
@@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmptyProjectDescriptor {
|
export interface ProjectDescriptor {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectKey?: string | null;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectCustomName: string | null;
|
projectCustomName: string | null;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEmptyProjectDescriptor(
|
export function normalizeProjectDescriptor(
|
||||||
payload: WorkspaceProjectDescriptorPayload,
|
payload: WorkspaceProjectDescriptorPayload,
|
||||||
): EmptyProjectDescriptor {
|
): ProjectDescriptor {
|
||||||
return {
|
return {
|
||||||
projectId: payload.projectId,
|
projectId: payload.projectId,
|
||||||
|
projectKey: payload.projectKey ?? null,
|
||||||
projectDisplayName: payload.projectDisplayName,
|
projectDisplayName: payload.projectDisplayName,
|
||||||
projectCustomName: payload.projectCustomName ?? null,
|
projectCustomName: payload.projectCustomName ?? null,
|
||||||
projectRootPath: payload.projectRootPath,
|
projectRootPath: payload.projectRootPath,
|
||||||
@@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity(
|
|||||||
return changed ? next : existing;
|
return changed ? next : existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyProjectDescriptorFromWorkspace(
|
function projectMapsEqual(
|
||||||
workspace: WorkspaceDescriptor,
|
left: ReadonlyMap<string, ProjectDescriptor>,
|
||||||
): EmptyProjectDescriptor {
|
right: ReadonlyMap<string, ProjectDescriptor>,
|
||||||
return {
|
|
||||||
projectId: workspace.projectId,
|
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
|
||||||
projectCustomName: workspace.projectCustomName ?? null,
|
|
||||||
projectRootPath: workspace.projectRootPath,
|
|
||||||
projectKind: workspace.projectKind,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasWorkspaceInProject(
|
|
||||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
|
|
||||||
projectId: string,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
for (const workspace of workspaces.values()) {
|
if (left.size !== right.size) return false;
|
||||||
if (workspace.projectId === projectId) {
|
for (const [projectId, project] of right) {
|
||||||
return true;
|
if (!equal(left.get(projectId), project)) return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
@@ -332,7 +321,7 @@ export interface SessionReplicaTimeline {
|
|||||||
export interface SessionReplica {
|
export interface SessionReplica {
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
projects: Map<string, ProjectDescriptor>;
|
||||||
timeline: SessionReplicaTimeline | null;
|
timeline: SessionReplicaTimeline | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,9 +372,8 @@ export interface SessionState {
|
|||||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||||
agentDetails: Map<string, Agent>;
|
agentDetails: Map<string, Agent>;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
// Project parents with no active workspaces, keyed by projectId. The
|
// All active project descriptors, keyed by host-local projectId.
|
||||||
// `emptyProjects` name is the existing protocol/store projection.
|
projects: Map<string, ProjectDescriptor>;
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
|
||||||
// Transient restore state for archived workspaces, keyed by normalized
|
// Transient restore state for archived workspaces, keyed by normalized
|
||||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||||
@@ -512,9 +500,9 @@ interface SessionStoreActions {
|
|||||||
) => void;
|
) => void;
|
||||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
|
||||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
||||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
removeProject: (serverId: string, projectId: string) => void;
|
||||||
setWorkspaceRestoreStatus: (
|
setWorkspaceRestoreStatus: (
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -599,7 +587,7 @@ function createInitialSessionState(
|
|||||||
workspaceAgentActivity: new Map(),
|
workspaceAgentActivity: new Map(),
|
||||||
agentDetails: new Map(),
|
agentDetails: new Map(),
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
projects: new Map(),
|
||||||
restoringWorkspaces: new Map(),
|
restoringWorkspaces: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
@@ -728,7 +716,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
agents: replica.agents,
|
agents: replica.agents,
|
||||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
||||||
workspaces: replica.workspaces,
|
workspaces: replica.workspaces,
|
||||||
emptyProjects: replica.emptyProjects,
|
projects: replica.projects,
|
||||||
agentStreamTail,
|
agentStreamTail,
|
||||||
agentTimelineCursor,
|
agentTimelineCursor,
|
||||||
agentTimelineHasOlder,
|
agentTimelineHasOlder,
|
||||||
@@ -1363,65 +1351,41 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setEmptyProjects: (serverId, emptyProjects) => {
|
setProjects: (serverId, projects) => {
|
||||||
const next = new Map<string, EmptyProjectDescriptor>();
|
const next = new Map<string, ProjectDescriptor>();
|
||||||
for (const project of emptyProjects) {
|
for (const project of projects) next.set(project.projectId, project);
|
||||||
next.set(project.projectId, project);
|
|
||||||
}
|
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
if (!session) {
|
if (!session || projectMapsEqual(session.projects, next)) return prev;
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
if (session.emptyProjects.size === 0 && next.size === 0) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } },
|
||||||
...prev.sessions,
|
|
||||||
[serverId]: { ...session, emptyProjects: next },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addEmptyProject: (serverId, emptyProject) => {
|
upsertProject: (serverId, project) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
if (!session) {
|
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
|
||||||
return prev;
|
const projects = new Map(session.projects);
|
||||||
}
|
projects.set(project.projectId, project);
|
||||||
const existing = session.emptyProjects.get(emptyProject.projectId);
|
|
||||||
if (existing && equal(existing, emptyProject)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(session.emptyProjects);
|
|
||||||
next.set(emptyProject.projectId, emptyProject);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||||
...prev.sessions,
|
|
||||||
[serverId]: { ...session, emptyProjects: next },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeEmptyProject: (serverId, projectId) => {
|
removeProject: (serverId, projectId) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
if (!session?.emptyProjects.has(projectId)) {
|
if (!session?.projects.has(projectId)) return prev;
|
||||||
return prev;
|
const projects = new Map(session.projects);
|
||||||
}
|
projects.delete(projectId);
|
||||||
const next = new Map(session.emptyProjects);
|
|
||||||
next.delete(projectId);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
||||||
...prev.sessions,
|
|
||||||
[serverId]: { ...session, emptyProjects: next },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1483,17 +1447,10 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
}
|
}
|
||||||
const next = new Map(session.workspaces);
|
const next = new Map(session.workspaces);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
// A workspace landing in a project means that project is no longer
|
|
||||||
// empty: prune any stale empty descriptor so it stops governing the
|
|
||||||
// project's rendered metadata.
|
|
||||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
|
||||||
// A descriptor arriving is the success signal for a pending restore:
|
// A descriptor arriving is the success signal for a pending restore:
|
||||||
// clear it at the source so every entry point converges to "ready".
|
// clear it at the source so every entry point converges to "ready".
|
||||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||||
for (const workspace of nextEntries) {
|
for (const workspace of nextEntries) {
|
||||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||||
nextRestoring.delete(workspace.id);
|
nextRestoring.delete(workspace.id);
|
||||||
@@ -1517,7 +1474,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
[serverId]: {
|
[serverId]: {
|
||||||
...session,
|
...session,
|
||||||
workspaces: next,
|
workspaces: next,
|
||||||
emptyProjects: nextEmptyProjects,
|
|
||||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1535,31 +1491,13 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
if (!session || !workspaceKey) {
|
if (!session || !workspaceKey) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const removedWorkspace = session.workspaces.get(workspaceKey);
|
|
||||||
if (!removedWorkspace) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(session.workspaces);
|
const next = new Map(session.workspaces);
|
||||||
next.delete(workspaceKey);
|
next.delete(workspaceKey);
|
||||||
let nextEmptyProjects = session.emptyProjects;
|
|
||||||
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
|
|
||||||
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
|
|
||||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
|
||||||
nextEmptyProjects.delete(removedWorkspace.projectId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
|
|
||||||
const existing = nextEmptyProjects.get(emptyProject.projectId);
|
|
||||||
if (!existing || !equal(existing, emptyProject)) {
|
|
||||||
nextEmptyProjects = new Map(nextEmptyProjects);
|
|
||||||
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: {
|
||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
|
[serverId]: { ...session, workspaces: next },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
useSessionStore,
|
||||||
|
type ProjectDescriptor,
|
||||||
|
type WorkspaceDescriptor,
|
||||||
|
} from "@/stores/session-store";
|
||||||
|
|
||||||
|
function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
// A daemon always derives a key for every project, so fixtures carry one too.
|
||||||
|
// Same key on two hosts means the same project, which is what grouping asserts.
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds a session the way a daemon populates it: every workspace's project is
|
||||||
|
* published on the project channel, so the store never holds a workspace whose
|
||||||
|
* project is unknown. Tests that seed workspaces alone model a state the daemon
|
||||||
|
* cannot produce, and nothing renders.
|
||||||
|
*/
|
||||||
|
export function seedSessionWorkspaces(
|
||||||
|
serverId: string,
|
||||||
|
workspaces: Map<string, WorkspaceDescriptor>,
|
||||||
|
projects?: Iterable<ProjectDescriptor>,
|
||||||
|
): void {
|
||||||
|
const byProjectId = new Map<string, ProjectDescriptor>();
|
||||||
|
for (const project of projects ?? []) byProjectId.set(project.projectId, project);
|
||||||
|
for (const workspace of workspaces.values()) {
|
||||||
|
if (!byProjectId.has(workspace.projectId)) {
|
||||||
|
byProjectId.set(workspace.projectId, projectFromWorkspace(workspace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
store.setProjects(serverId, byProjectId.values());
|
||||||
|
store.setWorkspaces(serverId, workspaces);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
encodeWorkspaceIdForPathSegment,
|
encodeWorkspaceIdForPathSegment,
|
||||||
isSettingsSectionSlug,
|
isSettingsSectionSlug,
|
||||||
normalizeHostSectionSlug,
|
normalizeHostSectionSlug,
|
||||||
|
normalizeProjectSettingsRouteId,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceOpenIntentFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
@@ -196,23 +197,14 @@ describe("projects settings routes", () => {
|
|||||||
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
|
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
|
it("buildProjectSettingsRoute addresses a host-local project id", () => {
|
||||||
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
|
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
|
||||||
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
|
"/settings/projects/host%20a/project%2F1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
|
it("keeps route ids opaque", () => {
|
||||||
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
|
expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1");
|
||||||
"/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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() {
|
|||||||
return "/settings/projects" as const;
|
return "/settings/projects" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProjectSettingsRoute(projectKey: string) {
|
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
|
||||||
const normalized = trimNonEmpty(projectKey);
|
if (!serverId.trim() || !projectId.trim()) {
|
||||||
if (!normalized) {
|
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
|
||||||
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
|
|
||||||
}
|
}
|
||||||
return `/settings/projects/${encodeSegment(normalized)}` as const;
|
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
|
||||||
|
const id = Array.isArray(value) ? value[0] : value;
|
||||||
|
return typeof id === "string" ? id : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,595 +1,107 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
|
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
|
||||||
import { buildProjects } from "./projects";
|
|
||||||
|
|
||||||
function placement(input: {
|
function descriptor(id: string, key: string, root: string): ProjectDescriptor {
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
remoteUrl: string | null;
|
|
||||||
mainRepoRoot?: string | null;
|
|
||||||
}): ProjectPlacementPayload {
|
|
||||||
return {
|
return {
|
||||||
projectKey: input.projectKey,
|
projectId: id,
|
||||||
projectName: input.projectName,
|
projectKey: key,
|
||||||
checkout: {
|
projectDisplayName: "acme/app",
|
||||||
cwd: input.cwd,
|
projectCustomName: null,
|
||||||
isGit: true,
|
projectRootPath: root,
|
||||||
currentBranch: "main",
|
projectKind: "git",
|
||||||
remoteUrl: input.remoteUrl,
|
|
||||||
worktreeRoot: input.cwd,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(input: {
|
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
|
||||||
id: string;
|
|
||||||
repoRoot: string;
|
|
||||||
project?: ProjectPlacementPayload;
|
|
||||||
projectId?: string;
|
|
||||||
projectName?: string;
|
|
||||||
remoteUrl?: string | null;
|
|
||||||
currentBranch?: string;
|
|
||||||
archivingAt?: string | null;
|
|
||||||
}): WorkspaceDescriptor {
|
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id,
|
||||||
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
|
projectId,
|
||||||
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
|
projectDisplayName: "acme/app",
|
||||||
projectRootPath: input.repoRoot,
|
projectCustomName: null,
|
||||||
workspaceDirectory: input.repoRoot,
|
projectRootPath: root,
|
||||||
|
workspaceDirectory: root,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "local_checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: input.id,
|
name: "main",
|
||||||
status: "done",
|
status: "done",
|
||||||
archivingAt: input.archivingAt ?? null,
|
|
||||||
statusEnteredAt: null,
|
statusEnteredAt: null,
|
||||||
|
archivingAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
gitRuntime: {
|
|
||||||
currentBranch: input.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", () => {
|
describe("buildProjects", () => {
|
||||||
it("normalizes detached and blank branches out of workspace summaries", () => {
|
test("uses the grouped project list while retaining each host project id", () => {
|
||||||
|
const key = "remote:github.com/acme/app";
|
||||||
const result = buildProjects({
|
const result = buildProjects({
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "host-a",
|
||||||
serverName: "Local",
|
serverName: "Host A",
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
workspaces: [
|
projects: [descriptor("prj_a", key, "/a/app")],
|
||||||
workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }),
|
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
|
||||||
workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
|
|
||||||
{ id: "detached", currentBranch: null },
|
|
||||||
{ id: "blank", currentBranch: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries active archive state into workspace summaries", () => {
|
|
||||||
const archivingAt = "2026-07-15T18:00:00.000Z";
|
|
||||||
const result = buildProjects({
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
serverId: "local",
|
|
||||||
serverName: "Local",
|
|
||||||
isOnline: true,
|
|
||||||
workspaces: [
|
|
||||||
workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }),
|
|
||||||
workspace({ id: "active", repoRoot: "/repo/app" }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
|
|
||||||
{ id: "archiving", archivingAt },
|
|
||||||
{ id: "active" },
|
|
||||||
]);
|
|
||||||
expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt");
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
serverId: "host-b",
|
||||||
serverName: "Laptop",
|
serverName: "Host B",
|
||||||
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,
|
isOnline: false,
|
||||||
workspaces: [
|
projects: [descriptor("prj_b", key, "/b/app")],
|
||||||
workspace({
|
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
|
||||||
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).toEqual([
|
||||||
expect(result.projects[0]?.hostCount).toBe(2);
|
expect.objectContaining({
|
||||||
expect(result.projects[0]?.onlineHostCount).toBe(1);
|
projectKey: key,
|
||||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
|
hostCount: 2,
|
||||||
true,
|
onlineHostCount: 1,
|
||||||
);
|
totalWorkspaceCount: 2,
|
||||||
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
|
hosts: [
|
||||||
false,
|
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }),
|
||||||
);
|
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }),
|
||||||
});
|
],
|
||||||
|
}),
|
||||||
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("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
|
test("includes a project with no workspaces", () => {
|
||||||
const result = buildProjects({
|
const result = buildProjects({
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: "local",
|
serverId: "host-a",
|
||||||
serverName: "Local",
|
serverName: "Host A",
|
||||||
isOnline: true,
|
|
||||||
workspaces: [
|
|
||||||
workspace({
|
|
||||||
id: "github",
|
|
||||||
repoRoot: "/repo/github",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "remote:github.com/acme/web",
|
|
||||||
projectName: "acme/web",
|
|
||||||
cwd: "/repo/github",
|
|
||||||
remoteUrl: "https://github.com/acme/web.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
workspace({
|
|
||||||
id: "gitlab",
|
|
||||||
repoRoot: "/repo/gitlab",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "remote:gitlab.com/acme/api",
|
|
||||||
projectName: "acme/api",
|
|
||||||
cwd: "/repo/gitlab",
|
|
||||||
remoteUrl: "https://gitlab.com/acme/api.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
workspace({
|
|
||||||
id: "bitbucket",
|
|
||||||
repoRoot: "/repo/bitbucket",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "remote:bitbucket.org/acme/cli",
|
|
||||||
projectName: "acme/cli",
|
|
||||||
cwd: "/repo/bitbucket",
|
|
||||||
remoteUrl: "https://bitbucket.org/acme/cli.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
workspace({
|
|
||||||
id: "local",
|
|
||||||
repoRoot: "/repo/local",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "/repo/local",
|
|
||||||
projectName: "local",
|
|
||||||
cwd: "/repo/local",
|
|
||||||
remoteUrl: null,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
|
||||||
"remote:gitlab.com/acme/api",
|
|
||||||
"remote:bitbucket.org/acme/cli",
|
|
||||||
"remote:github.com/acme/web",
|
|
||||||
"/repo/local",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a non-GitHub remote project on its own when no other 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: "acme/app",
|
|
||||||
cwd: "/repo/gitlab",
|
|
||||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects).toHaveLength(1);
|
|
||||||
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
|
|
||||||
expect(result.projects[0]?.projectName).toBe("acme/app");
|
|
||||||
expect(result.projects[0]?.githubUrl).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
|
|
||||||
const result = buildProjects({
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
serverId: "local",
|
|
||||||
serverName: "Local",
|
|
||||||
isOnline: true,
|
|
||||||
workspaces: [
|
|
||||||
workspace({
|
|
||||||
id: "main",
|
|
||||||
repoRoot: "/repo/gitlab",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "remote:gitlab.com/acme/app",
|
|
||||||
projectName: "acme/app",
|
|
||||||
cwd: "/repo/gitlab",
|
|
||||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
workspace({
|
|
||||||
id: "feature",
|
|
||||||
repoRoot: "/repo/gitlab",
|
|
||||||
project: placement({
|
|
||||||
projectKey: "remote:gitlab.com/acme/app",
|
|
||||||
projectName: "acme/app",
|
|
||||||
cwd: "/repo/gitlab/feature",
|
|
||||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.projects).toHaveLength(1);
|
|
||||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
|
||||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
|
|
||||||
const result = buildProjects({
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
serverId: "local",
|
|
||||||
serverName: "Local",
|
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
|
projects: [descriptor("prj_a", "local-a", "/a/app")],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
emptyProjects: [
|
|
||||||
{
|
|
||||||
projectId: "/repo/fresh",
|
|
||||||
projectDisplayName: "fresh",
|
|
||||||
projectCustomName: null,
|
|
||||||
projectRootPath: "/repo/fresh",
|
|
||||||
projectKind: "git",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.projects).toHaveLength(1);
|
expect(result.projects[0]).toMatchObject({
|
||||||
const summary = result.projects[0];
|
totalWorkspaceCount: 0,
|
||||||
expect(summary?.projectKey).toBe("/repo/fresh");
|
hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }],
|
||||||
expect(summary?.totalWorkspaceCount).toBe(0);
|
});
|
||||||
expect(summary?.hosts).toHaveLength(1);
|
});
|
||||||
// repoRoot must be non-empty or the project settings screen treats the host
|
|
||||||
// as non-editable and shows its empty state.
|
test("looks up a grouped project by host-local identity", () => {
|
||||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
|
const project = buildProjects({
|
||||||
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
|
hosts: [
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
serverName: "Host A",
|
||||||
|
isOnline: true,
|
||||||
|
projects: [descriptor("prj_a", "shared", "/a/app")],
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).projects[0];
|
||||||
|
|
||||||
|
expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe(
|
||||||
|
project,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
|
import type { HostProjectListItem } from "@/projects/host-project-model";
|
||||||
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||||
|
|
||||||
export interface WorkspaceSummary {
|
export interface WorkspaceSummary {
|
||||||
@@ -14,6 +14,9 @@ export interface WorkspaceSummary {
|
|||||||
|
|
||||||
export interface ProjectHostEntry {
|
export interface ProjectHostEntry {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
projectCustomName: string | null;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
@@ -39,8 +42,7 @@ export interface ProjectHost {
|
|||||||
serverName: string;
|
serverName: string;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
workspaces: WorkspaceDescriptor[];
|
workspaces: WorkspaceDescriptor[];
|
||||||
/** Project parents with no active workspaces, so they still surface as projects. */
|
projects: ProjectDescriptor[];
|
||||||
emptyProjects?: EmptyProjectDescriptor[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildProjectsInput {
|
export interface BuildProjectsInput {
|
||||||
@@ -51,10 +53,35 @@ export interface BuildProjectsResult {
|
|||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
export function getProjectSummaryForHostProject(
|
||||||
|
projects: readonly ProjectSummary[],
|
||||||
|
serverId: string,
|
||||||
|
projectId: string,
|
||||||
|
): ProjectSummary | undefined {
|
||||||
|
for (const project of projects) {
|
||||||
|
for (const host of project.hosts) {
|
||||||
|
if (host.serverId === serverId && host.projectId === projectId) return project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectHostEntry(
|
||||||
|
project: ProjectSummary | undefined,
|
||||||
|
serverId: string,
|
||||||
|
projectId?: string,
|
||||||
|
): ProjectHostEntry | undefined {
|
||||||
|
for (const host of project?.hosts ?? []) {
|
||||||
|
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface HostGroup {
|
interface HostGroup {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
projectCustomName: string | null;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
workspaces: WorkspaceDescriptor[];
|
workspaces: WorkspaceDescriptor[];
|
||||||
@@ -70,50 +97,32 @@ interface ProjectGroup {
|
|||||||
hostsByServerId: Map<string, HostGroup>;
|
hostsByServerId: Map<string, HostGroup>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findProjectCustomName(
|
function findProjectMetadata(
|
||||||
workspaces: WorkspaceDescriptor[],
|
host: ProjectHost,
|
||||||
projectKey: string,
|
projectKey: string,
|
||||||
): { customName: string; displayName: string } | null {
|
): { customName: string | null; displayName: string } | null {
|
||||||
for (const workspace of workspaces) {
|
for (const project of host.projects) {
|
||||||
if (workspace.projectId === projectKey && workspace.projectCustomName) {
|
if (project.projectId === projectKey) {
|
||||||
return {
|
return {
|
||||||
customName: workspace.projectCustomName,
|
customName: project.projectCustomName ?? null,
|
||||||
displayName: workspace.projectDisplayName,
|
displayName: project.projectDisplayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
|
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
|
||||||
return buildHostProjectList({
|
return buildWorkspaceStructureProjects({
|
||||||
projects: buildWorkspaceStructureProjects({
|
sessions: hosts.map((host) => ({
|
||||||
sessions: [
|
serverId: host.serverId,
|
||||||
{
|
projects: host.projects,
|
||||||
serverId: host.serverId,
|
workspaces: host.workspaces,
|
||||||
workspaces: host.workspaces,
|
})),
|
||||||
emptyProjects: host.emptyProjects,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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(group: HostGroup): string {
|
function resolveHostRepoRoot(group: HostGroup): string {
|
||||||
for (const workspace of group.workspaces) {
|
|
||||||
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
|
|
||||||
if (mainRepoRoot) {
|
|
||||||
return mainRepoRoot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
|
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
|
|||||||
group.workspaces[0];
|
group.workspaces[0];
|
||||||
return {
|
return {
|
||||||
serverId: group.serverId,
|
serverId: group.serverId,
|
||||||
|
projectId: group.projectId,
|
||||||
|
projectName: group.projectName,
|
||||||
|
projectCustomName: group.projectCustomName,
|
||||||
serverName: group.serverName,
|
serverName: group.serverName,
|
||||||
isOnline: group.isOnline,
|
isOnline: group.isOnline,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
@@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
|
|||||||
totalWorkspaceCount,
|
totalWorkspaceCount,
|
||||||
hostCount: hosts.length,
|
hostCount: hosts.length,
|
||||||
onlineHostCount,
|
onlineHostCount,
|
||||||
githubUrl: deriveGithubUrl(draft.projectKey),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addHostProjects(
|
||||||
|
groups: Map<string, ProjectGroup>,
|
||||||
|
host: ProjectHost,
|
||||||
|
hostProjects: HostProjectListItem[],
|
||||||
|
): void {
|
||||||
|
const repoRootByProjectId = new Map(
|
||||||
|
host.projects.map((project) => [project.projectId, project.projectRootPath]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const hostProject of hostProjects) {
|
||||||
|
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
|
||||||
|
if (!placement) continue;
|
||||||
|
const projectId = placement.projectId;
|
||||||
|
const customName = findProjectMetadata(host, projectId);
|
||||||
|
let group = groups.get(hostProject.projectKey);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
projectKey: hostProject.projectKey,
|
||||||
|
projectName: customName?.displayName ?? hostProject.projectName,
|
||||||
|
projectCustomName: customName?.customName ?? null,
|
||||||
|
hostsByServerId: new Map(),
|
||||||
|
};
|
||||||
|
groups.set(hostProject.projectKey, group);
|
||||||
|
} else if (customName?.customName && !group.projectCustomName) {
|
||||||
|
group.projectCustomName = customName.customName;
|
||||||
|
group.projectName = customName.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.hostsByServerId.has(host.serverId)) {
|
||||||
|
group.hostsByServerId.set(host.serverId, {
|
||||||
|
serverId: host.serverId,
|
||||||
|
projectId,
|
||||||
|
projectName: customName?.displayName ?? hostProject.projectName,
|
||||||
|
projectCustomName: customName?.customName ?? null,
|
||||||
|
serverName: host.serverName,
|
||||||
|
isOnline: host.isOnline,
|
||||||
|
workspaces: [],
|
||||||
|
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachHostWorkspaces(
|
||||||
|
groups: Map<string, ProjectGroup>,
|
||||||
|
host: ProjectHost,
|
||||||
|
hostProjects: HostProjectListItem[],
|
||||||
|
): void {
|
||||||
|
const projectKeyByProjectId = new Map(
|
||||||
|
hostProjects.flatMap((project) =>
|
||||||
|
project.hosts
|
||||||
|
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
|
||||||
|
.map((placement) => [placement.projectId!, project.projectKey] as const),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const workspace of host.workspaces) {
|
||||||
|
const key = projectKeyByProjectId.get(workspace.projectId);
|
||||||
|
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||||
const groups = new Map<string, ProjectGroup>();
|
const groups = new Map<string, ProjectGroup>();
|
||||||
|
const projectEntries = buildHostProjectEntries(input.hosts);
|
||||||
|
|
||||||
for (const host of input.hosts) {
|
for (const host of input.hosts) {
|
||||||
const emptyRepoRootByProjectKey = new Map<string, string>();
|
const hostProjects = projectEntries.filter((project) =>
|
||||||
for (const emptyProject of host.emptyProjects ?? []) {
|
project.hosts.some((placement) => placement.serverId === host.serverId),
|
||||||
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
|
);
|
||||||
}
|
addHostProjects(groups, host, hostProjects);
|
||||||
|
attachHostWorkspaces(groups, host, hostProjects);
|
||||||
const hostProjects = buildHostProjectEntries(host);
|
|
||||||
for (const hostProject of hostProjects) {
|
|
||||||
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
|
|
||||||
let group = groups.get(hostProject.projectKey);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
projectKey: hostProject.projectKey,
|
|
||||||
projectName: customName?.displayName ?? hostProject.projectName,
|
|
||||||
projectCustomName: customName?.customName ?? null,
|
|
||||||
hostsByServerId: new Map(),
|
|
||||||
};
|
|
||||||
groups.set(hostProject.projectKey, group);
|
|
||||||
} else if (customName && !group.projectCustomName) {
|
|
||||||
group.projectCustomName = customName.customName;
|
|
||||||
group.projectName = customName.displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!group.hostsByServerId.has(host.serverId)) {
|
|
||||||
group.hostsByServerId.set(host.serverId, {
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline: host.isOnline,
|
|
||||||
workspaces: [],
|
|
||||||
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const workspace of host.workspaces) {
|
|
||||||
const group = groups.get(workspace.projectId);
|
|
||||||
const hostGroup = group?.hostsByServerId.get(host.serverId);
|
|
||||||
if (!hostGroup) continue;
|
|
||||||
hostGroup.workspaces.push(workspace);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = Array.from(groups.values()).map(toProjectSummary);
|
const projects = Array.from(groups.values()).map(toProjectSummary);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
buildSidebarProjectRowModel,
|
buildSidebarProjectRowModel,
|
||||||
resolveSidebarProjectIconTarget,
|
resolveSidebarProjectIconTarget,
|
||||||
|
resolveSidebarProjectLocalPath,
|
||||||
} from "./sidebar-project-row-model";
|
} from "./sidebar-project-row-model";
|
||||||
|
|
||||||
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
|
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
|
||||||
@@ -34,18 +35,26 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
|
type ProjectOverrides = Omit<Partial<SidebarProjectEntry>, "hosts"> & {
|
||||||
|
hosts?: Array<Omit<SidebarProjectEntry["hosts"][number], "projectId"> & { projectId?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
|
||||||
const projectKind = overrides.projectKind ?? "git";
|
const projectKind = overrides.projectKind ?? "git";
|
||||||
|
const hosts = Array.from(
|
||||||
|
overrides.hosts ?? [
|
||||||
|
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
|
||||||
|
],
|
||||||
|
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
projectKey: "project-1",
|
projectKey: "project-1",
|
||||||
projectName: "paseo",
|
projectName: "paseo",
|
||||||
projectKind,
|
projectKind,
|
||||||
iconWorkingDir: "/repo",
|
iconWorkingDir: "/repo",
|
||||||
hosts: overrides.hosts ?? [
|
|
||||||
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
|
|
||||||
],
|
|
||||||
workspaces: [workspace()],
|
workspaces: [workspace()],
|
||||||
...overrides,
|
...overrides,
|
||||||
|
hosts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
chevron: "expand",
|
chevron: "expand",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_workspace",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
|
|
||||||
expect(result.trailingAction).toEqual({
|
expect(result.trailingAction).toEqual({
|
||||||
kind: "new_workspace",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
|
|
||||||
expect(result.trailingAction).toEqual({
|
expect(result.trailingAction).toEqual({
|
||||||
kind: "new_workspace",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
chevron: "expand",
|
chevron: "expand",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_workspace",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
|
expect(iconTarget).toEqual({
|
||||||
|
serverId: "host-b",
|
||||||
|
projectId: "project-host-b",
|
||||||
|
iconWorkingDir: "/repo/b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves desktop file actions from the local project placement", () => {
|
||||||
|
const groupedProject = project({
|
||||||
|
iconWorkingDir: "/remote/repo",
|
||||||
|
hosts: [
|
||||||
|
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
|
||||||
|
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
|
||||||
|
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an empty project as an expandable section", () => {
|
it("renders an empty project as an expandable section", () => {
|
||||||
@@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
chevron: "collapse",
|
chevron: "collapse",
|
||||||
trailingAction: {
|
trailingAction: {
|
||||||
kind: "new_workspace",
|
kind: "new_workspace",
|
||||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
|||||||
|
|
||||||
export interface SidebarProjectHostTarget {
|
export interface SidebarProjectHostTarget {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
iconWorkingDir: string;
|
iconWorkingDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel {
|
|||||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||||
|
|
||||||
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||||
|
|
||||||
function hostTarget(input: {
|
function hostTarget(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
projectId: string;
|
||||||
iconWorkingDir: string;
|
iconWorkingDir: string;
|
||||||
}): SidebarProjectHostTarget | null {
|
}): SidebarProjectHostTarget | null {
|
||||||
const iconWorkingDir = input.iconWorkingDir.trim();
|
const iconWorkingDir = input.iconWorkingDir.trim();
|
||||||
if (!input.serverId || !iconWorkingDir) {
|
if (!input.serverId || !iconWorkingDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { serverId: input.serverId, iconWorkingDir };
|
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSidebarProjectIconTarget(
|
export function resolveSidebarProjectIconTarget(
|
||||||
@@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSidebarProjectLocalPath(
|
||||||
|
project: SidebarProjectEntry,
|
||||||
|
localServerId: string | null,
|
||||||
|
): string {
|
||||||
|
if (!localServerId) return "";
|
||||||
|
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
// A project can host a brand-new workspace on a host when that host can create a
|
// A project can host a brand-new workspace on a host when that host can create a
|
||||||
// git worktree (git projects) OR the host supports running multiple independent
|
// git worktree (git projects) OR the host supports running multiple independent
|
||||||
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
||||||
@@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const target = hostTarget(host);
|
const target = hostTarget(host);
|
||||||
if (target) {
|
if (target) return target;
|
||||||
return target;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
|||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: workspaces[0]?.serverId ?? "s1",
|
serverId: workspaces[0]?.serverId ?? "s1",
|
||||||
|
projectId: projectKey,
|
||||||
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
||||||
canCreateWorktree: true,
|
canCreateWorktree: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
|
import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
|
||||||
|
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
|
||||||
import {
|
import {
|
||||||
applyLegacyDaemonWorkspaceOwnership,
|
applyLegacyDaemonWorkspaceOwnership,
|
||||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||||
@@ -10,6 +11,17 @@ import {
|
|||||||
|
|
||||||
const SERVER_ID = "srv_legacy";
|
const SERVER_ID = "srv_legacy";
|
||||||
|
|
||||||
|
function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectKey: null,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function legacyAgent(input: {
|
function legacyAgent(input: {
|
||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -118,6 +130,38 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps matching legacy path projects separate across hosts", () => {
|
||||||
|
const first = buildLegacyDaemonWorkspaceSnapshot({
|
||||||
|
serverId: "host-a",
|
||||||
|
entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })],
|
||||||
|
});
|
||||||
|
const second = buildLegacyDaemonWorkspaceSnapshot({
|
||||||
|
serverId: "host-b",
|
||||||
|
entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = buildWorkspaceStructureProjects({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
serverId: "host-a",
|
||||||
|
projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace),
|
||||||
|
workspaces: first.workspaces.values(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: "host-b",
|
||||||
|
projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace),
|
||||||
|
workspaces: second.workspaces.values(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projects).toHaveLength(2);
|
||||||
|
expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([
|
||||||
|
"host-a",
|
||||||
|
"host-b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps old-daemon agent updates attached to the path-backed workspace", () => {
|
it("keeps old-daemon agent updates attached to the path-backed workspace", () => {
|
||||||
const snapshot = buildLegacyDaemonWorkspaceSnapshot({
|
const snapshot = buildLegacyDaemonWorkspaceSnapshot({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
@@ -185,7 +229,7 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
|
|||||||
client,
|
client,
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
projects: new Map(),
|
||||||
isCancelled: () => cancelled,
|
isCancelled: () => cancelled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
deriveAgentStateBucket,
|
deriveAgentStateBucket,
|
||||||
getWorkspaceStateBucketPriority,
|
getWorkspaceStateBucketPriority,
|
||||||
} from "@getpaseo/protocol/agent-state-bucket";
|
} from "@getpaseo/protocol/agent-state-bucket";
|
||||||
import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store";
|
import type {
|
||||||
|
Agent,
|
||||||
|
DaemonServerInfo,
|
||||||
|
ProjectDescriptor,
|
||||||
|
WorkspaceDescriptor,
|
||||||
|
} from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
buildAgentDirectoryState,
|
buildAgentDirectoryState,
|
||||||
@@ -33,7 +38,7 @@ interface LegacyDaemonWorkspaceBackfillInput {
|
|||||||
client: Pick<DaemonClient, "fetchAgents">;
|
client: Pick<DaemonClient, "fetchAgents">;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: ReadonlyMap<unknown, unknown>;
|
workspaces: ReadonlyMap<unknown, unknown>;
|
||||||
emptyProjects: ReadonlyMap<unknown, unknown>;
|
projects: ReadonlyMap<unknown, unknown>;
|
||||||
isCancelled?: () => boolean;
|
isCancelled?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ export async function fetchLegacyDaemonWorkspaceDirectory(input: {
|
|||||||
export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
|
export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
|
||||||
input: LegacyDaemonWorkspaceBackfillInput,
|
input: LegacyDaemonWorkspaceBackfillInput,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (input.workspaces.size > 0 || input.emptyProjects.size > 0) {
|
if (input.workspaces.size > 0 || input.projects.size > 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo;
|
const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo;
|
||||||
@@ -205,11 +210,25 @@ export function replaceLegacyDaemonWorkspaceDirectory(input: {
|
|||||||
const workspaces = buildLegacyWorkspaces(entries);
|
const workspaces = buildLegacyWorkspaces(entries);
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setWorkspaces(input.serverId, workspaces);
|
store.setWorkspaces(input.serverId, workspaces);
|
||||||
store.setEmptyProjects(input.serverId, []);
|
store.setProjects(
|
||||||
|
input.serverId,
|
||||||
|
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
|
||||||
|
);
|
||||||
store.setHasHydratedWorkspaces(input.serverId, true);
|
store.setHasHydratedWorkspaces(input.serverId, true);
|
||||||
return { agents, workspaces };
|
return { agents, workspaces };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
||||||
|
return {
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectKey: null,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
projectCustomName: workspace.projectCustomName ?? null,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readFetchAgentsHasMore(
|
function readFetchAgentsHasMore(
|
||||||
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -678,6 +678,10 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
|
|||||||
};
|
};
|
||||||
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
|
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
|
||||||
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
|
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
|
||||||
|
export type ProjectListPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "project.list.response" }
|
||||||
|
>["payload"];
|
||||||
export interface CreateChatRoomOptions {
|
export interface CreateChatRoomOptions {
|
||||||
name: string;
|
name: string;
|
||||||
purpose?: string | null;
|
purpose?: string | null;
|
||||||
@@ -2092,6 +2096,24 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listProjects(requestId?: string): Promise<ProjectListPayload> {
|
||||||
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "project.list.request",
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
});
|
||||||
|
return this.sendRequest({
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
message,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "project.list.response") return null;
|
||||||
|
if (msg.payload.requestId !== resolvedRequestId) return null;
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
|
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@@ -48,4 +48,19 @@ describe("parseGitRemoteLocation port", () => {
|
|||||||
it("has no port for an scp-form remote", () => {
|
it("has no port for an scp-form remote", () => {
|
||||||
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
|
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits an explicitly written default port", () => {
|
||||||
|
expect(parseGitRemoteLocation("ssh://git@github.com:22/acme/repo.git")).toEqual(
|
||||||
|
parseGitRemoteLocation("ssh://git@github.com/acme/repo.git"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a scheme remote as a URL rather than scp form", () => {
|
||||||
|
expect(parseGitRemoteLocation("ssh://git@example.com:2222/acme/repo.git")).toEqual({
|
||||||
|
transport: "ssh",
|
||||||
|
host: "example.com",
|
||||||
|
port: "2222",
|
||||||
|
path: "acme/repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
|||||||
"ssh:": "ssh",
|
"ssh:": "ssh",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PORT_BY_PROTOCOL: Record<string, string> = {
|
||||||
|
"https:": "443",
|
||||||
|
"http:": "80",
|
||||||
|
"ssh:": "22",
|
||||||
|
};
|
||||||
|
|
||||||
export interface GitRemoteLocation {
|
export interface GitRemoteLocation {
|
||||||
transport: "scp" | "ssh" | "http" | "https";
|
transport: "scp" | "ssh" | "http" | "https";
|
||||||
host: string;
|
host: string;
|
||||||
@@ -50,7 +56,9 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
|||||||
const trimmed = remoteUrl.trim();
|
const trimmed = remoteUrl.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
// scp form has no scheme. Testing it first would match `ssh://git@host:22/x`
|
||||||
|
// — `[^@]+` happily eats `ssh://git` — and swallow the port into the path.
|
||||||
|
const scpLike = trimmed.includes("://") ? null : trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||||
if (scpLike) {
|
if (scpLike) {
|
||||||
const host = normalizeHost(scpLike[1] ?? "");
|
const host = normalizeHost(scpLike[1] ?? "");
|
||||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||||
@@ -78,7 +86,10 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
|||||||
const normalizedPath = normalizeRemotePath(path);
|
const normalizedPath = normalizeRemotePath(path);
|
||||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||||
|
|
||||||
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
|
const protocol = parsed.protocol.toLowerCase();
|
||||||
|
const port =
|
||||||
|
parsed.port && parsed.port !== DEFAULT_PORT_BY_PROTOCOL[protocol] ? parsed.port : undefined;
|
||||||
|
return { transport, host, port, path: normalizedPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||||
|
|||||||
@@ -1072,6 +1072,11 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectListRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("project.list.request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const FetchAgentHistoryRequestMessageSchema = z.object({
|
export const FetchAgentHistoryRequestMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agent_history_request"),
|
type: z.literal("fetch_agent_history_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -2445,6 +2450,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchAgentHistoryRequestMessageSchema,
|
FetchAgentHistoryRequestMessageSchema,
|
||||||
FetchRecentProviderSessionsRequestMessageSchema,
|
FetchRecentProviderSessionsRequestMessageSchema,
|
||||||
FetchWorkspacesRequestMessageSchema,
|
FetchWorkspacesRequestMessageSchema,
|
||||||
|
ProjectListRequestMessageSchema,
|
||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
DeleteAgentRequestMessageSchema,
|
DeleteAgentRequestMessageSchema,
|
||||||
ArchiveAgentRequestMessageSchema,
|
ArchiveAgentRequestMessageSchema,
|
||||||
@@ -2814,6 +2820,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||||
projectCreateDirectory: z.boolean().optional(),
|
projectCreateDirectory: z.boolean().optional(),
|
||||||
|
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
|
||||||
|
projectList: z.boolean().optional(),
|
||||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
commitsList: z.boolean().optional(),
|
commitsList: z.boolean().optional(),
|
||||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
|
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
|
||||||
@@ -3232,6 +3240,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
|||||||
// workspace is archived.
|
// workspace is archived.
|
||||||
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28.
|
||||||
|
projectKey: z.string().optional(),
|
||||||
projectDisplayName: z.string(),
|
projectDisplayName: z.string(),
|
||||||
projectCustomName: z.string().nullable().optional(),
|
projectCustomName: z.string().nullable().optional(),
|
||||||
projectRootPath: z.string(),
|
projectRootPath: z.string(),
|
||||||
@@ -3288,6 +3298,14 @@ export const ProjectUpdateMessageSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectListResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("project.list.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
projects: z.array(WorkspaceProjectDescriptorPayloadSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ScriptStatusUpdateMessageSchema = z.object({
|
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||||
type: z.literal("script_status_update"),
|
type: z.literal("script_status_update"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -5185,6 +5203,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentUpdateMessageSchema,
|
AgentUpdateMessageSchema,
|
||||||
WorkspaceUpdateMessageSchema,
|
WorkspaceUpdateMessageSchema,
|
||||||
ProjectUpdateMessageSchema,
|
ProjectUpdateMessageSchema,
|
||||||
|
ProjectListResponseMessageSchema,
|
||||||
ScriptStatusUpdateMessageSchema,
|
ScriptStatusUpdateMessageSchema,
|
||||||
WorkspaceSetupProgressMessageSchema,
|
WorkspaceSetupProgressMessageSchema,
|
||||||
WorkspaceSetupStatusResponseMessageSchema,
|
WorkspaceSetupStatusResponseMessageSchema,
|
||||||
@@ -5363,6 +5382,7 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
|
|||||||
export type WorkspaceProjectDescriptorPayload = z.infer<
|
export type WorkspaceProjectDescriptorPayload = z.infer<
|
||||||
typeof WorkspaceProjectDescriptorPayloadSchema
|
typeof WorkspaceProjectDescriptorPayloadSchema
|
||||||
>;
|
>;
|
||||||
|
export type ProjectListResponseMessage = z.infer<typeof ProjectListResponseMessageSchema>;
|
||||||
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
|
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
|
||||||
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
|
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
|
||||||
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
|
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
|
||||||
@@ -5508,6 +5528,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
|||||||
typeof FetchRecentProviderSessionsRequestMessageSchema
|
typeof FetchRecentProviderSessionsRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||||
|
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||||
|
|||||||
@@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
get: async (projectId) => (projectId === project.projectId ? project : null),
|
get: async (projectId) => (projectId === project.projectId ? project : null),
|
||||||
|
list: async () => [project],
|
||||||
},
|
},
|
||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies {
|
|||||||
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
||||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||||
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||||
projectRegistry?: Pick<ProjectRegistry, "get">;
|
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
|
||||||
createDirectoryWorkspace?: (
|
createDirectoryWorkspace?: (
|
||||||
cwd: string,
|
cwd: string,
|
||||||
title?: string | null,
|
title?: string | null,
|
||||||
|
|||||||
@@ -793,6 +793,7 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||||
|
serverId,
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
@@ -829,6 +830,7 @@ export async function createPaseoDaemon(
|
|||||||
await agentStorage.initialize();
|
await agentStorage.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
||||||
await bootstrapWorkspaceRegistries({
|
await bootstrapWorkspaceRegistries({
|
||||||
|
serverId,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
@@ -842,6 +844,7 @@ export async function createPaseoDaemon(
|
|||||||
releaseWorkspaceServicePortPlan(workspaceId);
|
releaseWorkspaceServicePortPlan(workspaceId);
|
||||||
};
|
};
|
||||||
const workspaceReconciliation = new WorkspaceReconciliationService({
|
const workspaceReconciliation = new WorkspaceReconciliationService({
|
||||||
|
serverId,
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger,
|
logger,
|
||||||
@@ -857,7 +860,7 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await workspaceReconciliation.start();
|
await workspaceReconciliation.start();
|
||||||
void workspaceReconciliation.runOnce().catch((error) => {
|
void workspaceReconciliation.reconcileNow().catch((error) => {
|
||||||
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
logger.warn({ err: error }, "Initial workspace reconciliation failed");
|
||||||
});
|
});
|
||||||
await chatService.initialize();
|
await chatService.initialize();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
|||||||
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
|
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
|
import { deriveProjectKey } from "./project-key.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
@@ -73,7 +74,16 @@ test("creates a worktree and registers it in the source workspace project withou
|
|||||||
expect(result.workspace.baseBranch).toBe("main");
|
expect(result.workspace.baseBranch).toBe("main");
|
||||||
expect(result.workspace.title).toBe("Feature One");
|
expect(result.workspace.title).toBe("Feature One");
|
||||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
|
expect(deps.projects.get(sourceProject.projectId)).toEqual({
|
||||||
|
...sourceProject,
|
||||||
|
projectKey: deriveProjectKey({
|
||||||
|
rootPath: result.repoRoot,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
});
|
||||||
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,7 +446,16 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.workspace.projectId).toBe(project.projectId);
|
expect(result.workspace.projectId).toBe(project.projectId);
|
||||||
expect(deps.projects.get(project.projectId)).toEqual(project);
|
expect(deps.projects.get(project.projectId)).toEqual({
|
||||||
|
...project,
|
||||||
|
projectKey: deriveProjectKey({
|
||||||
|
rootPath: project.rootPath,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { deriveProjectGroupingDisplayName, deriveProjectKey } from "./project-key.js";
|
||||||
|
|
||||||
|
describe("deriveProjectKey", () => {
|
||||||
|
const rootPath = path.resolve("repo");
|
||||||
|
const derive = (remoteUrl: string) =>
|
||||||
|
deriveProjectKey({ rootPath, remoteUrl, worktreeRoot: rootPath, mainRepoRoot: null });
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"https://github.com/getpaseo/paseo.git",
|
||||||
|
"ssh://git@github.com/getpaseo/paseo.git",
|
||||||
|
"git@github.com:getpaseo/paseo.git",
|
||||||
|
])("normalizes common remote form %s", (remoteUrl) => {
|
||||||
|
expect(derive(remoteUrl)).toBe("remote:github.com/getpaseo/paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes GitHub casing", () => {
|
||||||
|
expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe("remote:github.com/getpaseo/paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves self-hosted paths and explicit ports", () => {
|
||||||
|
expect(derive("https://git.example.com:8443/Team/App.git")).toBe(
|
||||||
|
"remote:git.example.com:8443/Team/App",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups an SSH remote whether or not its default port is spelled out", () => {
|
||||||
|
expect(derive("ssh://git@github.com:22/getpaseo/paseo.git")).toBe(
|
||||||
|
derive("ssh://git@github.com/getpaseo/paseo.git"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps projects without a remote host-local", () => {
|
||||||
|
const hostA = deriveProjectKey({
|
||||||
|
rootPath,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
serverId: "host-a",
|
||||||
|
});
|
||||||
|
const hostB = deriveProjectKey({
|
||||||
|
rootPath,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
serverId: "host-b",
|
||||||
|
});
|
||||||
|
expect(hostA).not.toBe(hostB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses one host-local key for two spellings of the same directory", () => {
|
||||||
|
const tempRoot = mkdtempSync(path.join(tmpdir(), "project-key-identity-"));
|
||||||
|
const projectRoot = path.join(tempRoot, "project");
|
||||||
|
const alternateRoot = path.join(tempRoot, "project-alias");
|
||||||
|
const deriveLocalProject = (localRootPath: string) =>
|
||||||
|
deriveProjectKey({
|
||||||
|
rootPath: localRootPath,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
serverId: "host-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(projectRoot);
|
||||||
|
symlinkSync(projectRoot, alternateRoot, process.platform === "win32" ? "junction" : "dir");
|
||||||
|
expect(deriveLocalProject(alternateRoot)).toBe(deriveLocalProject(projectRoot));
|
||||||
|
} finally {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a repository root distinct from one of its subprojects", () => {
|
||||||
|
const worktreeRoot = path.resolve("host-a", "repo");
|
||||||
|
const remoteUrl = "git@github.com:getpaseo/paseo.git";
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveProjectKey({ rootPath: worktreeRoot, remoteUrl, worktreeRoot, mainRepoRoot: null }),
|
||||||
|
).toBe("remote:github.com/getpaseo/paseo");
|
||||||
|
expect(
|
||||||
|
deriveProjectKey({
|
||||||
|
rootPath: path.join(worktreeRoot, "packages", "app"),
|
||||||
|
remoteUrl,
|
||||||
|
worktreeRoot,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups the same subproject across different absolute checkout roots", () => {
|
||||||
|
const remoteUrl = "git@github.com:getpaseo/paseo.git";
|
||||||
|
const deriveSubproject = (worktreeRoot: string) =>
|
||||||
|
deriveProjectKey({
|
||||||
|
rootPath: path.join(worktreeRoot, "packages", "app"),
|
||||||
|
remoteUrl,
|
||||||
|
worktreeRoot,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deriveSubproject(path.resolve("host-a", "repo"))).toBe(
|
||||||
|
deriveSubproject(path.resolve("host-b", "different", "repo")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveProjectGroupingDisplayName", () => {
|
||||||
|
test("uses the owner and repository from a remote", () => {
|
||||||
|
expect(
|
||||||
|
deriveProjectGroupingDisplayName({
|
||||||
|
rootPath: path.resolve("repo"),
|
||||||
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||||
|
worktreeRoot: path.resolve("repo"),
|
||||||
|
}),
|
||||||
|
).toBe("getpaseo/paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the selected directory name without a remote", () => {
|
||||||
|
expect(
|
||||||
|
deriveProjectGroupingDisplayName({
|
||||||
|
rootPath: path.resolve("acme", "app"),
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
}),
|
||||||
|
).toBe("app");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the selected subproject directory name instead of the repository remote", () => {
|
||||||
|
const worktreeRoot = path.resolve("acme", "repo");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveProjectGroupingDisplayName({
|
||||||
|
rootPath: path.join(worktreeRoot, "packages", "server"),
|
||||||
|
remoteUrl: "git@github.com:acme/repo.git",
|
||||||
|
worktreeRoot,
|
||||||
|
}),
|
||||||
|
).toBe("server");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { resolve, win32 } from "node:path";
|
||||||
|
import { isGitHubHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||||
|
import { getRealpathAwareRelativePath, normalizePathForIdentity } from "../utils/path.js";
|
||||||
|
|
||||||
|
/** Persisted opaque key used to join the same remote across hosts. */
|
||||||
|
export function deriveProjectKey(input: {
|
||||||
|
rootPath: string;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
worktreeRoot: string | null;
|
||||||
|
mainRepoRoot: string | null;
|
||||||
|
serverId?: string;
|
||||||
|
}): string {
|
||||||
|
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
|
||||||
|
const selectedPath = input.worktreeRoot
|
||||||
|
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) || null
|
||||||
|
: null;
|
||||||
|
if (remote) {
|
||||||
|
const host = remote.port ? `${remote.host}:${remote.port}` : remote.host;
|
||||||
|
const path = isGitHubHost(remote.host) ? remote.path.toLowerCase() : remote.path;
|
||||||
|
const remoteKey = `remote:${host}/${path}`;
|
||||||
|
return selectedPath ? `${remoteKey}#subdir:${selectedPath.replaceAll("\\", "/")}` : remoteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPathParts = [
|
||||||
|
selectedPath && input.mainRepoRoot ? input.mainRepoRoot : input.rootPath,
|
||||||
|
selectedPath && input.mainRepoRoot ? selectedPath : "",
|
||||||
|
];
|
||||||
|
const resolvedLocalPath = localPathParts.some(looksLikeWindowsPath)
|
||||||
|
? win32.resolve(...localPathParts)
|
||||||
|
: resolve(...localPathParts);
|
||||||
|
const localPath = normalizePathForIdentity(resolvedLocalPath);
|
||||||
|
return input.serverId ? `host:${input.serverId}:${localPath}` : localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProjectGroupingDisplayName(input: {
|
||||||
|
rootPath: string;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
worktreeRoot: string | null;
|
||||||
|
}): string {
|
||||||
|
const selectedPath = input.worktreeRoot
|
||||||
|
? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath)
|
||||||
|
: null;
|
||||||
|
if (selectedPath) return lastPathSegment(input.rootPath);
|
||||||
|
|
||||||
|
const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null;
|
||||||
|
if (!remote) return lastPathSegment(input.rootPath);
|
||||||
|
return remote.path.split("/").filter(Boolean).slice(-2).join("/") || input.rootPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastPathSegment(inputPath: string): string {
|
||||||
|
const segments = inputPath.split(/[\\/]/u).filter(Boolean);
|
||||||
|
return segments[segments.length - 1] ?? inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeWindowsPath(value: string): boolean {
|
||||||
|
return /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/u.test(value);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
|||||||
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
import type { AgentManagerEvent } from "./agent/agent-manager.js";
|
||||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||||
|
import { deriveProjectKey } from "./project-key.js";
|
||||||
import type { SessionOptions } from "./session.js";
|
import type { SessionOptions } from "./session.js";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -649,6 +650,12 @@ describe("project command-center RPCs", () => {
|
|||||||
rootPath: directoryPath,
|
rootPath: directoryPath,
|
||||||
kind: "non_git",
|
kind: "non_git",
|
||||||
displayName: "new-project",
|
displayName: "new-project",
|
||||||
|
projectKey: deriveProjectKey({
|
||||||
|
rootPath: directoryPath,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(messages).toEqual([
|
expect(messages).toEqual([
|
||||||
@@ -3416,7 +3423,7 @@ describe("session workspace descriptors", () => {
|
|||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const workspace = {
|
const workspace = {
|
||||||
workspaceId: "ws-gh",
|
workspaceId: "ws-gh",
|
||||||
projectId: "remote:github.com/acme/app",
|
projectId: "prj_app",
|
||||||
cwd: "/repo/app",
|
cwd: "/repo/app",
|
||||||
kind: "local_checkout" as const,
|
kind: "local_checkout" as const,
|
||||||
displayName: "app",
|
displayName: "app",
|
||||||
@@ -3424,7 +3431,8 @@ describe("session workspace descriptors", () => {
|
|||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
};
|
};
|
||||||
const project = {
|
const project = {
|
||||||
projectId: "remote:github.com/acme/app",
|
projectId: "prj_app",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
rootPath: "/repo/app",
|
rootPath: "/repo/app",
|
||||||
kind: "git" as const,
|
kind: "git" as const,
|
||||||
displayName: "acme/app",
|
displayName: "acme/app",
|
||||||
@@ -3435,7 +3443,10 @@ describe("session workspace descriptors", () => {
|
|||||||
const session = createSessionForTest({
|
const session = createSessionForTest({
|
||||||
messages,
|
messages,
|
||||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
projectRegistry: {
|
||||||
|
list: vi.fn().mockResolvedValue([project]),
|
||||||
|
get: vi.fn().mockResolvedValue(project),
|
||||||
|
},
|
||||||
workspaceGitService: {
|
workspaceGitService: {
|
||||||
getSnapshot: vi.fn(),
|
getSnapshot: vi.fn(),
|
||||||
peekSnapshot: vi.fn(() =>
|
peekSnapshot: vi.fn(() =>
|
||||||
@@ -3464,8 +3475,9 @@ describe("session workspace descriptors", () => {
|
|||||||
entries: [
|
entries: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "ws-gh",
|
id: "ws-gh",
|
||||||
|
projectId: "prj_app",
|
||||||
project: expect.objectContaining({
|
project: expect.objectContaining({
|
||||||
projectKey: "remote:github.com/acme/app",
|
projectKey: "prj_app",
|
||||||
projectName: "acme/app",
|
projectName: "acme/app",
|
||||||
workspaceName: "app",
|
workspaceName: "app",
|
||||||
checkout: expect.objectContaining({
|
checkout: expect.objectContaining({
|
||||||
@@ -4900,6 +4912,53 @@ test("sends project updates only to capable sockets in a retained session", () =
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("project.list returns every active project descriptor", async () => {
|
||||||
|
const messages: SessionOutboundMessage[] = [];
|
||||||
|
const active = createPersistedProjectRecord({
|
||||||
|
projectId: "project-active",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
|
rootPath: "/tmp/project-active",
|
||||||
|
kind: "git",
|
||||||
|
displayName: "acme/app",
|
||||||
|
createdAt: "2026-07-17T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const archived = createPersistedProjectRecord({
|
||||||
|
projectId: "project-archived",
|
||||||
|
rootPath: "/tmp/project-archived",
|
||||||
|
kind: "non_git",
|
||||||
|
displayName: "archived",
|
||||||
|
createdAt: "2026-07-17T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-17T00:00:00.000Z",
|
||||||
|
archivedAt: "2026-07-18T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const session = createSessionForTest({
|
||||||
|
messages,
|
||||||
|
projectRegistry: { list: vi.fn().mockResolvedValue([active, archived]) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.handleMessage({ type: "project.list.request", requestId: "projects-1" });
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{
|
||||||
|
type: "project.list.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "projects-1",
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
projectId: "project-active",
|
||||||
|
projectKey: "remote:github.com/acme/app",
|
||||||
|
projectDisplayName: "acme/app",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/tmp/project-active",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
describe("agent config setters", () => {
|
describe("agent config setters", () => {
|
||||||
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
|
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
|
||||||
[K in keyof SessionOptions["agentManager"]]?: unknown;
|
[K in keyof SessionOptions["agentManager"]]?: unknown;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user