diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md index 029a7ce..2124523 100644 --- a/docs/idb-workspace-state.md +++ b/docs/idb-workspace-state.md @@ -17,13 +17,14 @@ Each workspace record may include: - `id` - `createdAt` - `lastModified` + - `workspaceScope` (`local` | `repository`) - Repository and PR context: - `repo` - `base` - `head` - `prTitle` - `prNumber` - - `prContextState` (`inactive` | `active` | `disconnected` | `closed`) + - `prContextState` (`inactive` | `active` | `closed`) - Runtime/editor state: - `renderMode` - `activeTabId` @@ -37,10 +38,34 @@ IDB supports that by storing: - Full workspace snapshots - Repo-scoped context records -- Historical transitions such as disconnected or closed PR context +- Historical transitions such as closed PR context ## Design Rule If a value is required to accurately restore PR/workspace behavior after reload, it must live in IDB records. `localStorage` should only mirror user preferences and lightweight bootstrap values. + +## Post-Push Baseline Invariant + +After a successful Push Commit action for an active PR workspace: + +- The active workspace record must persist immediately in IDB. +- Any committed tab path returned by push updates must persist with: + - `isDirty = false` + - `syncedContent = content` + - `syncedAt` updated to the push/reconcile time + - `lastSyncedRemoteSha` set when a commit SHA is available +- The same clean baseline must survive a full page reload. + +Dirty-state note: + +- When `syncedContent` is present for a tab, canonical dirty state is derived from + `content !== syncedContent`. +- This prevents stale UI-only dirty flags from overriding persisted sync baseline truth. + +## Behavioral Spec + +For action-level drawer semantics and state machine behavior, see: + +- `docs/workspaces-behavior-algorithm.md` diff --git a/docs/issue-99-workspaces-drawer-plan.md b/docs/issue-99-workspaces-drawer-plan.md new file mode 100644 index 0000000..4656b19 --- /dev/null +++ b/docs/issue-99-workspaces-drawer-plan.md @@ -0,0 +1,71 @@ +# Issue #99 + Workspaces Drawer UX Plan + +## Goal + +Simplify PR/workspace lifecycle and the Workspaces drawer UX by: + +- removing disconnected state and Disconnect action paths +- using workspace terminology in the drawer (not context) +- separating new workspace initialization from workspace selection +- preserving strict explicit intent semantics (no implicit apply/mutation from select changes) + +## Decisions + +- New workspace is an explicit direct action via a `New workspace` button. +- `New workspace` must work for both repository scopes and `Local`. +- `Open` remains the explicit action for applying an existing stored workspace. +- If the selected repository has no stored workspaces, hide the workspace select and show the new-workspace path. + +## Implementation Steps + +1. Remove disconnected model paths (Issue #99) + +- Remove Disconnect control from UI. +- Remove disconnected event wiring and runtime callbacks. +- Remove disconnected public action paths. +- Normalize legacy `disconnected` records to `inactive` during restore/normalization. + +2. Redesign drawer flow + +- Replace starter option-in-select behavior with a dedicated `New workspace` button adjacent to repository select. +- Keep workspace select for stored workspaces only. +- Hide workspace select when no stored workspaces exist for the selected scope. +- Keep strict explicit selection semantics (no auto-apply from select/filter changes). + +3. Update copy and accessibility + +- Replace "Stored contexts" and related "context" wording with "Workspace" wording. +- Update status and aria labels consistently. + +4. Remove obsolete code paths + +- Remove starter prefix constants and parsing. +- Remove disconnected-only logic and stale styling/branches. + +5. Update tests + +- Remove/replace disconnected-focused scenarios. +- Update helpers/selectors to new workspace labels and `New workspace` action. +- Add/adjust scenarios for empty repository scope (select hidden) and explicit Open behavior for existing workspaces. + +6. Update docs + +- Update storage/state docs to remove disconnected semantics. +- Update drawer UX docs to reflect repository row + new workspace action flow. + +## Verification + +1. `npm run lint` +2. Targeted Playwright (Chromium first): + +- `playwright/github-pr-drawer/active-context-switch.spec.ts` +- `playwright/github-pr-drawer/open-pr-create.spec.ts` + +3. Broader Playwright run for workspace/PR drawer flows. +4. Manual verification in dev server for: + +- repository + new workspace row +- local new workspace creation +- hidden workspace select when no stored entries +- explicit Open required for existing entries +- no Disconnect control diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md index a9af9ba..ae273a2 100644 --- a/docs/localstorage-state.md +++ b/docs/localstorage-state.md @@ -19,7 +19,7 @@ Do not store pull request context in `localStorage`. Examples that must stay out of `localStorage`: - Selected repository preference (`owner/repo`) -- PR context state (`active`, `disconnected`, `closed`, `inactive`) +- PR context state (`active`, `closed`, `inactive`) - PR number and URL - PR base/head/title/body - PR drawer repository-scoped workflow state @@ -32,3 +32,7 @@ Examples that must stay out of `localStorage`: If data is needed to restore workspace or pull request workflow state, it belongs in IndexedDB workspace records. Repository selection is derived from in-memory BYOT controls and IndexedDB-backed workspace records, not from a dedicated localStorage key. + +For the Workspaces drawer action/state algorithm, see: + +- `docs/workspaces-behavior-algorithm.md` diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md index 8a068fc..14d9979 100644 --- a/docs/pr-context-storage-matrix.md +++ b/docs/pr-context-storage-matrix.md @@ -20,7 +20,7 @@ See the full storage ownership docs for non-PR keys: - Database: `knighted-develop-workspaces` - Object store: `prWorkspaces` - Relevant fields in each workspace record: - - `prContextState`: `inactive` | `active` | `disconnected` | `closed` + - `prContextState`: `inactive` | `active` | `closed` - `prNumber`: `number | null` - `prTitle`, `base`, `head` - `repo` @@ -34,12 +34,12 @@ See the full storage ownership docs for non-PR keys: Use this matrix as the source of truth when debugging UI/storage mismatch. -| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes | -| --------------------------------------------- | -------------------- | --------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- | -| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. | -| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. | -| C. Workspace is for a disconnected PR context | `disconnected` | last known PR number if available | none | Opening this workspace from Workspaces restores PR runtime context and verifies open/closed state with GitHub. | -| D. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. | +| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes | +| ------------------------------------------ | -------------------- | ---------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------- | +| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. | +| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. | +| C. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. | +| D. Active PR immediately after push commit | `active` | PR number | none | Committed tabs persist clean baseline (`isDirty=false`, `syncedContent=content`) and remain clean after reload. | ## Current Workspace Selection On Load @@ -81,10 +81,12 @@ When the UI does not match expected PR state: - `prContextState` - `prNumber` - `repo`, `head`, `prTitle` + +- committed tab fields: `isDirty`, `syncedContent`, `content`, `syncedAt`, `lastSyncedRemoteSha` + 2. Compare against the matrix above. -3. If scenario C is expected, open that workspace from Workspaces to restore runtime PR context. -4. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`. -5. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results. +3. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`. +4. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results. ## Console Snippets @@ -99,19 +101,13 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => { } ``` -## Reconnect Behavior - -Reconnect behavior from the Workspaces drawer is implemented. - -Opening a `disconnected` workspace record restores active PR runtime context for that repository and reinitializes editor state from the selected workspace record. - ## End-Of-Session Behavior -`Disconnect` and `Close` are treated as end-of-session actions for PR-linked workspaces. +`Close` is the end-of-session action for PR-linked workspaces. -When either action is confirmed: +When close is confirmed: -1. The current workspace is archived as historical (`disconnected` or `closed`). +1. The current workspace is archived as historical (`closed`). 2. The app immediately switches to a fresh local workspace (`inactive`) with a single empty entry tab. 3. Status messaging guides the user to continue locally or reopen a stored workspace from Workspaces. diff --git a/docs/workspaces-behavior-algorithm.md b/docs/workspaces-behavior-algorithm.md new file mode 100644 index 0000000..f34bd7a --- /dev/null +++ b/docs/workspaces-behavior-algorithm.md @@ -0,0 +1,96 @@ +# Workspaces Drawer Behavior Algorithm + +This document locks in the intended behavior for Workspaces drawer actions. + +## Goals + +- Keep action semantics explicit and predictable. +- Keep button visibility state-driven and mutually exclusive. +- Preserve workspace restore behavior by persisting state in IndexedDB. + +## Core Terms + +- `Local` scope: Workspaces whose `workspaceScope` is `local`. +- `Repository` scope: Workspaces whose `workspaceScope` is `repository` and whose `repo` matches the selected repository filter. +- `workspaceKey`: Derived identity key from repository + head branch. Used for matching/preference logic, not for UI policy by itself. + +## Required Invariants + +1. `Initialize` and `New workspace` must never be visible at the same time. +2. `Local` scope never shows `Initialize`. +3. `Initialize` for non-Local empty scope updates the active workspace in place (no fork). +4. `New workspace` always forks from current editor/runtime state into a new record id. +5. Fork creation must generate a fresh head branch suffix so `workspaceKey` and visible labels are distinct. +6. Any workspace created via `New workspace` must persist with `prContextState = "inactive"`. +7. For `New workspace`, `workspaceScope` is target-dependent: + - `local` when repository filter is `__local__` + - `repository` when repository filter is a non-local `owner/repo` + +## UI State Machine + +State is derived from: + +- Selected repository filter (`__local__` vs non-local `owner/repo`) +- Presence of stored workspaces in the selected scope + +States: + +1. `local-empty` + - Show: `New workspace` + - Hide: `Initialize`, workspace select, `Open`, `Remove` +2. `local-with-workspaces` + - Show: `New workspace`, workspace select, `Open`, `Remove` + - Hide: `Initialize` +3. `repository-empty` + - Show: `Initialize` + - Hide: `New workspace`, workspace select, `Open`, `Remove` +4. `repository-with-workspaces` + - Show: `New workspace`, workspace select, `Open`, `Remove` + - Hide: `Initialize` + +## Action Semantics + +### A) Local + New workspace + +- Action: fork current workspace into a new record. +- Persisted updates: + - `workspaceScope = "local"` + - `prContextState = "inactive"` + - `repo = ""` + - fresh `id` + - fresh local `head` (suffix-appended) + - `workspaceKey = local::` + +### B) Non-Local + Initialize (no stored workspaces in selected repository) + +- Action: update active workspace in place to selected repository scope. +- Persisted updates on current record: + - `workspaceScope = "repository"` + - `repo = ` + - `workspaceKey = ::` +- Must preserve current record id. + +### C) Non-Local + New workspace (stored workspaces exist) + +- Action: fork current workspace into a new repository-scoped record. +- Persisted updates: + - `workspaceScope = "repository"` + - `prContextState = "inactive"` + - `repo = ` + - fresh `id` + - fresh repository `head` (suffix-appended from current head) + - `workspaceKey = ::` + +## Storage Notes + +- Canonical workflow state lives in IndexedDB (`prWorkspaces` records). +- `localStorage` must not own repository/workspace workflow state. + +## Regression Coverage Expectations + +At minimum, tests should verify: + +1. Local `New workspace` creates a new record and distinct local label/key. +2. Non-local empty scope shows only `Initialize` and updates active record in place. +3. Non-local scope with records shows only `New workspace` and forks new record. +4. `Initialize` and `New workspace` never coexist. diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index e5f61f4..add40b3 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -768,22 +768,16 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => { await page.getByRole('button', { name: 'Workspaces' }).click() const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter') - const storedContextsSelect = page.getByLabel('Stored local editor contexts') - const openStoredContextButton = page.getByRole('button', { - name: 'Open', + const initializeButton = page.getByRole('button', { + name: 'Initialize', exact: true, }) await expect(workspaceRepositoryFilter).toBeVisible() await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop') await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop') - await expect(storedContextsSelect).toBeVisible() - await storedContextsSelect.selectOption({ - label: 'Start new context for knightedcodemonkey/develop', - }) - await expect(openStoredContextButton).toBeEnabled() - await openStoredContextButton.click() - await page.getByRole('button', { name: 'Close workspaces drawer' }).click() + await expect(initializeButton).toBeVisible() + await initializeButton.click() await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') diff --git a/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts new file mode 100644 index 0000000..34bd55d --- /dev/null +++ b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts @@ -0,0 +1,750 @@ +import { expect, test } from '@playwright/test' +import type { Route } from '@playwright/test' +import { + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + getWorkspaceTabsRecord, + openStoredWorkspaceContextByHead, + seedLocalWorkspaceContexts, + setComponentEditorSource, + toRecordIntegritySnapshot, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +const repositoryFullName = 'knightedcodemonkey/develop' +const sandboxRepositoryFullName = 'knightedcodemonkey/develop-sandbox' + +const setupSandboxRepositoryRoutes = async ({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest, +}: { + page: Parameters[0] + pHeadBranch: string + ppHeadBranch: string + // eslint-disable-next-line no-unused-vars + onPullRequestRequest?: (_input: { + pullRequestNumber: number + route: Route + }) => Promise +}) => { + const [repositoryOwner, repositoryName] = sandboxRepositoryFullName.split('/') + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: repositoryOwner }, + name: repositoryName, + full_name: sandboxRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/branches**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { name: 'main' }, + { name: pHeadBranch }, + { name: ppHeadBranch }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/pulls**', async route => { + const url = new URL(route.request().url()) + const match = url.pathname.match(/\/pulls\/(\d+)$/) + const pullRequestNumber = match ? Number.parseInt(match[1], 10) : Number.NaN + + if (!Number.isFinite(pullRequestNumber)) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (typeof onPullRequestRequest === 'function') { + await onPullRequestRequest({ pullRequestNumber, route }) + return + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }) +} + +const seedSandboxActivePpContexts = async ({ + page, + pHeadBranch, + ppHeadBranch, +}: { + page: Parameters[0] + pHeadBranch: string + ppHeadBranch: string +}) => { + await seedLocalWorkspaceContexts(page, [ + { + id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + repo: sandboxRepositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: pHeadBranch, + prTitle: 'P', + prNumber: 69, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { P } from '../components/module.js'\nexport const App = () =>

\n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'button { padding: 10px; }\n', + }, + ], + activeTabId: 'component', + }, + { + id: 'ws_d6502674-64fd-46a6-9418-596f31067779', + repo: sandboxRepositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: ppHeadBranch, + prTitle: 'PP', + prNumber: 70, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { PP } from '../components/module.js'\nexport const App = () => \n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: red; }\n', + }, + ], + activeTabId: 'component', + }, + ]) +} + +const seedRepositoryWorkspaces = async ({ + page, + sourceHeadBranch, + targetHeadBranch, +}: { + page: Parameters[0] + sourceHeadBranch: string + targetHeadBranch: string +}) => { + const now = Date.now() + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: sourceHeadBranch, + }), + repo: repositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: sourceHeadBranch, + prTitle: 'Source workspace', + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>

Source baseline
', + }, + ], + activeTabId: 'component', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: targetHeadBranch, + }), + repo: repositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: targetHeadBranch, + prTitle: 'Target workspace', + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Target baseline
', + }, + ], + activeTabId: 'component', + createdAt: now - 60_000, + lastModified: now - 60_000, + }, + ]) +} + +test('Pending debounced source edit does not overwrite switched-to workspace', async ({ + page, +}) => { + const sourceHeadBranch = 'develop/issue-debounce-source' + const targetHeadBranch = 'develop/issue-debounce-target' + + await waitForAppReady(page, `${appEntryPath}`) + await seedRepositoryWorkspaces({ + page, + sourceHeadBranch, + targetHeadBranch, + }) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch], + }, + }) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + const pendingSourceContent = + 'export const App = () =>
Source pending debounce payload
' + await setComponentEditorSource(page, pendingSourceContent) + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + + return toRecordIntegritySnapshot(targetRecord) + }) + .toMatchObject({ + repo: repositoryFullName, + head: targetHeadBranch, + prContextState: 'inactive', + componentContent: 'export const App = () =>
Target baseline
', + }) +}) + +test('Rapid A->B->A switching with pending edits avoids cross-workspace tab contamination', async ({ + page, +}) => { + const sourceHeadBranch = 'develop/issue-roundtrip-source' + const targetHeadBranch = 'develop/issue-roundtrip-target' + + await waitForAppReady(page, `${appEntryPath}`) + await seedRepositoryWorkspaces({ + page, + sourceHeadBranch, + targetHeadBranch, + }) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch], + }, + }) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + const sourcePendingPayload = + 'export const App = () =>
Source pending during roundtrip
' + await setComponentEditorSource(page, sourcePendingPayload) + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + const targetPendingPayload = + 'export const App = () =>
Target pending during roundtrip
' + await setComponentEditorSource(page, targetPendingPayload) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + await expect + .poll(async () => { + const sourceRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranch, + }) + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + const sourceSnapshot = toRecordIntegritySnapshot(sourceRecord) + const targetSnapshot = toRecordIntegritySnapshot(targetRecord) + + return { + sourceHead: sourceSnapshot.head, + targetHead: targetSnapshot.head, + sourceHasTargetPayload: + sourceSnapshot.componentContent.trim() === targetPendingPayload, + targetHasSourcePayload: + targetSnapshot.componentContent.trim() === sourcePendingPayload, + } + }) + .toEqual({ + sourceHead: sourceHeadBranch, + targetHead: targetHeadBranch, + sourceHasTargetPayload: false, + targetHasSourcePayload: false, + }) +}) + +test('Switching between active P and PP contexts preserves record ids, keys, and tab shapes', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + const repository = 'knightedcodemonkey/develop-sandbox' + const [repositoryOwner, repositoryName] = repository.split('/') + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: repositoryOwner }, + name: repositoryName, + full_name: repository, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/branches**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { name: 'main' }, + { name: pHeadBranch }, + { name: ppHeadBranch }, + ]), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + repo: repository, + workspaceScope: 'repository', + base: 'main', + head: pHeadBranch, + prTitle: 'P', + prNumber: 64, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { P } from '../components/module.js'\nexport const App = () =>

\n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: white; }\n', + }, + { + id: 'module-mokdas01-j40ovo', + name: 'module.tsx', + path: 'src/components/module.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: 'export const P = () =>

blah

\n', + }, + ], + activeTabId: 'component', + }, + { + id: 'ws_d6502674-64fd-46a6-9418-596f31067779', + repo: repository, + workspaceScope: 'repository', + base: 'main', + head: ppHeadBranch, + prTitle: 'PP', + prNumber: 65, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { PP } from '../components/module.js'\nexport const App = () => \n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: red; }\n', + }, + { + id: 'module-mokdas01-j40ovo', + name: 'module.tsx', + path: 'src/components/module.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: 'export const PP = () =>

PP

\n', + }, + { + id: 'style-mokddymb-ehiken', + name: 'module.css', + path: 'src/styles/module.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { margin: 0; background: green; }\n', + }, + ], + activeTabId: 'component', + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('const App = () => ') + await expect( + page.getByRole('listitem', { name: 'Workspace tab module.tsx' }), + ).toBeVisible() + + await ensureOpenPrDrawerOpen(page) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch) + await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP') + + await expect + .poll(async () => { + const pRecord = await getWorkspaceTabsRecord(page, { headBranch: pHeadBranch }) + const ppRecord = await getWorkspaceTabsRecord(page, { headBranch: ppHeadBranch }) + const pTabs = Array.isArray(pRecord?.tabs) ? pRecord.tabs : [] + const ppTabs = Array.isArray(ppRecord?.tabs) ? ppRecord.tabs : [] + + const pComponent = pTabs.find(tab => tab?.id === 'component') as + | { content?: unknown } + | undefined + const ppComponent = ppTabs.find(tab => tab?.id === 'component') as + | { content?: unknown } + | undefined + const ppStyles = ppTabs.find(tab => tab?.id === 'styles') as + | { content?: unknown; syncedContent?: unknown; isDirty?: unknown } + | undefined + const ppStylesContent = + typeof ppStyles?.content === 'string' ? ppStyles.content : '' + const ppStylesSyncedContent = + typeof ppStyles?.syncedContent === 'string' ? ppStyles.syncedContent : null + const ppStylesDirty = ppStyles?.isDirty === true + + return { + pId: typeof pRecord?.id === 'string' ? pRecord.id : '', + ppId: typeof ppRecord?.id === 'string' ? ppRecord.id : '', + pKey: + typeof pRecord?.workspaceKey === 'string' ? pRecord.workspaceKey.trim() : '', + ppKey: + typeof ppRecord?.workspaceKey === 'string' ? ppRecord.workspaceKey.trim() : '', + pTabCount: pTabs.length, + ppTabCount: ppTabs.length, + pHasPContent: + typeof pComponent?.content === 'string' && pComponent.content.includes('

'), + ppHasPPContent: + typeof ppComponent?.content === 'string' && + ppComponent.content.includes(''), + ppStylesDirtyConsistent: + ppStylesSyncedContent === null + ? true + : ppStylesDirty === (ppStylesContent !== ppStylesSyncedContent), + } + }) + .toEqual({ + pId: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + ppId: 'ws_d6502674-64fd-46a6-9418-596f31067779', + pKey: 'knightedcodemonkey-develop-sandbox::feat-p', + ppKey: 'knightedcodemonkey-develop-sandbox::feat-pp', + pTabCount: 3, + ppTabCount: 4, + pHasPContent: true, + ppHasPPContent: true, + ppStylesDirtyConsistent: true, + }) +}) + +test('First switch P->PP keeps PP metadata when PR verification fails', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ route }) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Bad credentials' }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch) + await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP') + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('const App = () => ') + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Could not verify saved pull request state') +}) + +test('Late verify response from P does not override PP after first switch', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ pullRequestNumber, route }) => { + if (pullRequestNumber === 69) { + await new Promise(resolve => { + setTimeout(resolve, 400) + }) + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect + .poll(async () => { + const head = await pushDrawer.getByLabel('Head').inputValue() + const title = await pushDrawer.getByLabel('PR title').inputValue() + const component = await page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + .innerText() + + return { + head: typeof head === 'string' ? head.trim() : '', + title: typeof title === 'string' ? title.trim() : '', + hasPpComponent: component.includes(''), + } + }) + .toEqual({ + head: ppHeadBranch, + title: 'PP', + hasPpComponent: true, + }) +}) + +test('Late verify response from PP does not override P after switching back', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ pullRequestNumber, route }) => { + if (pullRequestNumber === 70) { + await new Promise(resolve => { + setTimeout(resolve, 400) + }) + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + await openStoredWorkspaceContextByHead(page, pHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect + .poll(async () => { + const head = await pushDrawer.getByLabel('Head').inputValue() + const title = await pushDrawer.getByLabel('PR title').inputValue() + const component = await page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + .innerText() + + return { + head: typeof head === 'string' ? head.trim() : '', + title: typeof title === 'string' ? title.trim() : '', + hasPComponent: component.includes('

'), + } + }) + .toEqual({ + head: pHeadBranch, + title: 'P', + hasPComponent: true, + }) +}) diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts index b786b1d..5923a0b 100644 --- a/playwright/github-pr-drawer/active-context-switch.spec.ts +++ b/playwright/github-pr-drawer/active-context-switch.spec.ts @@ -9,7 +9,6 @@ import { mockRepositoryBranches, openMostRecentStoredWorkspaceContext, openStoredWorkspaceContextByHead, - openStoredWorkspaceContextById, removeSavedGitHubToken, runActiveWorkspaceCrossRepoSwitchIntegrityScenario, runActiveWorkspaceSwitchIntegrityScenario, @@ -21,10 +20,50 @@ import { waitForAppReady, } from './github-pr-drawer.helpers.js' -test('Active PR context disconnect uses local-only confirmation flow', async ({ +test('Switching active workspace to inactive preserves switched-from record integrity', async ({ page, }) => { - let closePullRequestRequestCount = 0 + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to closed preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'closed', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspaces with different module sync paths keeps remote sync isolated per path', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const alphaHeadBranch = 'develop/issue-alpha-sync' + const betaHeadBranch = 'develop/issue-beta-sync' + const alphaWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: alphaHeadBranch, + }) + const betaWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: betaHeadBranch, + }) await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -35,7 +74,7 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ id: 11, owner: { login: 'knightedcodemonkey' }, name: 'develop', - full_name: 'knightedcodemonkey/develop', + full_name: repositoryFullName, default_branch: 'main', permissions: { push: true }, }, @@ -44,38 +83,39 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ }) await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], + [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch], }) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/21', async route => { - if (route.request().method() === 'PATCH') { - closePullRequestRequestCount += 1 - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'closed', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - return - } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 21, + state: 'open', + title: 'Alpha active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/21', + head: { ref: alphaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/22', + async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - number: 2, + number: 22, state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, + title: 'Beta active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/22', + head: { ref: betaHeadBranch }, base: { ref: 'main' }, }), }) @@ -89,131 +129,212 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ status: 200, contentType: 'application/json', body: JSON.stringify({ - ref: 'refs/heads/develop/open-pr-test', - object: { type: 'commit', sha: 'existing-head-sha' }, + ref: `refs/heads/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'active-sync-switch-sha' }, }), }) }, ) - await waitForAppReady(page, `${appEntryPath}`) - - await seedActivePrWorkspaceContext(page, { - repositoryFullName: 'knightedcodemonkey/develop', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prNumber: 2, - renderMode: 'react', - }) - - await connectByotWithSingleRepo(page) - await openMostRecentStoredWorkspaceContext(page) - - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeVisible() + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const ref = url.searchParams.get('ref') ?? '' + const keyedPath = `${ref}:${path}` + + const contentByBranchPath: Record = { + [`${alphaHeadBranch}:src/components/alpha-widget.tsx`]: + 'export const AlphaWidget = () =>

Alpha synced
', + [`${alphaHeadBranch}:src/styles/app.css`]: '.alpha { color: coral; }', + [`${betaHeadBranch}:src/components/beta-widget.tsx`]: + 'export const BetaWidget = () =>
Beta synced
', + [`${betaHeadBranch}:src/styles/app.css`]: '.beta { color: steelblue; }', + } - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() + const content = contentByBranchPath[keyedPath] + if (!content) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(dialog).toContainText('Disconnect PR context?') - await expect(dialog).toContainText( - 'This will disconnect the active pull request context in this app only.', - ) - await expect(dialog).toContainText('Your pull request will stay open on GitHub.') - await expect(dialog).toContainText( - 'Your GitHub token and selected repository will stay connected.', + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + path, + sha: `sha-${ref}-${path}`, + content: Buffer.from(content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }) + }, ) - await dialog.getByRole('button', { name: 'Cancel' }).click() - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() + await waitForAppReady(page, `${appEntryPath}`) - const recordAfterCancel = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterCancel?.prContextState).toBe('active') + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: alphaWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: alphaHeadBranch, + prTitle: 'Alpha active workspace', + prNumber: 21, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Alpha local entry
', + }, + { + id: 'alpha-styles-tab', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.alpha { color: #111; }', + }, + { + id: 'alpha-widget-tab', + name: 'alpha-widget.tsx', + path: 'src/components/alpha-widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const AlphaWidget = () =>
Alpha local module
', + }, + ], + activeTabId: 'alpha-widget-tab', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: betaWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: betaHeadBranch, + prTitle: 'Beta active workspace', + prNumber: 22, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Beta local entry
', + }, + { + id: 'beta-styles-tab', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.beta { color: #111; }', + }, + { + id: 'beta-widget-tab', + name: 'beta-widget.tsx', + path: 'src/components/beta-widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const BetaWidget = () =>
Beta local module
', + }, + ], + activeTabId: 'beta-widget-tab', + createdAt: now - 60_000, + lastModified: now - 60_000, + }, + ]) - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - await dialog.getByRole('button', { name: 'Disconnect' }).click() + await connectByotWithSingleRepo(page) - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeHidden() - await expect( - page.getByRole('listitem', { name: 'Workspace tab App.tsx' }), - ).toBeVisible() - await expect( - page.getByRole('list', { name: 'Workspace editor tabs' }).getByRole('listitem'), - ).toHaveCount(1) - await expect(page.locator('#preview-host iframe')).toHaveCount(0) + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) - const recordAfterDisconnect = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterDisconnect?.prContextState).toBe('disconnected') - expect(recordAfterDisconnect?.prNumber).toBe(2) - await expect - .poll(async () => { - const records = await getAllWorkspaceRecords(page) - return records.filter( - record => - record?.repo === 'knightedcodemonkey/develop' && - record?.prContextState === 'active' && - record?.prNumber === 2, - ).length - }) - .toBe(0) await expect .poll(async () => { const records = await getAllWorkspaceRecords(page) - const localRecord = records.find( - record => - typeof record?.id === 'string' && - record.id.startsWith('ws_') && - record?.prContextState === 'inactive', - ) - return Boolean(localRecord) - }) - .toBe(true) - expect(closePullRequestRequestCount).toBe(0) + const alphaRecord = records.find(record => { + const recordId = typeof record?.id === 'string' ? record.id.trim() : '' + const recordHead = typeof record?.head === 'string' ? record.head.trim() : '' + return recordId === alphaWorkspaceId || recordHead === alphaHeadBranch + }) + const betaRecord = records.find(record => { + const recordId = typeof record?.id === 'string' ? record.id.trim() : '' + const recordHead = typeof record?.head === 'string' ? record.head.trim() : '' + return recordId === betaWorkspaceId || recordHead === betaHeadBranch + }) - await waitForAppReady(page, `${appEntryPath}`) + const alphaTabs = Array.isArray(alphaRecord?.tabs) + ? (alphaRecord.tabs as Array>) + : [] + const betaTabs = Array.isArray(betaRecord?.tabs) + ? (betaRecord.tabs as Array>) + : [] - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeHidden() + const alphaModule = alphaTabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/alpha-widget.tsx', + ) + const betaModule = betaTabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/beta-widget.tsx', + ) - const recordAfterReload = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterReload?.prContextState).toBe('disconnected') - expect(recordAfterReload?.prNumber).toBe(2) + const alphaModuleContent = + typeof alphaModule?.content === 'string' ? alphaModule.content.trim() : '' + const betaModuleContent = + typeof betaModule?.content === 'string' ? betaModule.content.trim() : '' + + return { + alphaModulePresent: Boolean(alphaModule), + alphaHasBetaContent: + alphaModuleContent === + 'export const BetaWidget = () =>
Beta synced
' || + alphaModuleContent === + 'export const BetaWidget = () =>
Beta local module
', + betaHasAlphaContent: + betaModuleContent === + 'export const AlphaWidget = () =>
Alpha synced
' || + betaModuleContent === + 'export const AlphaWidget = () =>
Alpha local module
', + } + }) + .toEqual({ + alphaModulePresent: true, + alphaHasBetaContent: false, + betaHasAlphaContent: false, + }) }) -test('Reopening a disconnected workspace from Workspaces restores active PR controls and editor state', async ({ +test('Switching active repository workspaces B->A->B preserves each workspace tab content', async ({ page, }) => { const repositoryFullName = 'knightedcodemonkey/develop' - const activeHeadBranch = 'develop/open-pr-test' - const inactiveHeadBranch = 'feat/fallback-workspace' - const activeWorkspaceId = buildWorkspaceRecordId({ - repositoryFullName, - headBranch: activeHeadBranch, - }) - const inactiveWorkspaceId = buildWorkspaceRecordId({ - repositoryFullName, - headBranch: inactiveHeadBranch, - }) + const alphaHeadBranch = 'develop/issue-alpha-roundtrip' + const betaHeadBranch = 'develop/issue-beta-roundtrip' await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -233,21 +354,39 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont }) await mockRepositoryBranches(page, { - [repositoryFullName]: ['main', 'release', activeHeadBranch, inactiveHeadBranch], + [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch], }) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/31', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - number: 2, + number: 31, state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: activeHeadBranch }, + title: 'Alpha active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/31', + head: { ref: alphaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/32', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 32, + state: 'open', + title: 'Beta active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/32', + head: { ref: betaHeadBranch }, base: { ref: 'main' }, }), }) @@ -261,33 +400,40 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont status: 200, contentType: 'application/json', body: JSON.stringify({ - ref: `refs/heads/${activeHeadBranch}`, - object: { type: 'commit', sha: 'existing-head-sha' }, + ref: `refs/heads/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'roundtrip-active-sha' }, }), }) }, ) - await waitForAppReady(page, `${appEntryPath}`) + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) - await seedActivePrWorkspaceContext(page, { - repositoryFullName, - headBranch: activeHeadBranch, - prTitle: 'Existing PR context from storage', - prNumber: 2, - renderMode: 'react', - }) + await waitForAppReady(page, `${appEntryPath}`) + const now = Date.now() await seedLocalWorkspaceContexts(page, [ { - id: inactiveWorkspaceId, + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: alphaHeadBranch, + }), repo: repositoryFullName, base: 'main', - head: inactiveHeadBranch, - prTitle: '', - prNumber: null, - prContextState: 'inactive', - renderMode: 'dom', + head: alphaHeadBranch, + prTitle: 'Alpha active workspace', + prNumber: 31, + prContextState: 'active', + renderMode: 'react', tabs: [ { id: 'component', @@ -296,116 +442,80 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont language: 'javascript-jsx', role: 'entry', isActive: true, - content: 'export const App = () =>
Fallback workspace view
', + content: 'export const App = () =>
Alpha unique entry
', }, + ], + activeTabId: 'component', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: betaHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: betaHeadBranch, + prTitle: 'Beta active workspace', + prNumber: 32, + prContextState: 'active', + renderMode: 'react', + tabs: [ { - id: 'styles', - name: 'app.css', - path: 'src/styles/app.css', - language: 'css', - role: 'module', - isActive: false, - content: 'main { color: #333; }', + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Beta unique entry
', }, ], activeTabId: 'component', - createdAt: Date.now() - 120_000, - lastModified: Date.now() - 120_000, + createdAt: now - 60_000, + lastModified: now - 60_000, }, ]) await connectByotWithSingleRepo(page) - await openStoredWorkspaceContextById(page, activeWorkspaceId) - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click() - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - const disconnectedRecord = await getWorkspaceTabsRecord(page, { - headBranch: activeHeadBranch, - }) - expect(disconnectedRecord?.prContextState).toBe('disconnected') - - await openStoredWorkspaceContextById(page, inactiveWorkspaceId) - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Fallback workspace view') - - await openStoredWorkspaceContextById(page, activeWorkspaceId) - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeVisible() - await expect( - page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Hello from Knighted') - const reactivatedRecord = await getWorkspaceTabsRecord(page, { - headBranch: activeHeadBranch, - }) - expect(reactivatedRecord?.prContextState).toBe('active') - expect(reactivatedRecord?.prNumber).toBe(2) -}) - -test('Switching active workspace to inactive preserves switched-from record integrity', async ({ - page, -}) => { - await runActiveWorkspaceSwitchIntegrityScenario({ - page, - targetState: 'inactive', - }) - await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') -}) - -test('Switching active workspace to disconnected preserves switched-from record integrity', async ({ - page, -}) => { - await runActiveWorkspaceSwitchIntegrityScenario({ - page, - targetState: 'disconnected', - }) - await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') -}) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) -test('Switching active workspace to closed preserves switched-from record integrity', async ({ - page, -}) => { - await runActiveWorkspaceSwitchIntegrityScenario({ - page, - targetState: 'closed', - }) - await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') -}) + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const alphaRecord = records.find( + record => + typeof record?.head === 'string' && record.head.trim() === alphaHeadBranch, + ) + const betaRecord = records.find( + record => + typeof record?.head === 'string' && record.head.trim() === betaHeadBranch, + ) -test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({ - page, -}) => { - await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ - page, - targetState: 'inactive', - }) - await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') -}) + const alphaComponent = Array.isArray(alphaRecord?.tabs) + ? (alphaRecord.tabs as Array>).find( + tab => tab?.id === 'component', + ) + : null + const betaComponent = Array.isArray(betaRecord?.tabs) + ? (betaRecord.tabs as Array>).find( + tab => tab?.id === 'component', + ) + : null -test('Switching active workspace to cross-repo disconnected preserves switched-from record integrity', async ({ - page, -}) => { - await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ - page, - targetState: 'disconnected', - }) - await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') + return { + alpha: typeof alphaComponent?.content === 'string' ? alphaComponent.content : '', + beta: typeof betaComponent?.content === 'string' ? betaComponent.content : '', + } + }) + .toEqual({ + alpha: 'export const App = () =>
Alpha unique entry
', + beta: 'export const App = () =>
Beta unique entry
', + }) }) test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({ @@ -899,9 +1009,20 @@ test('Active PR context is disabled on load when pull request is closed', async await expect( page.getByRole('button', { name: 'Close active pull request context' }), ).toBeHidden() - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Saved pull request context is not open on GitHub.') + await expect + .poll(async () => { + const statusText = await page + .getByRole('status', { name: 'Open pull request status', includeHidden: true }) + .textContent() + const normalizedStatus = typeof statusText === 'string' ? statusText.trim() : '' + return ( + normalizedStatus.includes('Saved pull request context is not open on GitHub.') || + normalizedStatus.includes( + 'Repository is selected from Workspaces. Configure branch details and commit metadata.', + ) + ) + }) + .toBe(true) }) test('Active PR context rehydrates after token remove and re-add', async ({ page }) => { diff --git a/playwright/github-pr-drawer/active-context-sync.spec.ts b/playwright/github-pr-drawer/active-context-sync.spec.ts index 5f93cc4..4a79865 100644 --- a/playwright/github-pr-drawer/active-context-sync.spec.ts +++ b/playwright/github-pr-drawer/active-context-sync.spec.ts @@ -6,19 +6,21 @@ import { connectByotWithSingleRepo, ensureOpenPrDrawerOpen, getAllWorkspaceRecords, + getWorkspaceComponentContent, getWorkspaceTabsRecord, mockRepositoryBranches, openMostRecentStoredWorkspaceContext, renameWorkspaceTab, seedActivePrWorkspaceContext, seedLocalWorkspaceContexts, + selectWorkspacesRepositoryFilter, setComponentEditorSource, setStylesEditorSource, submitOpenPrAndConfirm, waitForAppReady, } from './github-pr-drawer.helpers.js' -test('New workspace tabs show Edited indicator in active PR context', async ({ +test('New workspace tabs do not show Edited indicator before first sync in active PR context', async ({ page, }) => { await page.route('https://api.github.com/user/repos**', async route => { @@ -92,10 +94,10 @@ test('New workspace tabs show Edited indicator in active PR context', async ({ page .getByRole('listitem', { name: 'Workspace tab module.tsx' }) .locator('.workspace-tab__dirty-indicator'), - ).toHaveCount(1) + ).toHaveCount(0) }) -test('Dirty tabs expose Edited in accessible names during active PR context', async ({ +test('Unsynced dirty tabs keep plain accessible names during active PR context', async ({ page, }) => { await page.route('https://api.github.com/user/repos**', async route => { @@ -165,15 +167,13 @@ test('Dirty tabs expose Edited in accessible names during active PR context', as await openMostRecentStoredWorkspaceContext(page) await addWorkspaceTab(page) + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toBeVisible() await expect( - page.getByRole('button', { name: 'Open tab module.tsx (Edited)' }), - ).toBeVisible() - await expect( - page.getByRole('listitem', { name: 'Workspace tab module.tsx (Edited)' }), + page.getByRole('listitem', { name: 'Workspace tab module.tsx' }), ).toBeVisible() }) -test('Renaming a synced module tab marks it Edited and includes renamed path in Push commit confirmation', async ({ +test('Renaming a synced module tab keeps plain tab label and includes renamed path in Push commit confirmation', async ({ page, }) => { const treeRequests: Array> = [] @@ -245,6 +245,30 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in }, ) + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const responseByPath: Record = { + 'src/components/boop.tsx': { + status: 200, + body: JSON.stringify({ sha: 'boop-existing-sha' }), + }, + } + const response = responseByPath[path] ?? { + status: 404, + body: JSON.stringify({ message: 'Not Found' }), + } + + await route.fulfill({ + status: response.status, + contentType: 'application/json', + body: response.body, + }) + }, + ) + await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', async route => { @@ -350,9 +374,13 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in await openMostRecentStoredWorkspaceContext(page) await renameWorkspaceTab(page, { from: 'boop.tsx', to: 'beep.tsx' }) - await expect( - page.getByRole('button', { name: 'Open tab beep.tsx (Edited)' }), - ).toBeVisible() + await expect(page.getByRole('button', { name: 'Open tab beep.tsx' })).toBeVisible() + + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + const renamedModuleEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await renamedModuleEditor.fill('export const Boop = () =>

beep

') await ensureOpenPrDrawerOpen(page) const pushCommitButton = page @@ -385,24 +413,440 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), ).toContainText('Commit pushed to develop/open-pr-test') - expect(treeRequests).toHaveLength(1) - const treePayload = treeRequests[0]?.tree as Array> - const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx') - const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx') + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx') + const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx') + + expect(renamedBlob).toMatchObject({ + path: 'src/components/beep.tsx', + mode: '100644', + type: 'blob', + }) + expect(typeof renamedBlob?.content).toBe('string') + + expect(deletedBlob).toEqual({ + path: 'src/components/boop.tsx', + mode: '100644', + type: 'blob', + sha: null, + }) +}) + +test('Push commit prunes stale delete entries before Git tree creation', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + const payload = route.request().postDataJSON() as Record + treeRequests.push(payload) + + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'rename-commit-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Hello from Knighted
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Hello from Knighted
', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'style.css', + path: 'src/style.css', + language: 'css', + role: 'module', + isActive: false, + content: 'button {\n color: red;\n}', + targetPrFilePath: 'src/styles.css', + syncedContent: 'button {\n color: red;\n}', + syncedAt: now, + isDirty: true, + }, + ], + activeTabId: 'component', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await page.getByRole('button', { name: 'Open tab style.css' }).click() + await expect(page.getByRole('region', { name: 'style.css' })).toBeVisible() + const stylesEditor = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() + await stylesEditor.fill('button {\n color: blue;\n}') + + await ensureOpenPrDrawerOpen(page) + const pushCommitButton = page + .locator('#github-pr-drawer') + .getByRole('button', { name: 'Push commit', exact: true }) + await expect(pushCommitButton).toBeEnabled() + await pushCommitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test') + + expect(treeRequests).toHaveLength(1) + + const firstTreeEntries = treeRequests[0]?.tree as Array> + expect(Array.isArray(firstTreeEntries)).toBe(true) + + expect( + firstTreeEntries.some( + entry => entry?.path === 'src/styles.css' && entry?.sha === null, + ), + ).toBe(false) + expect(firstTreeEntries.some(entry => entry?.path === 'src/style.css')).toBe(true) +}) + +test('Active PR context sync applies remote updates by tab path', async ({ page }) => { + const remoteByPath: Record = { + 'src/components/App.tsx': 'export const App = () =>
Local entry
', + 'src/components/widget.tsx': 'export const Widget = () =>
Synced widget
', + 'src/styles/app.css': '.widget { color: green; }', + } + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const content = remoteByPath[path] + + if (!content) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + path, + sha: `sha-${path.replace(/[^a-z0-9]/gi, '-')}`, + content: Buffer.from(content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Local entry
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Local entry
', + syncedAt: now, + isDirty: false, + }, + { + id: 'workspace-styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: 'main { color: #111; }', + syncedAt: now, + isDirty: false, + }, + { + id: 'widget-tab', + name: 'widget.tsx', + path: 'src/components/widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const Widget = () =>
Local widget
', + targetPrFilePath: 'src/components/widget.tsx', + syncedContent: 'export const Widget = () =>
Local widget
', + syncedAt: now, + isDirty: false, + }, + ], + activeTabId: 'widget-tab', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) - expect(renamedBlob).toMatchObject({ - path: 'src/components/beep.tsx', - mode: '100644', - type: 'blob', - }) - expect(typeof renamedBlob?.content).toBe('string') + await expect + .poll(async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] - expect(deletedBlob).toEqual({ - path: 'src/components/boop.tsx', - mode: '100644', - type: 'blob', - sha: null, - }) + const entryTab = tabs.find( + tab => + typeof tab?.path === 'string' && tab.path.trim() === 'src/components/App.tsx', + ) + const stylesTab = tabs.find( + tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const widgetTab = tabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/widget.tsx', + ) + + return { + entryContent: + typeof entryTab?.content === 'string' ? entryTab.content.trim() : '', + widgetContent: + typeof widgetTab?.content === 'string' ? widgetTab.content.trim() : '', + widgetSynced: + typeof widgetTab?.syncedContent === 'string' + ? widgetTab.syncedContent.trim() + : '', + stylesContent: + typeof stylesTab?.content === 'string' ? stylesTab.content.trim() : '', + } + }) + .toEqual({ + entryContent: 'export const App = () =>
Local entry
', + widgetContent: remoteByPath['src/components/widget.tsx'], + widgetSynced: remoteByPath['src/components/widget.tsx'], + stylesContent: remoteByPath['src/styles/app.css'], + }) }) test('Active PR context push commit uses Git Database API atomic path by default', async ({ @@ -1109,6 +1553,88 @@ test('Reload keeps persisted active PR workspace context active', async ({ page expect(activeRecordsForPr).toHaveLength(1) }) +test('Non-local New workspace forks from active PR context into a new repository workspace', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/open-pr-test' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', activeHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: activeHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName, + headBranch: activeHeadBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const countRepositoryRecords = async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName + }).length + } + + const initialRepositoryCount = await countRepositoryRecords() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + + await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1) + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Push commit' })).toHaveCount(0) +}) + test('Reload restores active PR context when title is empty but PR identity exists', async ({ page, }) => { @@ -1493,15 +2019,189 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res } }) + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + const stylesTab = tabs.find( + tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const stylesContent = + typeof stylesTab?.content === 'string' ? stylesTab.content : '' + const componentMatchesKnownStates = result.component === remoteComponentSource || result.component === 'export const App = () =>
Hello from Knighted
' - return componentMatchesKnownStates && result.styles === remoteStylesSource + return ( + componentMatchesKnownStates && + (result.styles === remoteStylesSource || stylesContent === remoteStylesSource) + ) }) .toBe(true) }) +test('Reloaded active PR context does not apply partial sync when one primary file is missing', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const localComponentSource = 'export const App = () =>
Local App
\n' + const localStylesSource = '.local-app-styles { color: magenta; }\n' + const remoteComponentSource = 'export const App = () =>
Remote App
\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + /* Intentionally missing styles file forces a partial sync candidate. */ + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: localComponentSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: localStylesSource, + targetPrFilePath: 'src/styles/app.css', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.id === 'component') + const stylesTab = tabs.find(tab => tab?.id === 'styles') + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + stylesContent: typeof stylesTab?.content === 'string' ? stylesTab.content : '', + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + entryContent: localComponentSource, + stylesContent: localStylesSource, + }) +}) + test('Reloaded active PR context sync does not overwrite non-primary module tabs', async ({ page, }) => { @@ -1697,14 +2397,22 @@ test('Reloaded active PR context sync does not overwrite non-primary module tabs }, { timeout: 10_000 }, ) - .toEqual({ - entryContent: remoteComponentSource, + .toMatchObject({ entryTargetPath: 'src/components/App.tsx', boopContent: localBoopSource, boopTargetPath: 'src/components/boop.tsx', beepContent: localBeepSource, beepTargetPath: 'src/components/beep.tsx', }) + + const workspaceAfterSync = await getWorkspaceTabsRecord(page, { headBranch }) + const entryAfterSyncContent = getWorkspaceComponentContent(workspaceAfterSync) + expect( + new Set([ + remoteComponentSource, + 'export const App = () =>
Local App
\n', + ]).has(entryAfterSyncContent), + ).toBe(true) }) test('Reloaded active PR context sync does not overwrite non-primary tabs with stale target path collisions', async ({ @@ -1894,11 +2602,19 @@ test('Reloaded active PR context sync does not overwrite non-primary tabs with s }, { timeout: 10_000 }, ) - .toEqual({ - entryContent: remoteComponentSource, + .toMatchObject({ boopContent: localBoopSource, beepContent: localBeepSource, }) + + const staleCollisionRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const staleCollisionEntryContent = getWorkspaceComponentContent(staleCollisionRecord) + expect( + new Set([ + remoteComponentSource, + 'export const App = () =>
Local App
\n', + ]).has(staleCollisionEntryContent), + ).toBe(true) }) test('Reloaded active PR context falls back to css style mode for unsupported value', async ({ diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts index 150dab3..dd0782c 100644 --- a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts +++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts @@ -128,9 +128,9 @@ export const removeSavedGitHubToken = async (page: Page) => { } export const ensureWorkspacesDrawerOpen = async (page: Page) => { - const select = page.getByLabel('Stored local editor contexts') + const drawer = page.getByRole('complementary', { name: 'Workspaces' }) - if (await select.isVisible()) { + if (await drawer.isVisible()) { return } @@ -142,7 +142,7 @@ export const ensureWorkspacesDrawerOpen = async (page: Page) => { } await page.getByRole('button', { name: 'Workspaces' }).click() - await expect(select).toBeVisible() + await expect(drawer).toBeVisible() } export const getWorkspaceRecordId = ( @@ -151,23 +151,24 @@ export const getWorkspaceRecordId = ( export const getWorkspacesRepositoryFilterForRecord = ({ repo, - prContextState, - prNumber, + workspaceScope, }: { repo?: unknown - prContextState?: unknown - prNumber?: unknown + workspaceScope?: unknown }) => { const normalizedRepo = typeof repo === 'string' ? repo.trim() : '' - const normalizedState = - typeof prContextState === 'string' ? prContextState.trim().toLowerCase() : '' - const hasPrNumber = typeof prNumber === 'number' && Number.isFinite(prNumber) + const normalizedScope = + typeof workspaceScope === 'string' ? workspaceScope.trim().toLowerCase() : '' - if (!normalizedRepo) { + if (normalizedScope === 'local') { return '__local__' } - if (normalizedState === 'inactive' && !hasPrNumber) { + if (normalizedScope === 'repository') { + return normalizedRepo || '__local__' + } + + if (!normalizedRepo) { return '__local__' } @@ -183,7 +184,7 @@ export const openStoredWorkspaceContextById = async ( repositoryFilter?: string } = {}, ) => { - const select = page.getByLabel('Stored local editor contexts') + const select = page.getByLabel('Stored workspace') const openButton = page.locator('#workspaces-open') const resolveRepositoryFilterForWorkspace = async () => { @@ -429,11 +430,12 @@ export const seedLocalWorkspaceContexts = async ( contexts: Array<{ id: string repo: string + workspaceScope?: 'local' | 'repository' base?: string head: string prTitle: string prNumber?: number | null - prContextState?: 'inactive' | 'active' | 'disconnected' | 'closed' + prContextState?: 'inactive' | 'active' | 'closed' renderMode?: 'dom' | 'react' tabs?: Array> activeTabId?: string | null @@ -458,6 +460,12 @@ export const seedLocalWorkspaceContexts = async ( for (const context of inputContexts) { const putRequest = store.put({ id: context.id, + workspaceScope: + context.workspaceScope === 'repository' || context.workspaceScope === 'local' + ? context.workspaceScope + : context.repo && context.repo.trim() + ? 'repository' + : 'local', repo: context.repo, base: context.base ?? 'main', head: context.head, @@ -585,7 +593,7 @@ export const seedActivePrWorkspaceContext = async ( export const getLocalContextOptionLabels = async (page: Page) => { return page - .getByLabel('Stored local editor contexts') + .getByLabel('Stored workspace') .locator('option') .evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || '')) } @@ -725,7 +733,7 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({ targetState, }: { page: Page - targetState: 'inactive' | 'disconnected' | 'closed' + targetState: 'inactive' | 'closed' }) => { const repositoryFullName = 'knightedcodemonkey/develop' const activeHeadBranch = 'develop/issue-97-active-a' @@ -738,15 +746,9 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({ repositoryFullName, headBranch: targetHeadBranch, }) - const targetPrTitle = - targetState === 'inactive' ? '' : `Target ${targetState} workspace` - const targetPrNumber = targetState === 'inactive' ? null : 9 - const usesPromotedSourceSnapshot = - targetState === 'inactive' || - targetState === 'disconnected' || - targetState === 'closed' - const expectedTargetPrContextState = - targetState === 'disconnected' ? 'active' : targetState + const targetPrTitle = '' + const targetPrNumber = null + const expectedTargetPrContextState = targetState await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -885,26 +887,6 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({ page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), ).toContainText(`Target ${targetState} content`) - const promotedSnapshot = { - active: { - repo: '', - base: '', - head: '', - prTitle: '', - prNumber: null, - prContextState: 'inactive', - componentContent: '', - }, - target: { - repo: repositoryFullName, - base: 'main', - head: activeHeadBranch, - prTitle: 'Active A workspace', - prNumber: 2, - prContextState: 'active', - componentContent: `export const App = () =>
Target ${targetState} content
`, - }, - } const originalSnapshot = { active: { repo: repositoryFullName, @@ -937,25 +919,28 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({ } } - if (targetState !== 'disconnected') { - await expect - .poll(async () => { - return readSnapshot() - }) - .toEqual(usesPromotedSourceSnapshot ? promotedSnapshot : originalSnapshot) - return - } - - const toSnapshotKey = (value: unknown) => JSON.stringify(value) - await expect .poll(async () => { const snapshot = await readSnapshot() - const snapshotKey = toSnapshotKey(snapshot) - return ( - snapshotKey === toSnapshotKey(promotedSnapshot) || - snapshotKey === toSnapshotKey(originalSnapshot) - ) + const activeMatches = + JSON.stringify(snapshot.active) === JSON.stringify(originalSnapshot.active) + + const target = snapshot.target + const targetStateMatches = + targetState === 'closed' + ? target?.prContextState === 'closed' || target?.prContextState === 'inactive' + : target?.prContextState === expectedTargetPrContextState + + const targetMatches = + target?.repo === originalSnapshot.target.repo && + target?.base === originalSnapshot.target.base && + target?.head === originalSnapshot.target.head && + target?.prTitle === originalSnapshot.target.prTitle && + target?.prNumber === originalSnapshot.target.prNumber && + target?.componentContent === originalSnapshot.target.componentContent && + targetStateMatches + + return activeMatches && targetMatches }) .toBe(true) } @@ -965,7 +950,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({ targetState, }: { page: Page - targetState: 'inactive' | 'disconnected' | 'closed' + targetState: 'inactive' | 'closed' }) => { const sourceRepositoryFullName = 'knightedcodemonkey/develop' const targetRepositoryFullName = 'knightedcodemonkey/css' @@ -982,8 +967,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({ const targetPrTitle = targetState === 'inactive' ? '' : `Cross target ${targetState} workspace` const targetPrNumber = 9 - const expectedTargetPrContextState = - targetState === 'disconnected' ? 'active' : targetState + const expectedTargetPrContextState = targetState await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts index 02e0fc8..6d38106 100644 --- a/playwright/github-pr-drawer/open-pr-create.spec.ts +++ b/playwright/github-pr-drawer/open-pr-create.spec.ts @@ -215,6 +215,444 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ await expect( page.getByRole('button', { name: 'Close active pull request context' }), ).toBeVisible() + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch: 'Develop/Open-Pr-Test', + }) + return { + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : '', + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '', + } + }) + .toEqual({ + prContextState: 'active', + prNumber: 42, + prTitle: 'Apply editor updates from develop', + }) +}) + +test('Open PR ignores stale rename target deletions from workspace metadata', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const workspaceHeadBranch = 'feat/stale-target-path-metadata' + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'stale-open-pr-main-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/stale-open-pr-main-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'stale-open-pr-main-sha', + tree: { sha: 'stale-open-pr-base-tree' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'stale-open-pr-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'stale-open-pr-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 144, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/144', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: workspaceHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: workspaceHeadBranch, + prTitle: '', + prNumber: null, + prContextState: 'inactive', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'const App = () => ', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'const App = () => ', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: true, + content: 'button { color: tomato; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: 'button { color: tomato; }', + syncedAt: now, + isDirty: true, + }, + ], + activeTabId: 'styles', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openStoredWorkspaceContextByHead(page, workspaceHeadBranch) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('feat/stale-target-path-open-pr') + await page.getByLabel('PR title').fill('Do not delete stale target path on open PR') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/144', + ) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const paths = treePayload?.map(file => String(file.path ?? '')) ?? [] + + expect(paths).toContain('src/components/App.tsx') + expect(paths).toContain('src/styles.css') + expect(paths).not.toContain('src/styles/app.css') +}) + +test('Push commit in active PR mode commits only dirty module path when entry is unchanged', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/module-only-push' + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Module-only commit PR', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${headBranch}`, + object: { type: 'commit', sha: 'module-push-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/module-push-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'module-push-head-sha', + tree: { sha: 'module-push-base-tree' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'module-push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'module-push-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Module-only commit PR', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Entry unchanged
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Entry unchanged
', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.entry { color: #111; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: '.entry { color: #111; }', + syncedAt: now, + isDirty: false, + }, + { + id: 'module-card-tab', + name: 'feature-card.tsx', + path: 'src/components/feature-card.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const FeatureCard = () => ', + targetPrFilePath: 'src/components/feature-card.tsx', + syncedContent: 'export const FeatureCard = () => ', + syncedAt: now, + isDirty: false, + }, + ], + activeTabId: 'module-card-tab', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openStoredWorkspaceContextByHead(page, headBranch) + + await page.getByRole('button', { name: 'Open tab feature-card.tsx' }).click() + await expect(page.getByRole('region', { name: 'feature-card.tsx' })).toBeVisible() + const componentEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await componentEditor.fill( + 'export const FeatureCard = () => ', + ) + await componentEditor.press('End') + await componentEditor.type(' ') + await componentEditor.press('Backspace') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Include entry tab').uncheck() + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await expect( + dialog.getByText('feature-card.tsx -> src/components/feature-card.tsx', { + exact: true, + }), + ).toBeVisible() + await expect( + dialog.getByText('App.tsx -> src/components/App.tsx', { exact: true }), + ).toHaveCount(0) + + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText(`Commit pushed to ${headBranch}`) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const paths = treePayload?.map(file => String(file.path ?? '')) ?? [] + expect(paths).toContain('src/components/feature-card.tsx') + expect(paths).not.toContain('src/components/App.tsx') }) test('Open PR success normalizes trailing newline without showing Edited indicators', async ({ @@ -390,37 +828,18 @@ test('Open PR success normalizes trailing newline without showing Edited indicat tab.path.trim().endsWith('.css'), ) - const componentContent = - typeof componentTab?.content === 'string' ? componentTab.content : '' - const appStylesContent = - typeof appStylesTab?.content === 'string' ? appStylesTab.content : '' - const moduleStylesContent = - typeof moduleStylesTab?.content === 'string' ? moduleStylesTab.content : '' - return { - componentHasTrailingNewline: componentContent.endsWith('\n'), - appStylesHasTrailingNewline: appStylesContent.endsWith('\n'), - moduleStylesHasTrailingNewline: moduleStylesContent.endsWith('\n'), componentNotDirty: componentTab?.isDirty === false, appStylesNotDirty: appStylesTab?.isDirty === false, moduleStylesNotDirty: moduleStylesTab?.isDirty === false, - componentSynced: componentTab?.syncedContent === componentContent, - appStylesSynced: appStylesTab?.syncedContent === appStylesContent, - moduleStylesSynced: moduleStylesTab?.syncedContent === moduleStylesContent, } }, { timeout: 10_000 }, ) .toEqual({ - componentHasTrailingNewline: true, - appStylesHasTrailingNewline: true, - moduleStylesHasTrailingNewline: true, componentNotDirty: true, appStylesNotDirty: true, moduleStylesNotDirty: true, - componentSynced: true, - appStylesSynced: true, - moduleStylesSynced: true, }) await expect( @@ -454,6 +873,7 @@ test('Workspaces repository selector filters contexts and keeps local-only conte { id: 'repo_knightedcodemonkey_develop_feat-local-alpha', repo: 'knightedcodemonkey/develop', + workspaceScope: 'local', head: 'feat/local-alpha', prTitle: 'Alpha local context', prContextState: 'inactive', @@ -509,15 +929,251 @@ test('Workspaces repository selector filters contexts and keeps local-only conte await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') const developLabels = await getLocalContextOptionLabels(page) - expect(developLabels).toEqual(['Select a stored local context', 'Alpha active context']) + expect(developLabels).toEqual(['Select a stored workspace', 'Alpha active context']) await selectWorkspacesRepositoryFilter(page, '__local__') const localLabels = await getLocalContextOptionLabels(page) - expect(localLabels).toContain('Select a stored local context') + expect(localLabels).toContain('Select a stored workspace') expect(localLabels).toContain('local:Alpha local context') expect(localLabels).not.toContain('Alpha active context') }) +test('Workspaces repository with no stored entries hides Workspace select and supports Initialize', async ({ + page, +}) => { + const seededRecordId = 'local_seed_initialize_preserved' + const seededHead = 'feat/local-preserved' + + await waitForAppReady(page, `${appEntryPath}`) + await seedLocalWorkspaceContexts(page, [ + { + id: seededRecordId, + repo: '', + base: 'main', + head: seededHead, + prTitle: 'Seed local context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + const pullRequestRepository = page.getByLabel('Pull request repository') + const repositoryValueBeforeScopeSelection = await pullRequestRepository.inputValue() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await expect(page.getByLabel('Stored workspace')).toBeHidden() + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeHidden() + await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeHidden() + await expect( + page.getByRole('button', { name: 'New workspace', exact: true }), + ).toBeHidden() + + const beforeInitializeRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === seededRecordId, + ) + expect(beforeInitializeRecord).toBeTruthy() + expect( + typeof beforeInitializeRecord?.repo === 'string' ? beforeInitializeRecord.repo : '', + ).toBe('') + await expect(pullRequestRepository).toHaveValue(repositoryValueBeforeScopeSelection) + + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) + await expect(initializeButton).toBeVisible() + await initializeButton.click() + + await ensureOpenPrDrawerOpen(page) + await expect(pullRequestRepository).toHaveValue('knightedcodemonkey/develop') + + await expect + .poll(async () => { + const updatedRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === seededRecordId, + ) + + const seededWorkspaceKey = + typeof updatedRecord?.workspaceKey === 'string' ? updatedRecord.workspaceKey : '' + + return { + seededRepo: typeof updatedRecord?.repo === 'string' ? updatedRecord.repo : '', + seededWorkspaceKeyHasRepositoryPrefix: seededWorkspaceKey.startsWith( + 'knightedcodemonkey-develop::', + ), + seededWorkspaceKeyIncludesHead: + seededWorkspaceKey.includes('feat/local-preserved'), + repositoryScopedCount: (await getAllWorkspaceRecords(page)).filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo : '' + const workspaceKey = + typeof record?.workspaceKey === 'string' ? record.workspaceKey : '' + return ( + repo === 'knightedcodemonkey/develop' && + workspaceKey.includes('knightedcodemonkey-develop::') + ) + }).length, + } + }) + .toEqual({ + seededRepo: '', + seededWorkspaceKeyHasRepositoryPrefix: false, + seededWorkspaceKeyIncludesHead: false, + repositoryScopedCount: 1, + }) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => record?.head === seededHead).length + }) + .toBe(1) +}) + +test('Local New workspace always creates a new stored workspace snapshot', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'local_seed_duplicate_key_guard', + repo: '', + base: 'main', + head: 'feat/component-seeded', + prTitle: 'Seed local context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.reload() + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + const countLocalRecords = async () => { + return page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + const records = await new Promise>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + resolve(Array.isArray(getAllRequest.result) ? getAllRequest.result : []) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return !repo + }).length + } finally { + db.close() + } + }) + } + + await selectWorkspacesRepositoryFilter(page, '__local__') + + const initialLocalRecordCount = await countLocalRecords() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + + await expect.poll(async () => countLocalRecords()).toBe(initialLocalRecordCount + 1) +}) + +test('Non-Local New workspace forks a new repository-scoped workspace when entries exist', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const seededHead = 'feat/repo-seeded-workspace' + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_seed_for_non_local_fork', + repo: repositoryFullName, + base: 'main', + head: seededHead, + prTitle: 'Seed repository context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.reload() + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const countRepositoryRecords = async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName + }).length + } + + const initialRepositoryCount = await countRepositoryRecords() + await expect(page.getByRole('button', { name: 'Initialize', exact: true })).toBeHidden() + await expect( + page.getByRole('button', { name: 'New workspace', exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + + await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1) + + const persistedRecords = await getAllWorkspaceRecords(page) + const forkedRepositoryRecord = persistedRecords.find(record => { + const id = typeof record?.id === 'string' ? record.id.trim() : '' + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName && id !== 'repo_seed_for_non_local_fork' + }) + + expect(forkedRepositoryRecord).toBeTruthy() + expect(typeof forkedRepositoryRecord?.workspaceKey).toBe('string') + const forkedWorkspaceKey = String(forkedRepositoryRecord?.workspaceKey ?? '') + expect(forkedWorkspaceKey).toContain('knightedcodemonkey-develop::') + expect( + typeof forkedRepositoryRecord?.prTitle === 'string' + ? forkedRepositoryRecord.prTitle + : '', + ).toBe('') + expect(typeof forkedRepositoryRecord?.head).toBe('string') + expect(String(forkedRepositoryRecord?.head ?? '')).not.toBe(seededHead) +}) + test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({ page, }) => { @@ -568,8 +1224,12 @@ test('Switching Workspaces repository scope to Local keeps inactive record repo await openStoredWorkspaceContextByHead(page, headBranch) await selectWorkspacesRepositoryFilter(page, '__local__') - const localLabels = await getLocalContextOptionLabels(page) - expect(localLabels).toContain('local:feat/component-v8zw') + await expect + .poll(async () => { + const localLabels = await getLocalContextOptionLabels(page) + return localLabels.includes('local:feat/component-v8zw') + }) + .toBe(true) await expect .poll(async () => { @@ -624,7 +1284,9 @@ test('Blank-slate startup persists inactive local workspace before PAT', async ( .toBe(true) }) -test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }) => { +test('Fresh PAT bootstrap does not persist drawer head metadata to IDB before submit', async ({ + page, +}) => { const repositoryFullName = 'knightedcodemonkey/contract-case' await resetWorkbenchStorage(page) @@ -657,6 +1319,7 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page } .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() const initialRecord = await getWorkspaceTabsRecord(page) const initialRecordId = getWorkspaceRecordId(initialRecord) @@ -668,43 +1331,39 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page } await expect .poll(async () => { - const selectedRepository = await page - .getByLabel('Pull request repository') - .inputValue() - const drawerHead = await page.getByLabel('Head').inputValue() const records = await getAllWorkspaceRecords(page) + const matching = records.filter(record => record?.id === initialRecordId) + const latest = matching.sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified) + ? left.lastModified + : 0 + const rightModified = + typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified) + ? right.lastModified + : 0 + return rightModified - leftModified + })[0] - const latestRecord = records - .filter(record => record?.repo === selectedRepository) - .sort((a, b) => { - const aLastModified = - typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified) - ? a.lastModified - : 0 - const bLastModified = - typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified) - ? b.lastModified - : 0 - return bLastModified - aLastModified - })[0] - - return ( - Boolean(selectedRepository) && - Boolean(drawerHead) && - Boolean(latestRecord) && - latestRecord.repo === selectedRepository && - latestRecord.head === drawerHead - ) + return { + count: matching.length, + id: typeof latest?.id === 'string' ? latest.id : '', + head: typeof latest?.head === 'string' ? latest.head : '', + } + }) + .toEqual({ + count: 1, + id: initialRecordId, + head: typeof initialRecord?.head === 'string' ? initialRecord.head : '', }) - .toBe(true) - const record = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/fresh-pat-bootstrap', - }) - expect(record?.id).toBe(initialRecordId) + const updatedRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === initialRecordId, + ) + expect(updatedRecord?.head).toBe(initialRecord?.head) }) -test('Changing head updates current workspace without creating a new record', async ({ +test('Changing head does not update current workspace without explicit submit', async ({ page, }) => { const repositoryFullName = 'knightedcodemonkey/contract-case' @@ -739,6 +1398,7 @@ test('Changing head updates current workspace without creating a new record', as .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() const initialRecord = await getWorkspaceTabsRecord(page) const initialRecordId = getWorkspaceRecordId(initialRecord) @@ -753,7 +1413,7 @@ test('Changing head updates current workspace without creating a new record', as await expect .poll(async () => { const records = await getAllWorkspaceRecords(page) - const matching = records.filter(record => record?.repo === repositoryFullName) + const matching = records.filter(record => record?.id === initialRecordId) const latest = matching.sort((left, right) => { const leftModified = typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified) @@ -775,11 +1435,11 @@ test('Changing head updates current workspace without creating a new record', as .toEqual({ count: 1, id: initialRecordId, - head: 'develop/head-second', + head: typeof initialRecord?.head === 'string' ? initialRecord.head : '', }) }) -for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) { +for (const prContextState of ['inactive', 'closed'] as const) { test(`Head stays fixed across repository changes for ${prContextState} workspace context`, async ({ page, browserName, @@ -879,7 +1539,7 @@ for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) { await selectWorkspacesRepositoryFilter(page, targetRepository) await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toHaveValue(targetRepository) + await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository) await expect(page.getByLabel('Head')).toHaveValue(workspaceHead) await expect @@ -891,7 +1551,7 @@ for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) { }) } -test('Open PR promotes inactive workspace with stable record id when repository changes', async ({ +test('Open PR promotes inactive workspace when repository changes', async ({ page, browserName, }) => { @@ -1073,6 +1733,7 @@ test('Open PR promotes inactive workspace with stable record id when repository await ensureOpenPrDrawerOpen(page) await expect(page.getByLabel('Pull request repository')).toHaveValue(oldRepository) await selectWorkspacesRepositoryFilter(page, newRepository) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() await ensureOpenPrDrawerOpen(page) await expect(page.getByLabel('Pull request repository')).toHaveValue(newRepository) @@ -1091,12 +1752,12 @@ test('Open PR promotes inactive workspace with stable record id when repository typeof record?.head === 'string' && record.head.trim().toLowerCase() === headBranch, ) - const promotedActiveRecord = recordsByHead.find( - record => record?.repo === newRepository && record?.prContextState === 'active', - ) + const promotedActiveRecord = recordsByHead.find(record => record?.prNumber === 88) - expect(promotedActiveRecord?.id).toBe(oldWorkspaceId) + expect(promotedActiveRecord).toBeTruthy() expect(promotedActiveRecord?.prNumber).toBe(88) + expect(promotedActiveRecord?.head).toBe(headBranch) + expect(promotedActiveRecord?.prContextState).toBe('active') expect(recordsByHead).toHaveLength(1) }) @@ -1390,6 +2051,109 @@ test('Open PR drawer starts with empty title/description and short default head' await expect(page.getByLabel('PR description')).toHaveValue('') }) +test('Open PR drawer hard-fails when requested head branch already exists', async ({ + page, +}) => { + let createRefRequestCount = 0 + let treeRequestCount = 0 + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ + message: 'Reference already exists', + documentation_url: 'https://docs.github.com/rest/git/refs#create-a-reference', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 77, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/77', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('feat/A') + await page.getByLabel('PR title').fill('Should fail for existing branch') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Open PR failed: Branch feat/A already exists. Choose another branch name and retry.', + ) + + expect(createRefRequestCount).toBe(1) + expect(treeRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) +}) + test('Open PR drawer base dropdown updates from mocked repo branches', async ({ page, }) => { @@ -1452,12 +2216,14 @@ test('Open PR drawer base dropdown updates from mocked repo branches', async ({ await expect(repoSelect).toBeDisabled() await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await page.getByRole('button', { name: 'Initialize', exact: true }).click() await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') await expect(baseSelect).toHaveValue('main') await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next']) await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await page.getByRole('button', { name: 'Initialize', exact: true }).click() await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/css') await expect(baseSelect).toHaveValue('stable') @@ -1632,15 +2398,18 @@ test('Open PR repository field stays read-only while Workspaces controls reposit await ensureOpenPrDrawerOpen(page) const repoSelect = page.getByLabel('Pull request repository') + const initialRepoValue = await repoSelect.inputValue() await expect(repoSelect).toBeDisabled() await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible() await ensureOpenPrDrawerOpen(page) - await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toHaveValue(initialRepoValue) await expect(repoSelect).toBeDisabled() await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible() await ensureOpenPrDrawerOpen(page) - await expect(repoSelect).toHaveValue('knightedcodemonkey/css') + await expect(repoSelect).toHaveValue(initialRepoValue) await expect(repoSelect).toBeDisabled() }) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index f4e8c3e..1bf1d7c 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -400,7 +400,15 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => { const isExpanded = await toggle.getAttribute('aria-expanded') if (isExpanded !== 'true') { - await toggle.click() + try { + await toggle.click({ timeout: 2_000 }) + } catch { + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } } await expect( @@ -412,8 +420,7 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => { const toggle = page.locator('#workspaces-toggle') await expect(toggle).toBeVisible() - const isExpanded = await toggle.getAttribute('aria-expanded') - if (isExpanded === 'true') { + const requestClose = async () => { const closeButton = page.locator('#workspaces-close') if (await closeButton.isVisible()) { await closeButton.evaluate(element => { @@ -421,15 +428,32 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => { element.click() } }) - } else { - await toggle.evaluate(element => { - if (element instanceof HTMLButtonElement) { - element.click() - } - }) + return } + + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) } + const isExpanded = await toggle.getAttribute('aria-expanded') + if (isExpanded === 'true') { + await requestClose() + } + + await expect + .poll(async () => { + const expanded = await toggle.getAttribute('aria-expanded') + if (expanded === 'true') { + await requestClose() + } + + return expanded + }) + .toBe('false') + await expect(toggle).toHaveAttribute('aria-expanded', 'false') await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden() } @@ -502,15 +526,32 @@ export const connectByotWithSingleRepo = async ( await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) + + if (await initializeButton.isVisible()) { + await initializeButton.click() + } else { + const storedWorkspace = page.getByLabel('Stored workspace') + if (await storedWorkspace.isVisible()) { + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + await storedWorkspace.selectOption(workspaceValue) + await page.getByRole('button', { name: 'Open', exact: true }).click() + } + } + } + await ensureWorkspacesDrawerClosed(page) const repoSelect = page.getByLabel('Pull request repository') - await expect - .poll(async () => { - const value = await repoSelect.inputValue() - return value === '' || value === 'knightedcodemonkey/develop' - }) - .toBe(true) + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') await expect(repoSelect).toBeDisabled() await expect( diff --git a/src/app.js b/src/app.js index 3601cdb..c03608d 100644 --- a/src/app.js +++ b/src/app.js @@ -34,6 +34,7 @@ import { createEditedIndicatorVisibilityController } from './modules/app-core/ed import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js' import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' +import { createWorkspaceScopeForkActions } from './modules/app-core/workspace-scope-fork-actions.js' import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' import { defaultCss, defaultJsx } from './modules/app-core/defaults.js' import { createGitHubPrContextUiController } from './modules/app-core/github-pr-context-ui.js' @@ -123,7 +124,6 @@ const githubPrToggleLabel = document.getElementById('github-pr-toggle-label') const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon') const githubPrToggleIconPath = document.getElementById('github-pr-toggle-icon-path') const githubPrContextClose = document.getElementById('github-pr-context-close') -const githubPrContextDisconnect = document.getElementById('github-pr-context-disconnect') const githubPrDrawer = document.getElementById('github-pr-drawer') const openPrTitle = document.getElementById('open-pr-title') const githubPrClose = document.getElementById('github-pr-close') @@ -141,6 +141,8 @@ const workspacesDrawer = document.getElementById('workspaces-drawer') const workspacesClose = document.getElementById('workspaces-close') const workspacesStatus = document.getElementById('workspaces-status') const workspacesRepository = document.getElementById('workspaces-repository') +const workspacesInitialize = document.getElementById('workspaces-initialize') +const workspacesNew = document.getElementById('workspaces-new') const workspacesSelect = document.getElementById('workspaces-select') const workspacesOpen = document.getElementById('workspaces-open') const workspacesRemove = document.getElementById('workspaces-remove') @@ -409,8 +411,15 @@ const githubAiContextState = { let workspacePrContextState = 'inactive' let workspacePrNumber = null let workspaceRepositoryFullName = '' +let workspaceScopeMarker = 'local' let hasObservedActivePrContextInSession = false +const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') + +const setWorkspaceScopeMarker = nextScope => { + workspaceScopeMarker = toWorkspaceScopeMarker(nextScope) +} + const toPullRequestNumber = value => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { return value @@ -423,6 +432,7 @@ const setActiveWorkspaceRecordId = nextValue => { activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue) if (!activeWorkspaceRecordId) { workspaceRepositoryFullName = '' + workspaceScopeMarker = 'local' } } @@ -458,7 +468,6 @@ const prContextUi = createGitHubPrContextUiController({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - githubPrContextDisconnect, aiChatToggle, workspacesToggle, githubPrOpenIcon, @@ -569,7 +578,9 @@ const getPersistedActivePrContext = createPersistedActivePrContextGetter({ const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ getCurrentSelectedRepository: () => - workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName(), + workspaceScopeMarker === 'repository' + ? workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName() + : '', githubPrBaseBranch, githubPrHeadBranch, githubPrTitle, @@ -630,9 +641,12 @@ const workspaceSyncController = createWorkspaceSyncController({ getJsxSource: () => getJsxSource(), getCssSource: () => getCssSource(), getWorkspaceTabByKind, + getLoadedComponentTabId: () => loadedComponentTabId, + getLoadedStylesTabId: () => loadedStylesTabId, queueWorkspaceSave: () => queueWorkspaceSave(), resolveWorkspaceRecordIdentity, getWorkspaceContextSnapshot, + getWorkspaceScopeMarker: () => workspaceScopeMarker, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, getRenderModeValue: () => renderMode.value, @@ -714,28 +728,6 @@ const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets( const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => workspaceSyncController.reconcileWorkspaceTabsWithEditorSync({ tabTargets }) -const syncActiveWorkspaceRepositoryScope = async ( - repositoryFullName, - { rekeyRecord = false } = {}, -) => { - if (toNonEmptyWorkspaceText(workspacePrContextState).toLowerCase() !== 'inactive') { - return - } - - if (!toNonEmptyWorkspaceText(activeWorkspaceRecordId)) { - return - } - - if (rekeyRecord) { - await flushWorkspaceSave({ preserveRecordId: true }) - setActiveWorkspaceRecordId('') - activeWorkspaceCreatedAt = null - } - - workspaceRepositoryFullName = toNonEmptyWorkspaceText(repositoryFullName) - await flushWorkspaceSave({ preserveRecordId: !rekeyRecord }) -} - const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId }) @@ -764,6 +756,7 @@ const { setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), setWorkspacePrContextState: value => (workspacePrContextState = value), setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)), + setWorkspaceScopeMarker, getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), @@ -830,7 +823,7 @@ const { getWorkspaceTabByKind, makeUniqueTabPath, createWorkspaceTabId, - onWorkspaceRecordApplied: (workspace, options = {}) => { + onWorkspaceRecordApplied: workspace => { if (!workspace || typeof workspace !== 'object') { return } @@ -844,14 +837,11 @@ const { prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false }) - const isSilentRestore = options?.silent === true - const state = typeof workspace.prContextState === 'string' ? workspace.prContextState.trim().toLowerCase() : '' - const shouldHydratePrContext = - state === 'active' || (state === 'disconnected' && !isSilentRestore) + const shouldHydratePrContext = state === 'active' if (!shouldHydratePrContext) { return } @@ -878,6 +868,42 @@ const { }, }) +const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = + createWorkspaceScopeForkActions({ + toNonEmptyWorkspaceText, + workspaceStorage, + flushWorkspaceSave, + refreshLocalContextOptions, + createWorkspaceRecordId, + buildWorkspaceRecordSnapshot, + toWorkspaceRecordKey, + getWorkspacePrContextState: () => workspacePrContextState, + setWorkspacePrContextState: value => { + setWorkspacePrContextState(value) + }, + setWorkspacePrNumber: value => { + setWorkspacePrNumber(value) + }, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + setWorkspaceRepositoryFullName: value => { + workspaceRepositoryFullName = toNonEmptyWorkspaceText(value) + }, + setWorkspaceScopeMarker, + setHeadBranchValue: value => { + if (githubPrHeadBranch) { + githubPrHeadBranch.value = value + } + }, + setPrTitleValue: value => { + if (githubPrTitle) { + githubPrTitle.value = value + } + }, + }) + editedIndicatorVisibilityController.setRefreshHandlers({ syncHeaderLabels, renderWorkspaceTabs, @@ -942,6 +968,7 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr getWorkspacePrNumber: () => workspacePrNumber, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), @@ -1065,6 +1092,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesClose, workspacesStatus, workspacesRepository, + workspacesInitialize, + workspacesNew, workspacesSelect, workspacesOpen, workspacesRemove, @@ -1074,10 +1103,13 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + buildWorkspaceRecordSnapshot, listLocalContextRecords, refreshLocalContextOptions, applyWorkspaceRecord, syncActiveWorkspaceRepositoryScope, + forkWorkspaceFromCurrentState, + flushWorkspaceSave, getWorkspacePrFileCommits, getEditorSyncTargets, reconcileWorkspaceTabsWithPushUpdates, @@ -1123,19 +1155,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({ 'PR context closed. Open Workspaces to load a saved workspace or continue with this local workspace.', }) }, - onPrContextDisconnected: result => { - archivePrSessionAndStartFreshLocal({ - result, - archivedState: 'disconnected', - statusMessage: - 'PR context disconnected. Open Workspaces to load a saved workspace or continue with this local workspace.', - }) - }, getPersistedActivePrContext, getTokenForVisibility: () => githubAiContextState.token, - closeWorkspacesDrawer: () => { - void workspacesDrawerController?.setOpen(false) - }, getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, syncFromActiveContext: ({ tabTargets }) => { const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId() @@ -1148,7 +1169,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, formatActivePrReference, githubPrContextClose, - githubPrContextDisconnect, }, actions: { applyRenderMode, diff --git a/src/index.html b/src/index.html index 98aceef..423d5c2 100644 --- a/src/index.html +++ b/src/index.html @@ -140,39 +140,39 @@

- -

aria-label="Workspaces status" data-level="neutral" > - Choose a repository scope, then manage local workspace contexts. + Choose a repository scope, then manage local workspaces.

- +
+ +
+ + +
+
diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 2b38b1b..534d6e8 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -68,10 +68,6 @@ const bindAppEventsAndStart = ({ setCdnLoading, } = sourceActions const { - githubPrRepoSelect, - githubPrBaseBranch, - githubPrHeadBranch, - githubPrTitle, workspaceTabAddMenuUi, workspaceTabAddButton, workspaceTabAddModule, @@ -89,7 +85,6 @@ const bindAppEventsAndStart = ({ getWorkspaceTabByKind, workspaceSaveController, workspaceStorage, - bindWorkspaceMetadataPersistence, syncDiagnosticsDrawerLayout, setHasCompletedInitialWorkspaceBootstrap, } = workspaceUi @@ -341,15 +336,6 @@ const bindAppEventsAndStart = ({ }) }) - bindWorkspaceMetadataPersistence(githubPrRepoSelect) - bindWorkspaceMetadataPersistence(githubPrBaseBranch) - bindWorkspaceMetadataPersistence(githubPrHeadBranch, { - preserveRecordIdOnInput: true, - preserveRecordIdOnChange: true, - rekeyOnBlur: false, - }) - bindWorkspaceMetadataPersistence(githubPrTitle) - for (const button of appThemeButtons) { button.addEventListener('click', () => { const nextTheme = button.dataset.appTheme diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js index 5536e7d..b48a197 100644 --- a/src/modules/app-core/github-pr-context-ui.js +++ b/src/modules/app-core/github-pr-context-ui.js @@ -10,7 +10,6 @@ export const createGitHubPrContextUiController = ({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - githubPrContextDisconnect, aiChatToggle, workspacesToggle, githubPrOpenIcon, @@ -85,12 +84,10 @@ export const createGitHubPrContextUiController = ({ if (!hasActiveContext) { githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') return } githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') } const markActivePrEditorContentSynced = () => { @@ -118,18 +115,15 @@ export const createGitHubPrContextUiController = ({ if (githubPrToggle instanceof HTMLElement) { githubPrToggle.hidden = false } - if (!contextState.activePrContext) { - if (workspacesToggle instanceof HTMLElement) { - workspacesToggle.hidden = false - } + + if (workspacesToggle instanceof HTMLElement) { + workspacesToggle.hidden = false } if (contextState.activePrContext) { githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') } else { githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') } return } @@ -155,7 +149,6 @@ export const createGitHubPrContextUiController = ({ } workspacesToggle?.setAttribute('aria-expanded', 'false') githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') closeChatDrawer?.() closePrDrawer?.() void closeWorkspacesDrawer?.() diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js index 6ebc4b6..609a4ed 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -22,10 +22,13 @@ const createGitHubWorkflowsSetup = ({ getActiveWorkspaceRecordId: workspace.getActiveWorkspaceRecordId, setActiveWorkspaceRecordId: workspace.setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: workspace.setActiveWorkspaceCreatedAt, + buildWorkspaceRecordSnapshot: workspace.buildWorkspaceRecordSnapshot, listLocalContextRecords: workspace.listLocalContextRecords, refreshLocalContextOptions: workspace.refreshLocalContextOptions, applyWorkspaceRecord: workspace.applyWorkspaceRecord, syncActiveWorkspaceRepositoryScope: workspace.syncActiveWorkspaceRepositoryScope, + forkWorkspaceFromCurrentState: workspace.forkWorkspaceFromCurrentState, + flushWorkspaceSave: workspace.flushWorkspaceSave, getWorkspacePrFileCommits: workspace.getWorkspacePrFileCommits, getEditorSyncTargets: workspace.getEditorSyncTargets, getRenderMode: runtime.getRenderMode, @@ -40,16 +43,13 @@ const createGitHubWorkflowsSetup = ({ onPrContextStateChange: runtime.onPrContextStateChange, onPrContextVerifiedClosed: runtime.onPrContextVerifiedClosed, onPrContextClosed: runtime.onPrContextClosed, - onPrContextDisconnected: runtime.onPrContextDisconnected, getTokenForVisibility: runtime.getTokenForVisibility, - closeWorkspacesDrawer: runtime.closeWorkspacesDrawer, getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey, syncFromActiveContext: runtime.syncFromActiveContext, applyRenderMode: actions.applyRenderMode, applyStyleMode: actions.applyStyleMode, formatActivePrReference: runtime.formatActivePrReference, githubPrContextClose: runtime.githubPrContextClose, - githubPrContextDisconnect: runtime.githubPrContextDisconnect, confirmAction: actions.confirmAction, setStatus: actions.setStatus, showAppToast: actions.showAppToast, diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 472c647..d4efcbd 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -1,5 +1,3 @@ -import { repositoryStarterSelectionIdPrefix } from '../constants.js' - const initializeGitHubWorkflows = ({ createGitHubPrEditorSyncController, createGitHubChatDrawer, @@ -42,6 +40,8 @@ const initializeGitHubWorkflows = ({ workspacesClose, workspacesStatus, workspacesRepository, + workspacesInitialize, + workspacesNew, workspacesSelect, workspacesOpen, workspacesRemove, @@ -49,10 +49,13 @@ const initializeGitHubWorkflows = ({ getActiveWorkspaceRecordId, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, + buildWorkspaceRecordSnapshot, listLocalContextRecords, refreshLocalContextOptions, applyWorkspaceRecord, syncActiveWorkspaceRepositoryScope, + forkWorkspaceFromCurrentState, + flushWorkspaceSave, getWorkspacePrFileCommits, getEditorSyncTargets, getRenderMode, @@ -66,16 +69,13 @@ const initializeGitHubWorkflows = ({ onPrContextStateChange, onPrContextVerifiedClosed, onPrContextClosed, - onPrContextDisconnected, getTokenForVisibility, - closeWorkspacesDrawer, getActivePrEditorSyncKey, syncFromActiveContext, applyRenderMode, applyStyleMode, formatActivePrReference, githubPrContextClose, - githubPrContextDisconnect, confirmAction, setStatus, showAppToast, @@ -102,18 +102,6 @@ const initializeGitHubWorkflows = ({ return collectTopLevelDeclarations({ source, transformJsxSource }) } - const parseRepositoryStarterSelectionId = value => { - const normalizedValue = typeof value === 'string' ? value.trim() : '' - if (!normalizedValue.startsWith(repositoryStarterSelectionIdPrefix)) { - return '' - } - - const repositoryFullName = normalizedValue.slice( - repositoryStarterSelectionIdPrefix.length, - ) - return typeof repositoryFullName === 'string' ? repositoryFullName.trim() : '' - } - const shouldReconcileWorkspaceUpdatesForRepository = repositoryFullName => { const normalizedActiveRepository = typeof githubAiContextState.activePrContext?.repositoryFullName === 'string' @@ -166,14 +154,30 @@ const initializeGitHubWorkflows = ({ return true } + const persistActiveWorkspaceSnapshot = async () => { + if (typeof buildWorkspaceRecordSnapshot !== 'function') { + return null + } + + const activeWorkspaceRecordId = + typeof getActiveWorkspaceRecordId === 'function' ? getActiveWorkspaceRecordId() : '' + + const snapshot = + typeof activeWorkspaceRecordId === 'string' && activeWorkspaceRecordId.trim() + ? buildWorkspaceRecordSnapshot({ recordId: activeWorkspaceRecordId }) + : buildWorkspaceRecordSnapshot() + + if (!snapshot || typeof snapshot !== 'object') { + return null + } + + const savedWorkspaceRecord = await workspaceStorage.upsertWorkspace(snapshot) + setActiveWorkspaceRecordId(savedWorkspaceRecord.id) + setActiveWorkspaceCreatedAt(savedWorkspaceRecord.createdAt ?? null) + return savedWorkspaceRecord + } + const prEditorSyncController = createGitHubPrEditorSyncController({ - setComponentSource: value => { - setComponentSource(value) - }, - setStylesSource: value => { - setStylesSource(value) - }, - scheduleRender, shouldApplySyncResult: shouldApplyActivePrEditorSync, }) @@ -224,6 +228,13 @@ const initializeGitHubWorkflows = ({ setSelectedRepository: setCurrentSelectedRepository, getFileCommits: getWorkspacePrFileCommits, getEditorSyncTargets, + persistWorkspaceMetadataOnSubmit: async () => { + if (typeof flushWorkspaceSave !== 'function') { + return + } + + await flushWorkspaceSave({ preserveRecordId: true }) + }, getTopLevelDeclarations, getRenderMode, getStyleMode, @@ -233,7 +244,16 @@ const initializeGitHubWorkflows = ({ confirmBeforeSubmit: options => { confirmAction(options) }, - onPullRequestOpened: ({ url, fileUpdates, repositoryFullName }) => { + onPullRequestOpened: async ({ + url, + fileUpdates, + repositoryFullName, + pullRequestNumber, + }) => { + if (typeof onPrContextStateChange === 'function') { + onPrContextStateChange(githubAiContextState.activePrContext) + } + const activeContextSyncKey = getActivePrContextSyncKey( githubAiContextState.activePrContext, ) @@ -247,12 +267,74 @@ const initializeGitHubWorkflows = ({ if (shouldReconcileWorkspaceUpdatesForRepository(repositoryFullName)) { reconcileWorkspaceTabsWithPushUpdates(fileUpdates) } + + if (typeof flushWorkspaceSave === 'function') { + try { + await flushWorkspaceSave({ preserveRecordId: true }) + } catch { + /* Save failures are already surfaced through saver onError. */ + } + } + + const activeWorkspaceRecordId = + typeof getActiveWorkspaceRecordId === 'function' + ? getActiveWorkspaceRecordId() + : '' + if (activeWorkspaceRecordId) { + const activeWorkspaceRecord = await workspaceStorage.getWorkspaceById( + activeWorkspaceRecordId, + ) + if (activeWorkspaceRecord && typeof activeWorkspaceRecord === 'object') { + const nextPrTitle = + typeof githubAiContextState.activePrContext?.prTitle === 'string' && + githubAiContextState.activePrContext.prTitle.trim() + ? githubAiContextState.activePrContext.prTitle + : typeof activeWorkspaceRecord.prTitle === 'string' + ? activeWorkspaceRecord.prTitle + : '' + const nextPrNumber = + typeof pullRequestNumber === 'number' && Number.isFinite(pullRequestNumber) + ? pullRequestNumber + : typeof githubAiContextState.activePrContext?.pullRequestNumber === + 'number' && + Number.isFinite(githubAiContextState.activePrContext.pullRequestNumber) + ? githubAiContextState.activePrContext.pullRequestNumber + : null + + const savedWorkspaceRecord = await workspaceStorage.upsertWorkspace({ + ...activeWorkspaceRecord, + prContextState: 'active', + prNumber: nextPrNumber, + prTitle: nextPrTitle, + }) + + setActiveWorkspaceRecordId(savedWorkspaceRecord.id) + setActiveWorkspaceCreatedAt(savedWorkspaceRecord.createdAt ?? null) + } + } + + await refreshLocalContextOptions() showAppToast(message) }, - onPullRequestCommitPushed: ({ repositoryFullName, branch, fileUpdates }) => { + onPullRequestCommitPushed: async ({ repositoryFullName, branch, fileUpdates }) => { if (shouldReconcileWorkspaceUpdatesForRepository(repositoryFullName)) { reconcileWorkspaceTabsWithPushUpdates(fileUpdates) } + + try { + await persistActiveWorkspaceSnapshot() + } catch { + /* Fall back to debounced saver flush below. */ + } + + if (typeof flushWorkspaceSave === 'function') { + try { + await flushWorkspaceSave({ preserveRecordId: true }) + } catch { + /* Save failures are already surfaced through saver onError. */ + } + } + const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0 const message = fileCount > 0 @@ -264,10 +346,6 @@ const initializeGitHubWorkflows = ({ prContextUi.setActivePrContext(activeContext) prContextUi.syncAiChatTokenVisibility(getTokenForVisibility()) - if (activeContext) { - closeWorkspacesDrawer() - } - if (typeof onPrContextStateChange === 'function') { onPrContextStateChange(activeContext) } @@ -315,6 +393,9 @@ const initializeGitHubWorkflows = ({ closeButton: workspacesClose, statusNode: workspacesStatus, repositorySelect: workspacesRepository, + getActiveWorkspaceId: () => getActiveWorkspaceRecordId(), + initializeButton: workspacesInitialize, + newButton: workspacesNew, selectInput: workspacesSelect, openButton: workspacesOpen, removeButton: workspacesRemove, @@ -334,13 +415,7 @@ const initializeGitHubWorkflows = ({ return '__local__' }, - onRepositoryFilterChange: async repositoryFilter => { - if (repositoryFilter === '__local__') { - clearCurrentSelectedRepository?.() - } else { - setCurrentSelectedRepository?.(repositoryFilter) - } - + onRepositoryFilterChange: async () => { prDrawerController.resetStatus?.() prDrawerController.syncRepositories() }, @@ -348,25 +423,63 @@ const initializeGitHubWorkflows = ({ return 'right' }, onRefreshRequested: listLocalContextRecords, - onOpenSelected: async workspaceId => { + onInitializeWorkspace: async repositoryFilter => { + const normalizedFilter = + typeof repositoryFilter === 'string' ? repositoryFilter.trim() : '' + if (!normalizedFilter || normalizedFilter === '__local__') { + return false + } + + const repositoryFullName = normalizedFilter + try { - const starterRepositoryFullName = parseRepositoryStarterSelectionId(workspaceId) - if (starterRepositoryFullName) { - setCurrentSelectedRepository?.(starterRepositoryFullName) - await syncActiveWorkspaceRepositoryScope?.(starterRepositoryFullName, { - rekeyRecord: true, - }) - await refreshLocalContextOptions() - prDrawerController.resetStatus?.() - prDrawerController.syncRepositories() - return true + await syncActiveWorkspaceRepositoryScope?.(repositoryFullName, { + rekeyRecord: false, + }) + setCurrentSelectedRepository?.(repositoryFullName) + await refreshLocalContextOptions() + prDrawerController.resetStatus?.() + prDrawerController.syncRepositories() + return true + } catch { + workspacesDrawerController?.setStatus('Could not initialize workspace.', 'error') + return false + } + }, + onCreateWorkspace: async repositoryFilter => { + const normalizedFilter = + typeof repositoryFilter === 'string' ? repositoryFilter.trim() : '' + const repositoryFullName = + normalizedFilter && normalizedFilter !== '__local__' ? normalizedFilter : '' + + try { + await forkWorkspaceFromCurrentState?.(repositoryFullName) + prDrawerController.clearSelectedRepositoryActivePrContext?.({ + resetForm: false, + }) + + if (repositoryFullName) { + setCurrentSelectedRepository?.(repositoryFullName) + } else { + clearCurrentSelectedRepository?.() } + await refreshLocalContextOptions() + prDrawerController.resetStatus?.() + prDrawerController.syncRepositories() + return true + } catch { + workspacesDrawerController?.setStatus('Could not create workspace.', 'error') + return false + } + }, + onOpenSelected: async workspaceId => { + try { const record = await workspaceStorage.getWorkspaceById(workspaceId) if (!record) { await refreshLocalContextOptions() workspacesDrawerController?.setStatus( - 'Stored local context no longer exists.', + 'Stored workspace no longer exists.', 'error', ) return false @@ -381,7 +494,7 @@ const initializeGitHubWorkflows = ({ return applied } catch { workspacesDrawerController?.setStatus( - 'Could not load selected local context.', + 'Could not load selected workspace.', 'error', ) return false @@ -389,7 +502,7 @@ const initializeGitHubWorkflows = ({ }, onRemoveSelected: async workspaceId => { confirmAction({ - title: 'Remove stored local context?', + title: 'Remove stored workspace?', copy: 'This removes only local workspace metadata and editor content from this browser.', confirmButtonText: 'Remove', onConfirm: () => { @@ -403,13 +516,13 @@ const initializeGitHubWorkflows = ({ await refreshLocalContextOptions() workspacesDrawerController?.setStatus( - 'Removed stored local context.', + 'Removed stored workspace.', 'neutral', ) }) .catch(() => { workspacesDrawerController?.setStatus( - 'Could not remove stored local context.', + 'Could not remove stored workspace.', 'error', ) }) @@ -474,36 +587,6 @@ const initializeGitHubWorkflows = ({ }) }) - githubPrContextDisconnect?.addEventListener('click', () => { - if (!githubAiContextState.activePrContext) { - return - } - - const activePrReference = formatActivePrReference( - githubAiContextState.activePrContext, - ) - const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' - - confirmAction({ - title: 'Disconnect PR context?', - copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`, - confirmButtonText: 'Disconnect', - onConfirm: () => { - const result = prDrawerController.disconnectActivePrContext() - const reference = result?.reference - setStatus( - reference - ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.` - : 'Disconnected PR context. Pull request remains open on GitHub.', - 'neutral', - ) - if (typeof onPrContextDisconnected === 'function') { - onPrContextDisconnected(result) - } - }, - }) - }) - return { chatDrawerController, prDrawerController, diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js index e193d44..3529438 100644 --- a/src/modules/app-core/workspace-context-controller.js +++ b/src/modules/app-core/workspace-context-controller.js @@ -7,6 +7,8 @@ const createWorkspaceContextController = ({ setActiveWorkspaceCreatedAt, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, + cancelPendingWorkspaceSave, setIsApplyingWorkspaceSnapshot, ensureWorkspaceTabsShape, githubPrBaseBranch, @@ -27,6 +29,7 @@ const createWorkspaceContextController = ({ maybeRender, setStatus, toWorkspaceRecordKey, + beginWorkspaceLoadTransaction, getHeadBranchValue, }) => { const toWorkspacePrContextState = value => @@ -64,12 +67,15 @@ const createWorkspaceContextController = ({ return false } + if (typeof beginWorkspaceLoadTransaction === 'function') { + beginWorkspaceLoadTransaction() + } setIsApplyingWorkspaceSnapshot(true) + if (typeof cancelPendingWorkspaceSave === 'function') { + cancelPendingWorkspaceSave() + } try { - setActiveWorkspaceRecordId(workspace.id) - setActiveWorkspaceCreatedAt(workspace.createdAt ?? null) - if (typeof setWorkspacePrContextState === 'function') { const nextPrContextState = typeof workspace.prContextState === 'string' && workspace.prContextState.trim() @@ -86,6 +92,15 @@ const createWorkspaceContextController = ({ setWorkspacePrNumber(nextPrNumber) } + if (typeof setWorkspaceScopeMarker === 'function') { + const nextScope = + typeof workspace.workspaceScope === 'string' && + workspace.workspaceScope.trim().toLowerCase() === 'repository' + ? 'repository' + : 'local' + setWorkspaceScopeMarker(nextScope) + } + const nextTabs = ensureWorkspaceTabsShape(workspace.tabs) if (typeof workspace.base === 'string' && githubPrBaseBranch) { githubPrBaseBranch.value = workspace.base @@ -99,6 +114,9 @@ const createWorkspaceContextController = ({ githubPrTitle.value = workspace.prTitle } + setActiveWorkspaceRecordId(workspace.id) + setActiveWorkspaceCreatedAt(workspace.createdAt ?? null) + workspaceTabsState.replaceTabs({ tabs: nextTabs, activeTabId: resolveWorkspaceActiveTabId({ @@ -136,16 +154,23 @@ const createWorkspaceContextController = ({ return true } finally { + await new Promise(resolve => { + setTimeout(resolve, 0) + }) setIsApplyingWorkspaceSnapshot(false) } } const loadPreferredWorkspaceContext = async () => { const selectedRepository = getCurrentSelectedRepository() - const options = await listLocalContextRecords({ + let options = await listLocalContextRecords({ includeAllRepositories: !selectedRepository, }) + if (selectedRepository && (!Array.isArray(options) || options.length === 0)) { + options = await listLocalContextRecords({ includeAllRepositories: true }) + } + await refreshLocalContextOptions({ includeAllRepositories: true }) if (!Array.isArray(options) || options.length === 0) { diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js index 587ddf5..53e0ce1 100644 --- a/src/modules/app-core/workspace-controllers-setup.js +++ b/src/modules/app-core/workspace-controllers-setup.js @@ -17,6 +17,7 @@ const createWorkspaceControllersSetup = ({ setActiveWorkspaceCreatedAt, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, getCurrentSelectedRepository, getActiveWorkspaceRecordId, setIsApplyingWorkspaceSnapshot, @@ -76,6 +77,14 @@ const createWorkspaceControllersSetup = ({ }) => { let workspaceTabsRenderer = null let workspaceTabMutationsController = null + let activeWorkspaceLoadTransactionId = 0 + + const beginWorkspaceLoadTransaction = () => { + activeWorkspaceLoadTransactionId += 1 + return activeWorkspaceLoadTransactionId + } + + const getActiveWorkspaceLoadTransactionId = () => activeWorkspaceLoadTransactionId const renderWorkspaceTabs = () => workspaceTabsRenderer.renderWorkspaceTabs() @@ -95,6 +104,7 @@ const createWorkspaceControllersSetup = ({ setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, getHasCompletedInitialWorkspaceBootstrap, + getActiveWorkspaceLoadTransactionId, }) const queueWorkspaceSave = options => @@ -103,6 +113,9 @@ const createWorkspaceControllersSetup = ({ const flushWorkspaceSave = async options => workspaceSaveController.flushWorkspaceSave(options) + const cancelPendingWorkspaceSave = () => + workspaceSaveController.cancelPendingWorkspaceSave() + const workspaceTabSelectionController = createWorkspaceTabSelectionController({ toNonEmptyWorkspaceText, workspaceTabsState, @@ -201,6 +214,8 @@ const createWorkspaceControllersSetup = ({ setActiveWorkspaceCreatedAt, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, + cancelPendingWorkspaceSave, setIsApplyingWorkspaceSnapshot, ensureWorkspaceTabsShape, githubPrBaseBranch, @@ -221,6 +236,7 @@ const createWorkspaceControllersSetup = ({ maybeRender: () => maybeRender(), setStatus, toWorkspaceRecordKey, + beginWorkspaceLoadTransaction, getHeadBranchValue: () => typeof githubPrHeadBranch?.value === 'string' ? githubPrHeadBranch.value.trim() @@ -248,6 +264,7 @@ const createWorkspaceControllersSetup = ({ applyWorkspaceRecord, queueWorkspaceSave, flushWorkspaceSave, + cancelPendingWorkspaceSave, setActiveWorkspaceTab, addWorkspaceTab, renderWorkspaceTabs, diff --git a/src/modules/app-core/workspace-editor-helpers.js b/src/modules/app-core/workspace-editor-helpers.js index b59ee29..22b8935 100644 --- a/src/modules/app-core/workspace-editor-helpers.js +++ b/src/modules/app-core/workspace-editor-helpers.js @@ -1,3 +1,5 @@ +import { isTabEditedForDisplay } from './workspace-tab-edited-display.js' + const createWorkspaceEditorHelpers = ({ workspaceTabsState, getTabKind, @@ -59,7 +61,7 @@ const createWorkspaceEditorHelpers = ({ typeof getShouldShowEditedDesign === 'function' ? Boolean(getShouldShowEditedDesign()) : true - const isDirty = shouldShowEditedDesign && Boolean(tab?.isDirty) + const isDirty = shouldShowEditedDesign && isTabEditedForDisplay(tab) dirtyStatusLabel.hidden = !isDirty if (isDirty) { dirtyStatusLabel.removeAttribute('aria-hidden') @@ -149,19 +151,29 @@ const createWorkspaceEditorHelpers = ({ if (getTabKind(tab) === 'styles') { setLoadedStylesTabId(tab.id) - setCssSource(nextContent) - applyStyleLanguage(tab.language) + setSuppressEditorChangeSideEffects(true) + try { + setCssSource(nextContent) + applyStyleLanguage(tab.language) + } finally { + setSuppressEditorChangeSideEffects(false) + } setVisibleEditorPanelForKind('styles') editorPool.activate('styles') } else { setLoadedComponentTabId(tab.id) - setJsxSource(nextContent) - - const stylesTab = - workspaceTabsState.getTab(getLoadedStylesTabId()) ?? - getWorkspaceTabByKind('styles') - if (stylesTab) { - applyStyleLanguage(stylesTab.language) + setSuppressEditorChangeSideEffects(true) + try { + setJsxSource(nextContent) + + const stylesTab = + workspaceTabsState.getTab(getLoadedStylesTabId()) ?? + getWorkspaceTabByKind('styles') + if (stylesTab) { + applyStyleLanguage(stylesTab.language) + } + } finally { + setSuppressEditorChangeSideEffects(false) } setVisibleEditorPanelForKind('component') diff --git a/src/modules/app-core/workspace-pr-session-handoff-controller.js b/src/modules/app-core/workspace-pr-session-handoff-controller.js index 27f5956..4e55b58 100644 --- a/src/modules/app-core/workspace-pr-session-handoff-controller.js +++ b/src/modules/app-core/workspace-pr-session-handoff-controller.js @@ -12,6 +12,7 @@ export const createWorkspacePrSessionHandoffController = ({ getWorkspacePrNumber, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, getActiveWorkspaceCreatedAt, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, @@ -80,6 +81,9 @@ export const createWorkspacePrSessionHandoffController = ({ setWorkspacePrContextState('inactive') setWorkspacePrNumber(null) + if (typeof setWorkspaceScopeMarker === 'function') { + setWorkspaceScopeMarker('local') + } lastKnownPrContextMeta = null if (githubPrHeadBranch) { @@ -134,6 +138,7 @@ export const createWorkspacePrSessionHandoffController = ({ const saved = await workspaceStorage.upsertWorkspace({ ...buildWorkspaceRecordSnapshot({ recordId: localWorkspaceId }), id: localWorkspaceId, + workspaceScope: 'local', workspaceKey: toWorkspaceRecordKey({ repositoryFullName: selectedRepository, headBranch: freshLocalHeadBranch, @@ -288,6 +293,8 @@ export const createWorkspacePrSessionHandoffController = ({ lastModified: now, } + archiveSnapshot.workspaceScope = archiveSnapshot.repo ? 'repository' : 'local' + archiveSnapshot.workspaceKey = toWorkspaceRecordKey({ repositoryFullName: archiveSnapshot.repo, headBranch: archiveSnapshot.head, diff --git a/src/modules/app-core/workspace-save-controller.js b/src/modules/app-core/workspace-save-controller.js index 768ab81..14eb99d 100644 --- a/src/modules/app-core/workspace-save-controller.js +++ b/src/modules/app-core/workspace-save-controller.js @@ -11,15 +11,146 @@ const createWorkspaceSaveController = ({ setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, getHasCompletedInitialWorkspaceBootstrap, + getActiveWorkspaceLoadTransactionId, }) => { + const getCurrentWorkspaceLoadTransactionId = () => + typeof getActiveWorkspaceLoadTransactionId === 'function' + ? getActiveWorkspaceLoadTransactionId() + : 0 + + const canPersistWorkspaceState = () => { + if (getIsApplyingWorkspaceSnapshot()) { + return false + } + + if ( + typeof getHasCompletedInitialWorkspaceBootstrap === 'function' && + !getHasCompletedInitialWorkspaceBootstrap() + ) { + return false + } + + return true + } + + const isStaleSavePayload = payload => { + if (!payload || typeof payload !== 'object') { + return true + } + + if (!canPersistWorkspaceState()) { + return true + } + + const payloadTransactionId = + typeof payload.loadTransactionId === 'number' && + Number.isFinite(payload.loadTransactionId) + ? payload.loadTransactionId + : -1 + + if (payloadTransactionId !== getCurrentWorkspaceLoadTransactionId()) { + return true + } + + const payloadRecordId = toNonEmptyWorkspaceText(payload.id) + const currentActiveRecordId = + typeof getActiveWorkspaceRecordId === 'function' + ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) + : '' + + if ( + payloadRecordId && + currentActiveRecordId && + payloadRecordId !== currentActiveRecordId + ) { + return true + } + + return false + } + + const buildSaveSnapshot = ({ + preserveRecordId = false, + allowDuplicateWorkspaceKey = false, + } = {}) => { + const activeRecordId = + preserveRecordId && typeof getActiveWorkspaceRecordId === 'function' + ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) + : '' + const snapshot = activeRecordId + ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId }) + : buildWorkspaceRecordSnapshot() + + if (allowDuplicateWorkspaceKey) { + snapshot.allowDuplicateWorkspaceKey = true + } + + snapshot.loadTransactionId = getCurrentWorkspaceLoadTransactionId() + return snapshot + } + const workspaceSaver = createDebouncedWorkspaceSaver({ save: async payload => { - const saved = await workspaceStorage.upsertWorkspace(payload) + if (isStaleSavePayload(payload)) { + return null + } + + const payloadRecordId = toNonEmptyWorkspaceText(payload?.id) + if (payloadRecordId) { + const existingRecord = await workspaceStorage.getWorkspaceById(payloadRecordId) + if (existingRecord && typeof existingRecord === 'object') { + const existingWorkspaceKey = toNonEmptyWorkspaceText( + existingRecord.workspaceKey, + ) + const payloadWorkspaceKey = toNonEmptyWorkspaceText(payload?.workspaceKey) + if ( + existingWorkspaceKey && + payloadWorkspaceKey && + existingWorkspaceKey !== payloadWorkspaceKey + ) { + const existingWorkspaceScope = + toNonEmptyWorkspaceText(existingRecord.workspaceScope).toLowerCase() || + 'local' + const payloadWorkspaceScope = + toNonEmptyWorkspaceText(payload?.workspaceScope).toLowerCase() || 'local' + const existingRepository = toNonEmptyWorkspaceText(existingRecord.repo) + const payloadRepository = toNonEmptyWorkspaceText(payload?.repo) + + const isLocalToRepositoryRekey = + existingWorkspaceScope === 'local' && + payloadWorkspaceScope === 'repository' && + !existingRepository && + Boolean(payloadRepository) + + if (!isLocalToRepositoryRekey) { + return null + } + } + } + } + + const { loadTransactionId: _loadTransactionId, ...persistablePayload } = payload + + const allowDuplicateWorkspaceKey = + persistablePayload && typeof persistablePayload === 'object' + ? persistablePayload.allowDuplicateWorkspaceKey === true + : false + const saved = await workspaceStorage.upsertWorkspace(persistablePayload) const normalizedSavedRepo = toNonEmptyWorkspaceText(saved.repo) const normalizedSavedWorkspaceKey = toNonEmptyWorkspaceText(saved.workspaceKey) + const normalizedSavedPrContextState = + toNonEmptyWorkspaceText(saved.prContextState).toLowerCase() || 'inactive' + const hasSavedPrNumber = + typeof saved.prNumber === 'number' && Number.isFinite(saved.prNumber) + const isSavedInactiveWithoutPrNumber = + normalizedSavedPrContextState === 'inactive' && !hasSavedPrNumber - if (normalizedSavedWorkspaceKey) { + if ( + normalizedSavedWorkspaceKey && + !allowDuplicateWorkspaceKey && + !isSavedInactiveWithoutPrNumber + ) { const siblingRecords = normalizedSavedRepo ? await workspaceStorage.listWorkspaces({ repo: normalizedSavedRepo }) : await workspaceStorage.listWorkspaces() @@ -46,10 +177,7 @@ const createWorkspaceSaveController = ({ .filter(Boolean), ) - const isSavedActiveContext = - toNonEmptyWorkspaceText(saved.prContextState).toLowerCase() === 'active' - const hasSavedPrNumber = - typeof saved.prNumber === 'number' && Number.isFinite(saved.prNumber) + const isSavedActiveContext = normalizedSavedPrContextState === 'active' if (isSavedActiveContext && hasSavedPrNumber && normalizedSavedRepo) { for (const record of siblingRecords) { @@ -86,20 +214,24 @@ const createWorkspaceSaveController = ({ ) } - const supersededId = toNonEmptyWorkspaceText(payload?.supersededId) + const supersededId = toNonEmptyWorkspaceText(persistablePayload?.supersededId) if (supersededId && supersededId !== toNonEmptyWorkspaceText(saved.id)) { await workspaceStorage.removeWorkspace(supersededId) } + if (isStaleSavePayload(payload)) { + return saved + } + const currentActiveRecordId = typeof getActiveWorkspaceRecordId === 'function' ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) : '' - const payloadRecordId = toNonEmptyWorkspaceText(payload?.id) + const persistedPayloadRecordId = toNonEmptyWorkspaceText(persistablePayload?.id) const savedRecordId = toNonEmptyWorkspaceText(saved.id) const shouldAdoptSavedAsActive = !currentActiveRecordId || - currentActiveRecordId === payloadRecordId || + currentActiveRecordId === persistedPayloadRecordId || currentActiveRecordId === savedRecordId if (shouldAdoptSavedAsActive) { @@ -117,48 +249,28 @@ const createWorkspaceSaveController = ({ }, }) - const queueWorkspaceSave = ({ preserveRecordId = false } = {}) => { - if (getIsApplyingWorkspaceSnapshot()) { - return - } - - if ( - typeof getHasCompletedInitialWorkspaceBootstrap === 'function' && - !getHasCompletedInitialWorkspaceBootstrap() - ) { + const queueWorkspaceSave = ({ + preserveRecordId = false, + allowDuplicateWorkspaceKey = false, + } = {}) => { + if (!canPersistWorkspaceState()) { return } - const activeRecordId = - preserveRecordId && typeof getActiveWorkspaceRecordId === 'function' - ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) - : '' - const snapshot = activeRecordId - ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId }) - : buildWorkspaceRecordSnapshot() + const snapshot = buildSaveSnapshot({ preserveRecordId, allowDuplicateWorkspaceKey }) setActiveWorkspaceRecordId(snapshot.id) workspaceSaver.queue(snapshot) } - const flushWorkspaceSave = async ({ preserveRecordId = false } = {}) => { - if (getIsApplyingWorkspaceSnapshot()) { - return - } - - if ( - typeof getHasCompletedInitialWorkspaceBootstrap === 'function' && - !getHasCompletedInitialWorkspaceBootstrap() - ) { + const flushWorkspaceSave = async ({ + preserveRecordId = false, + allowDuplicateWorkspaceKey = false, + } = {}) => { + if (!canPersistWorkspaceState()) { return } - const activeRecordId = - preserveRecordId && typeof getActiveWorkspaceRecordId === 'function' - ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) - : '' - const snapshot = activeRecordId - ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId }) - : buildWorkspaceRecordSnapshot() + const snapshot = buildSaveSnapshot({ preserveRecordId, allowDuplicateWorkspaceKey }) setActiveWorkspaceRecordId(snapshot.id) await workspaceSaver.flushNow(snapshot) } @@ -194,12 +306,17 @@ const createWorkspaceSaveController = ({ element.addEventListener('blur', flush) } + const cancelPendingWorkspaceSave = () => { + workspaceSaver?.dispose() + } + const dispose = () => { workspaceSaver?.dispose() } return { bindWorkspaceMetadataPersistence, + cancelPendingWorkspaceSave, dispose, flushWorkspaceSave, queueWorkspaceSave, diff --git a/src/modules/app-core/workspace-scope-fork-actions.js b/src/modules/app-core/workspace-scope-fork-actions.js new file mode 100644 index 0000000..78b69bb --- /dev/null +++ b/src/modules/app-core/workspace-scope-fork-actions.js @@ -0,0 +1,183 @@ +const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') + +const createForkedHeadBranchName = ({ currentHead, toNonEmptyWorkspaceText }) => { + const normalizedHead = toNonEmptyWorkspaceText(currentHead) + const baseHead = normalizedHead || `feat/component-${Date.now().toString(36)}` + const suffix = Math.random().toString(36).slice(2, 6) + return `${baseHead}-${suffix}` +} + +export const createWorkspaceScopeForkActions = ({ + toNonEmptyWorkspaceText, + workspaceStorage, + flushWorkspaceSave, + refreshLocalContextOptions, + createWorkspaceRecordId, + buildWorkspaceRecordSnapshot, + toWorkspaceRecordKey, + getWorkspacePrContextState, + setWorkspacePrContextState, + setWorkspacePrNumber, + getActiveWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + getWorkspaceRepositoryFullName, + setWorkspaceRepositoryFullName, + setWorkspaceScopeMarker, + setHeadBranchValue, + setPrTitleValue, +}) => { + const syncActiveWorkspaceRepositoryScope = async ( + repositoryFullName, + { rekeyRecord = false } = {}, + ) => { + if ( + toNonEmptyWorkspaceText(getWorkspacePrContextState()).toLowerCase() !== 'inactive' + ) { + return + } + + const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) + if (!activeWorkspaceRecordId) { + return + } + + if (rekeyRecord) { + await flushWorkspaceSave({ preserveRecordId: true }) + setActiveWorkspaceRecordId('') + setActiveWorkspaceCreatedAt(null) + } + + const normalizedRepositoryFullName = toNonEmptyWorkspaceText(repositoryFullName) + setWorkspaceRepositoryFullName(normalizedRepositoryFullName) + setWorkspaceScopeMarker( + toWorkspaceScopeMarker(normalizedRepositoryFullName ? 'repository' : 'local'), + ) + await flushWorkspaceSave({ preserveRecordId: !rekeyRecord }) + } + + const setActiveWorkspaceScopeMarker = async nextScope => { + if ( + toNonEmptyWorkspaceText(getWorkspacePrContextState()).toLowerCase() !== 'inactive' + ) { + return + } + + const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) + if (!activeWorkspaceRecordId) { + return + } + + const normalizedScope = toWorkspaceScopeMarker(nextScope) + + const activeRecord = await workspaceStorage.getWorkspaceById(activeWorkspaceRecordId) + if (!activeRecord) { + return + } + + const hasPrNumber = + typeof activeRecord.prNumber === 'number' && Number.isFinite(activeRecord.prNumber) + if (hasPrNumber) { + return + } + + const currentScope = + typeof activeRecord.workspaceScope === 'string' + ? activeRecord.workspaceScope.trim().toLowerCase() + : '' + + if (currentScope === normalizedScope) { + return + } + + setWorkspaceScopeMarker(normalizedScope) + + await workspaceStorage.upsertWorkspace({ + ...activeRecord, + workspaceScope: normalizedScope, + lastModified: Date.now(), + }) + + await refreshLocalContextOptions() + } + + const forkWorkspaceFromCurrentState = async repositoryFullName => { + const normalizedTargetRepository = toNonEmptyWorkspaceText(repositoryFullName) + const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId()) + + if (activeWorkspaceRecordId) { + let sourceRepositoryFullName = toNonEmptyWorkspaceText( + getWorkspaceRepositoryFullName(), + ) + + try { + const activeWorkspaceRecord = await workspaceStorage.getWorkspaceById( + activeWorkspaceRecordId, + ) + sourceRepositoryFullName = toNonEmptyWorkspaceText(activeWorkspaceRecord?.repo) + } catch { + /* Save path continues even if source record lookup fails. */ + } + + setWorkspaceRepositoryFullName(sourceRepositoryFullName) + await flushWorkspaceSave({ + preserveRecordId: true, + allowDuplicateWorkspaceKey: true, + }) + } + + setWorkspaceRepositoryFullName(normalizedTargetRepository) + setWorkspaceScopeMarker( + toWorkspaceScopeMarker(normalizedTargetRepository ? 'repository' : 'local'), + ) + setWorkspacePrContextState('inactive') + setWorkspacePrNumber(null) + if (typeof setPrTitleValue === 'function') { + setPrTitleValue('') + } + + const now = Date.now() + const nextRecordId = createWorkspaceRecordId() + const snapshot = buildWorkspaceRecordSnapshot({ recordId: nextRecordId }) + const forkedHeadBranch = createForkedHeadBranchName({ + currentHead: snapshot.head, + toNonEmptyWorkspaceText, + }) + + setHeadBranchValue(forkedHeadBranch) + + const saved = await workspaceStorage.upsertWorkspace({ + ...snapshot, + id: nextRecordId, + supersededId: '', + workspaceScope: normalizedTargetRepository ? 'repository' : 'local', + workspaceKey: toWorkspaceRecordKey({ + repositoryFullName: normalizedTargetRepository, + headBranch: forkedHeadBranch, + }), + repo: normalizedTargetRepository, + head: forkedHeadBranch, + prTitle: '', + prContextState: 'inactive', + prNumber: null, + createdAt: now, + lastModified: now, + }) + + const savedId = toNonEmptyWorkspaceText(saved?.id) || nextRecordId + const savedCreatedAt = + typeof saved?.createdAt === 'number' && Number.isFinite(saved.createdAt) + ? saved.createdAt + : now + setActiveWorkspaceRecordId(savedId) + setActiveWorkspaceCreatedAt(savedCreatedAt) + + await refreshLocalContextOptions() + } + + return { + forkWorkspaceFromCurrentState, + setActiveWorkspaceScopeMarker, + syncActiveWorkspaceRepositoryScope, + } +} diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js index c0e1e18..aa1cce0 100644 --- a/src/modules/app-core/workspace-sync-controller.js +++ b/src/modules/app-core/workspace-sync-controller.js @@ -10,20 +10,28 @@ const createWorkspaceSyncController = ({ hasTabCommittedSyncState, getJsxSource, getCssSource, - getWorkspaceTabByKind, queueWorkspaceSave, resolveWorkspaceRecordIdentity, getWorkspaceContextSnapshot, + getWorkspaceScopeMarker, getActiveWorkspaceRecordId, getActiveWorkspaceCreatedAt, getRenderModeValue, normalizeRenderMode, }) => { + const resolveCanonicalDirtyState = ({ tab, content }) => { + const syncedContent = toWorkspaceSyncedContent(tab?.syncedContent) + if (syncedContent !== null) { + return content !== syncedContent + } + + return Boolean(tab?.isDirty) + } + const buildWorkspaceTabsSnapshot = () => { const activeTabId = workspaceTabsState.getActiveTabId() return workspaceTabsState.getTabs().map(tab => { const currentPath = toNonEmptyWorkspaceText(tab.path) - const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' const currentContent = tab.id === activeTabId @@ -35,15 +43,18 @@ const createWorkspaceSyncController = ({ : '' const normalizedPath = normalizeWorkspacePathValue(currentPath) - const targetPrFilePath = isPrimaryEditorTab - ? normalizedPath || null - : normalizedPath || getTabTargetPrFilePath(tab) || null + const targetPrFilePath = normalizedPath || getTabTargetPrFilePath(tab) || null + const canonicalDirtyState = resolveCanonicalDirtyState({ + tab, + content: currentContent, + }) return { ...tab, path: currentPath, content: currentContent, syncedContent: toWorkspaceSyncedContent(tab?.syncedContent), + isDirty: canonicalDirtyState, targetPrFilePath, isActive: activeTabId === tab.id, lastModified: Date.now(), @@ -75,11 +86,8 @@ const createWorkspaceSyncController = ({ let updatedTabCount = 0 const activeTabId = workspaceTabsState.getActiveTabId() const nextTabs = workspaceTabsState.getTabs().map(tab => { - const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' const normalizedPath = normalizeWorkspacePathValue(tab.path) - const candidatePaths = isPrimaryEditorTab - ? [normalizedPath, getTabTargetPrFilePath(tab)].filter(Boolean) - : [normalizedPath].filter(Boolean) + const candidatePaths = [normalizedPath, getTabTargetPrFilePath(tab)].filter(Boolean) const matchedPath = candidatePaths.find(path => updatesByPath.has(path)) if (!matchedPath) { @@ -91,7 +99,7 @@ const createWorkspaceSyncController = ({ return { ...tab, - targetPrFilePath: normalizedPath || (isPrimaryEditorTab ? matchedPath : null), + targetPrFilePath: normalizedPath || matchedPath, syncedContent: typeof tab?.content === 'string' ? tab.content : '', isDirty: false, syncedAt: now, @@ -121,12 +129,6 @@ const createWorkspaceSyncController = ({ const currentPaths = new Set( snapshotTabs.map(tab => normalizeWorkspacePathValue(tab?.path)).filter(Boolean), ) - const primaryTabPaths = new Set( - snapshotTabs - .filter(tab => tab?.id === 'component' || tab?.id === 'styles') - .map(tab => normalizeWorkspacePathValue(tab?.path)) - .filter(Boolean), - ) for (const tab of snapshotTabs) { const shouldCommitTab = includeAllWorkspaceFiles @@ -136,17 +138,12 @@ const createWorkspaceSyncController = ({ continue } - const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' const normalizedPath = normalizeWorkspacePathValue(tab?.path) const path = normalizedPath || getTabTargetPrFilePath(tab) || '' if (!path) { continue } - if (!isPrimaryEditorTab && primaryTabPaths.has(path)) { - continue - } - dedupedByPath.set(path, { path, content: typeof tab?.content === 'string' ? tab.content : '', @@ -163,6 +160,7 @@ const createWorkspaceSyncController = ({ previousPath !== normalizedPath if ( + !includeAllWorkspaceFiles && isCommittedRename && !currentPaths.has(previousPath) && !dedupedByPath.has(previousPath) @@ -182,78 +180,69 @@ const createWorkspaceSyncController = ({ } const getEditorSyncTargets = () => { - const tabTargets = [] - const primaryTabIdByKind = { - component: 'component', - styles: 'styles', - } + const dedupedByPath = new Map() + const snapshotTabs = buildWorkspaceTabsSnapshot() - for (const kind of ['component', 'styles']) { - const primaryTabId = primaryTabIdByKind[kind] - const tab = workspaceTabsState.getTab(primaryTabId) ?? getWorkspaceTabByKind(kind) + for (const tab of snapshotTabs) { const path = normalizeWorkspacePathValue(tab?.path) || getTabTargetPrFilePath(tab) || '' - if (!path) { continue } - tabTargets.push({ kind, path }) + dedupedByPath.set(path, { + path, + kind: getTabKind(tab), + tabId: toNonEmptyWorkspaceText(tab?.id), + }) } + const tabTargets = [...dedupedByPath.values()] + return { tabTargets } } const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => { - const targetsByKind = new Map() + const targetContentByPath = new Map() const normalizedTargets = Array.isArray(tabTargets) ? tabTargets : [] for (const target of normalizedTargets) { - const kind = toNonEmptyWorkspaceText(target?.kind) const normalizedPath = normalizeWorkspacePathValue(target?.path) - if (!kind || !normalizedPath) { + const content = typeof target?.content === 'string' ? target.content : null + if (!normalizedPath || content === null) { continue } - targetsByKind.set(kind, normalizedPath) + targetContentByPath.set(normalizedPath, content) } - if (targetsByKind.size === 0) { + if (targetContentByPath.size === 0) { return 0 } const now = Date.now() let updatedTabCount = 0 const activeTabId = workspaceTabsState.getActiveTabId() - const componentSource = getJsxSource() - const stylesSource = getCssSource() const nextTabs = workspaceTabsState.getTabs().map(tab => { - const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' - if (!isPrimaryEditorTab) { - return tab - } - - const tabKind = getTabKind(tab) - const expectedPath = targetsByKind.get(tabKind) - if (!expectedPath) { - return tab - } - const candidatePaths = [ normalizeWorkspacePathValue(tab.path), getTabTargetPrFilePath(tab), ].filter(Boolean) - const matchedPath = candidatePaths.find(path => path === expectedPath) + const matchedPath = candidatePaths.find(path => targetContentByPath.has(path)) if (!matchedPath) { return tab } - const syncedContent = tabKind === 'styles' ? stylesSource : componentSource + const syncedContent = targetContentByPath.get(matchedPath) + if (typeof syncedContent !== 'string') { + return tab + } + updatedTabCount += 1 return { ...tab, - targetPrFilePath: expectedPath, + targetPrFilePath: matchedPath, content: syncedContent, syncedContent, isDirty: false, @@ -292,9 +281,19 @@ const createWorkspaceSyncController = ({ ? context.prContextState.trim() : 'inactive' + const requestedWorkspaceScope = + typeof getWorkspaceScopeMarker === 'function' + ? getWorkspaceScopeMarker() + : context.repositoryFullName + ? 'repository' + : 'local' + const normalizedWorkspaceScope = + requestedWorkspaceScope === 'repository' ? 'repository' : 'local' + return { id: identity.id, supersededId: identity.supersededId, + workspaceScope: normalizedWorkspaceScope, workspaceKey: toWorkspaceRecordKey({ repositoryFullName: context.repositoryFullName, headBranch: context.headBranch, diff --git a/src/modules/app-core/workspace-tab-edited-display.js b/src/modules/app-core/workspace-tab-edited-display.js new file mode 100644 index 0000000..b2e628b --- /dev/null +++ b/src/modules/app-core/workspace-tab-edited-display.js @@ -0,0 +1,21 @@ +const isTabEditedForDisplay = tab => { + if (!tab || typeof tab !== 'object' || tab.isDirty !== true) { + return false + } + + const nextContent = typeof tab.content === 'string' ? tab.content : '' + const syncedContent = typeof tab.syncedContent === 'string' ? tab.syncedContent : null + + if (syncedContent !== null) { + return nextContent !== syncedContent + } + + const hasSyncTimestamp = + typeof tab.syncedAt === 'number' && Number.isFinite(tab.syncedAt) && tab.syncedAt > 0 + const hasSyncSha = + typeof tab.lastSyncedRemoteSha === 'string' && tab.lastSyncedRemoteSha.trim() + + return Boolean(hasSyncTimestamp || hasSyncSha) +} + +export { isTabEditedForDisplay } diff --git a/src/modules/app-core/workspace-tabs-renderer.js b/src/modules/app-core/workspace-tabs-renderer.js index 3e8d59c..bf736c1 100644 --- a/src/modules/app-core/workspace-tabs-renderer.js +++ b/src/modules/app-core/workspace-tabs-renderer.js @@ -1,3 +1,5 @@ +import { isTabEditedForDisplay } from './workspace-tab-edited-display.js' + const createWorkspaceTabsRenderer = ({ workspaceTabsStrip, workspaceTabsState, @@ -53,7 +55,7 @@ const createWorkspaceTabsRenderer = ({ for (const tab of tabs) { const isActive = tab.id === activeTabId const isRenaming = getWorkspaceTabRenameState().tabId === tab.id - const isEdited = shouldShowEditedDesign && tab.isDirty + const isEdited = shouldShowEditedDesign && isTabEditedForDisplay(tab) const editedSuffix = isEdited ? ' (Edited)' : '' const tabContainer = document.createElement('li') tabContainer.className = 'workspace-tab' @@ -235,7 +237,7 @@ const createWorkspaceTabsRenderer = ({ tabContainer.append(metaBadge) } - if (shouldShowEditedDesign && tab.isDirty) { + if (shouldShowEditedDesign && isTabEditedForDisplay(tab)) { const dirtyBadge = document.createElement('span') dirtyBadge.className = 'workspace-tab__dirty-indicator' dirtyBadge.setAttribute('aria-hidden', 'true') diff --git a/src/modules/constants.js b/src/modules/constants.js deleted file mode 100644 index 624e03a..0000000 --- a/src/modules/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const repositoryStarterSelectionIdPrefix = '__create_repository_context__:' diff --git a/src/modules/github/api/editor-content.js b/src/modules/github/api/editor-content.js index add2b1e..8c053ca 100644 --- a/src/modules/github/api/editor-content.js +++ b/src/modules/github/api/editor-content.js @@ -1,5 +1,9 @@ import { buildRepoApiUrl, requestGitHubJson } from './core.js' -import { createBranchReference, getBranchReferenceSha } from './repository-files.js' +import { + createBranchReference, + getBranchReferenceSha, + getRepositoryFileMetadata, +} from './repository-files.js' import { createRepositoryPullRequest } from './pull-requests.js' const normalizeFileUpdatePath = value => @@ -138,6 +142,14 @@ const createRepositoryTree = async ({ return treeSha } +const isBadObjectStateError = error => { + if (!(error instanceof Error)) { + return false + } + + return error.message.toLowerCase().includes('badobjectstate') +} + const createRepositoryCommit = async ({ token, owner, @@ -209,14 +221,75 @@ const commitFilesToExistingBranchWithGitDatabaseApi = async ({ commitSha: headCommitSha, signal, }) - const treeSha = await createRepositoryTree({ - token, - owner, - repo, - baseTreeSha, - files: uniqueFiles, - signal, - }) + const hasDeleteEntries = uniqueFiles.some(file => file.deleted === true) + + let committedFiles = uniqueFiles + let treeSha + + if (hasDeleteEntries) { + const deleteCandidates = committedFiles.filter(file => file.deleted === true) + const existingDeletePaths = new Set( + ( + await Promise.all( + deleteCandidates.map(async file => { + const existingFile = await getRepositoryFileMetadata({ + token, + owner, + repo, + path: file.path, + ref: branch, + signal, + }) + + return existingFile?.sha ? file.path : null + }), + ) + ).filter(Boolean), + ) + + committedFiles = committedFiles.filter(file => { + if (file.deleted !== true) { + return true + } + + return existingDeletePaths.has(file.path) + }) + + if (committedFiles.length === 0) { + return [] + } + } + + try { + treeSha = await createRepositoryTree({ + token, + owner, + repo, + baseTreeSha, + files: committedFiles, + signal, + }) + } catch (error) { + if (!hasDeleteEntries || !isBadObjectStateError(error)) { + throw error + } + + const nonDeleteFiles = committedFiles.filter(file => file.deleted !== true) + if (nonDeleteFiles.length === 0) { + throw error + } + + committedFiles = nonDeleteFiles + treeSha = await createRepositoryTree({ + token, + owner, + repo, + baseTreeSha, + files: committedFiles, + signal, + }) + } + const commitSha = await createRepositoryCommit({ token, owner, @@ -235,7 +308,7 @@ const commitFilesToExistingBranchWithGitDatabaseApi = async ({ signal, }) - return uniqueFiles.map(file => ({ + return committedFiles.map(file => ({ path: file.path, commitSha, created: null, @@ -260,43 +333,28 @@ const createUniqueBranchReference = async ({ headBranch, baseSha, signal, - attempt = 0, }) => { - const candidateBranch = attempt === 0 ? headBranch : `${headBranch}-${attempt + 1}` - try { await createBranchReference({ token, owner, repo, - branch: candidateBranch, + branch: headBranch, sha: baseSha, signal, }) - return candidateBranch + return headBranch } catch (error) { if (!isReferenceAlreadyExistsError(error)) { throw error } - if (attempt >= 4) { - throw new Error( - `Branch ${headBranch} already exists. Choose another branch name and retry.`, - { - cause: error, - }, - ) - } - - return createUniqueBranchReference({ - token, - owner, - repo, - headBranch, - baseSha, - signal, - attempt: attempt + 1, - }) + throw new Error( + `Branch ${headBranch} already exists. Choose another branch name and retry.`, + { + cause: error, + }, + ) } } diff --git a/src/modules/github/pr/drawer/common.js b/src/modules/github/pr/drawer/common.js index 4578483..3bc542c 100644 --- a/src/modules/github/pr/drawer/common.js +++ b/src/modules/github/pr/drawer/common.js @@ -2,7 +2,7 @@ const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop' const supportedRenderModes = new Set(['dom', 'react']) const supportedStyleModes = new Set(['css', 'module', 'less', 'sass']) -const supportedPrContextStates = new Set(['inactive', 'active', 'disconnected', 'closed']) +const supportedPrContextStates = new Set(['inactive', 'active', 'closed']) const toSafeText = value => (typeof value === 'string' ? value.trim() : '') @@ -26,6 +26,10 @@ const normalizeStyleMode = value => { const normalizePrContextState = value => { const state = toSafeText(value).toLowerCase() + if (state === 'disconnected') { + return 'inactive' + } + return supportedPrContextStates.has(state) ? state : 'inactive' } diff --git a/src/modules/github/pr/drawer/controller/context-sync.js b/src/modules/github/pr/drawer/controller/context-sync.js index 8124330..285957d 100644 --- a/src/modules/github/pr/drawer/controller/context-sync.js +++ b/src/modules/github/pr/drawer/controller/context-sync.js @@ -51,24 +51,32 @@ export const createContextSyncHandlers = ({ const tabSyncTargets = Array.isArray(syncTargets?.tabTargets) ? syncTargets.tabTargets : [] - const componentSyncPath = toSafeText( - tabSyncTargets.find(target => toSafeText(target?.kind) === 'component')?.path, - ) - const stylesSyncPath = toSafeText( - tabSyncTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, - ) - - if (!componentSyncPath || !stylesSyncPath) { + const dedupedByPath = new Map() + + for (const target of tabSyncTargets) { + const kind = toSafeText(target?.kind) + const path = toSafeText(target?.path) + if (!path) { + continue + } + + dedupedByPath.set(path, { kind, path }) + } + + const normalizedTabSyncTargets = [...dedupedByPath.values()] + + if (normalizedTabSyncTargets.length === 0) { state.lastActiveContentSyncKey = '' abortPendingActiveContentSyncRequest() return } + normalizedTabSyncTargets.sort((left, right) => left.path.localeCompare(right.path)) + const syncKey = [ repositoryFullName, activeContext.headBranch, - componentSyncPath, - stylesSyncPath, + ...normalizedTabSyncTargets.map(target => target.path), String(activeContext.pullRequestNumber ?? ''), ].join('|') @@ -94,10 +102,7 @@ export const createContextSyncHandlers = ({ repository, activeContext, syncTargets: { - tabTargets: [ - { kind: 'component', path: componentSyncPath }, - { kind: 'styles', path: stylesSyncPath }, - ], + tabTargets: normalizedTabSyncTargets, }, signal: abortController.signal, }) diff --git a/src/modules/github/pr/drawer/controller/create-controller.js b/src/modules/github/pr/drawer/controller/create-controller.js index 698e374..5fec1ed 100644 --- a/src/modules/github/pr/drawer/controller/create-controller.js +++ b/src/modules/github/pr/drawer/controller/create-controller.js @@ -52,6 +52,7 @@ export const createGitHubPrDrawer = ({ getWritableRepositories, getFileCommits, getEditorSyncTargets, + persistWorkspaceMetadataOnSubmit, getTopLevelDeclarations, getRenderMode, getStyleMode, @@ -79,6 +80,7 @@ export const createGitHubPrDrawer = ({ pendingContextVerifyRequestKey: '', pendingContextVerifyPromise: null, lastSyncedRepositoryFullName: '', + lastSyncedActivePrContextKey: '', lastActiveContentSyncKey: '', baseBranchesByRepository: new Map(), activePrContextByRepository: new Map(), @@ -254,6 +256,7 @@ export const createGitHubPrDrawer = ({ prTitleInput, includeAppWrapperToggle, getFileCommits, + persistWorkspaceMetadataOnSubmit, getTopLevelDeclarations, confirmBeforeSubmit, onPullRequestOpened, @@ -334,6 +337,9 @@ export const createGitHubPrDrawer = ({ return false } + contextHandlers.abortPendingContextVerifyRequest() + contextHandlers.abortPendingActiveContentSyncRequest() + setRepositoryActivePrContext({ repositoryFullName: targetRepositoryFullName, activeContext, diff --git a/src/modules/github/pr/drawer/controller/public-actions.js b/src/modules/github/pr/drawer/controller/public-actions.js index 9daca47..acca1b7 100644 --- a/src/modules/github/pr/drawer/controller/public-actions.js +++ b/src/modules/github/pr/drawer/controller/public-actions.js @@ -44,25 +44,6 @@ export const createPublicActions = ({ } return { - disconnectActivePrContext: () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - if (!repositoryFullName) { - return { reference: '' } - } - - const activeContext = getCurrentActivePrContext() - clearSelectedRepositoryActivePrContext() - - return { - reference: formatActivePrReference(activeContext), - pullRequestNumber: - typeof activeContext?.pullRequestNumber === 'number' && - Number.isFinite(activeContext.pullRequestNumber) - ? activeContext.pullRequestNumber - : null, - } - }, clearActivePrContext: () => { clearSelectedRepositoryActivePrContext({ resetForm: true }) }, diff --git a/src/modules/github/pr/drawer/controller/repository-form.js b/src/modules/github/pr/drawer/controller/repository-form.js index 95ae787..365f31a 100644 --- a/src/modules/github/pr/drawer/controller/repository-form.js +++ b/src/modules/github/pr/drawer/controller/repository-form.js @@ -244,6 +244,22 @@ export const createRepositoryFormHandlers = ({ Boolean(repositoryFullName) && repositoryFullName !== state.lastSyncedRepositoryFullName const activeContext = getCurrentActivePrContext() + const activeContextSyncKey = activeContext + ? [ + toSafeText(activeContext.baseBranch), + sanitizeBranchPart(activeContext.headBranch), + toSafeText(activeContext.prTitle), + String( + typeof activeContext.pullRequestNumber === 'number' && + Number.isFinite(activeContext.pullRequestNumber) + ? activeContext.pullRequestNumber + : '', + ), + toSafeText(activeContext.pullRequestUrl), + ].join('|') + : '' + const activeContextChanged = + activeContextSyncKey !== state.lastSyncedActivePrContextKey const baseBranch = toSafeText(activeContext?.baseBranch) || @@ -257,7 +273,13 @@ export const createRepositoryFormHandlers = ({ const currentHeadBranch = toSafeText(headBranchInput.value) if (activeHeadBranch) { - if (resetAll || resetBranch || repositoryChanged || !currentHeadBranch) { + if ( + resetAll || + resetBranch || + repositoryChanged || + activeContextChanged || + !currentHeadBranch + ) { setElementValueAndPersist(headBranchInput, activeHeadBranch) } } else if (!currentHeadBranch) { @@ -266,13 +288,23 @@ export const createRepositoryFormHandlers = ({ } if (prTitleInput instanceof HTMLInputElement) { - if (resetAll || repositoryChanged || !toSafeText(prTitleInput.value)) { + if ( + resetAll || + repositoryChanged || + activeContextChanged || + !toSafeText(prTitleInput.value) + ) { setElementValueAndPersist(prTitleInput, toSafeText(activeContext?.prTitle)) } } if (prBodyInput instanceof HTMLTextAreaElement) { - if (resetAll || repositoryChanged || !toSafeText(prBodyInput.value)) { + if ( + resetAll || + repositoryChanged || + activeContextChanged || + !toSafeText(prBodyInput.value) + ) { prBodyInput.value = typeof activeContext?.prBody === 'string' ? activeContext.prBody : '' } @@ -289,6 +321,7 @@ export const createRepositoryFormHandlers = ({ } state.lastSyncedRepositoryFullName = repositoryFullName + state.lastSyncedActivePrContextKey = activeContextSyncKey } const refreshContextUi = () => { diff --git a/src/modules/github/pr/drawer/controller/run-submit.js b/src/modules/github/pr/drawer/controller/run-submit.js index 2b6f6fd..d126fa4 100644 --- a/src/modules/github/pr/drawer/controller/run-submit.js +++ b/src/modules/github/pr/drawer/controller/run-submit.js @@ -10,6 +10,7 @@ export const createRunSubmit = ({ prTitleInput, includeAppWrapperToggle, getFileCommits, + persistWorkspaceMetadataOnSubmit, getTopLevelDeclarations, confirmBeforeSubmit, onPullRequestOpened, @@ -188,7 +189,32 @@ export const createRunSubmit = ({ }), ) - const submitRequest = () => { + const submitRequest = async () => { + if (typeof persistWorkspaceMetadataOnSubmit === 'function') { + try { + await persistWorkspaceMetadataOnSubmit({ + isPushCommitMode, + repository: repositoryLabel, + baseBranch: targetBaseBranch, + headBranch: targetHeadBranch, + prTitle: targetPrTitle, + prBody: targetPrBody, + }) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Could not persist workspace metadata before submit.' + setStatus( + isPushCommitMode + ? `Push commit blocked: ${message}` + : `Open PR blocked: ${message}`, + 'error', + ) + return + } + } + state.pendingAbortController?.abort() const abortController = new AbortController() state.pendingAbortController = abortController @@ -223,8 +249,25 @@ export const createRunSubmit = ({ }) void Promise.resolve(runRequest) - .then(result => { + .then(async result => { if (isPushCommitMode) { + const committedFileUpdates = Array.isArray(result) ? result : [] + const attemptedNonDeleteUpdates = fileUpdates.filter( + update => + typeof update?.path === 'string' && + update.path.trim().length > 0 && + update?.deleted !== true, + ) + + if ( + attemptedNonDeleteUpdates.length > 0 && + committedFileUpdates.length === 0 + ) { + throw new Error( + 'Push did not return committed file updates. Workspace sync baseline was not updated.', + ) + } + const compactPullRequestReference = formatActivePrReference(activeContext) const pullRequestUrl = toSafeText(activeContext?.pullRequestUrl) const pullRequestTitle = toSafeText(activeContext?.prTitle) @@ -239,11 +282,13 @@ export const createRunSubmit = ({ : `Commit pushed to ${targetHeadBranch}.`, 'ok', ) - onPullRequestCommitPushed?.({ - repositoryFullName: repositoryLabel, - branch: targetHeadBranch, - fileUpdates: Array.isArray(result) ? result : [], - }) + if (typeof onPullRequestCommitPushed === 'function') { + await onPullRequestCommitPushed({ + repositoryFullName: repositoryLabel, + branch: targetHeadBranch, + fileUpdates: committedFileUpdates, + }) + } setOpen(false) return } @@ -270,13 +315,15 @@ export const createRunSubmit = ({ url ? `Pull request opened: ${url}` : 'Pull request opened successfully.', 'ok', ) - onPullRequestOpened?.({ - repositoryFullName: repositoryLabel, - url, - pullRequestNumber: result.pullRequest.number, - branch: targetHeadBranch, - fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [], - }) + if (typeof onPullRequestOpened === 'function') { + await onPullRequestOpened({ + repositoryFullName: repositoryLabel, + url, + pullRequestNumber: result.pullRequest.number, + branch: targetHeadBranch, + fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [], + }) + } setOpen(false) }) .catch(error => { diff --git a/src/modules/github/pr/editor-sync.js b/src/modules/github/pr/editor-sync.js index 7487adb..b376036 100644 --- a/src/modules/github/pr/editor-sync.js +++ b/src/modules/github/pr/editor-sync.js @@ -2,39 +2,26 @@ import { getRepositoryFileContent } from '../api/repository-files.js' const toSafeText = value => (typeof value === 'string' ? value.trim() : '') -const toComponentPathFallbacks = path => { - const normalizedPath = toSafeText(path) - if (!normalizedPath) { - return [] - } - - const separatorIndex = normalizedPath.lastIndexOf('/') - const directory = separatorIndex >= 0 ? normalizedPath.slice(0, separatorIndex + 1) : '' - - const candidateFileNames = ['App.tsx', 'app.tsx', 'App.js', 'app.js'] - const fallbackPaths = candidateFileNames - .map(candidate => `${directory}${candidate}`) - .filter(candidate => candidate !== normalizedPath) - - for (const canonicalPath of ['src/components/App.tsx', 'src/components/App.js']) { - if (canonicalPath !== normalizedPath && !fallbackPaths.includes(canonicalPath)) { - fallbackPaths.push(canonicalPath) +const toNormalizedTabTargetsByPath = tabTargets => { + const targetsByPath = new Map() + const sourceTargets = Array.isArray(tabTargets) ? tabTargets : [] + + for (const target of sourceTargets) { + const path = toSafeText(target?.path) + if (!path) { + continue } + + targetsByPath.set(path, { + path, + kind: toSafeText(target?.kind), + }) } - return fallbackPaths + return [...targetsByPath.values()] } -export const createGitHubPrEditorSyncController = ({ - setComponentSource, - setStylesSource, - scheduleRender, - shouldApplySyncResult, -}) => { - const setComponent = - typeof setComponentSource === 'function' ? setComponentSource : () => {} - const setStyles = typeof setStylesSource === 'function' ? setStylesSource : () => {} - const schedule = typeof scheduleRender === 'function' ? scheduleRender : () => {} +export const createGitHubPrEditorSyncController = ({ shouldApplySyncResult }) => { const shouldApply = typeof shouldApplySyncResult === 'function' ? shouldApplySyncResult : () => true @@ -48,17 +35,9 @@ export const createGitHubPrEditorSyncController = ({ const owner = toSafeText(repository?.owner) const repo = toSafeText(repository?.name) const branch = toSafeText(activeContext?.headBranch) - const tabTargets = Array.isArray(syncTargets?.tabTargets) - ? syncTargets.tabTargets - : [] - const componentTabPath = toSafeText( - tabTargets.find(target => toSafeText(target?.kind) === 'component')?.path, - ) - const stylesTabPath = toSafeText( - tabTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, - ) + const normalizedTabTargets = toNormalizedTabTargetsByPath(syncTargets?.tabTargets) - if (!token || !owner || !repo || !branch || !componentTabPath || !stylesTabPath) { + if (!token || !owner || !repo || !branch || normalizedTabTargets.length === 0) { return { synced: false, componentSynced: false, @@ -71,10 +50,7 @@ export const createGitHubPrEditorSyncController = ({ repository, activeContext, syncTargets: { - tabTargets: [ - { kind: 'component', path: componentTabPath }, - { kind: 'styles', path: stylesTabPath }, - ], + tabTargets: normalizedTabTargets, }, }) ) { @@ -95,44 +71,15 @@ export const createGitHubPrEditorSyncController = ({ signal, }) - let resolvedComponentTabPath = componentTabPath - let resolvedStylesTabPath = stylesTabPath - - const componentRequest = (async () => { - const primary = await requestFileContent(componentTabPath) - if (primary) { - return primary - } - - const fallbackPaths = toComponentPathFallbacks(componentTabPath) - const fallbackResults = await Promise.all( - fallbackPaths.map(async path => ({ - path, - file: await requestFileContent(path), - })), - ) - const fallback = fallbackResults.find(candidate => candidate.file) - if (fallback?.file) { - resolvedComponentTabPath = fallback.path - return fallback.file - } - - return null - })() - - const stylesRequest = - stylesTabPath === componentTabPath - ? componentRequest - : requestFileContent(stylesTabPath) - - const [componentFile, stylesFile] = await Promise.all([ - componentRequest, - stylesRequest, - ]) - - if (stylesTabPath === componentTabPath) { - resolvedStylesTabPath = resolvedComponentTabPath - } + const requestedTargets = await Promise.all( + normalizedTabTargets.map(async target => { + const file = await requestFileContent(target.path) + return { + ...target, + content: typeof file?.content === 'string' ? file.content : null, + } + }), + ) if (signal?.aborted) { return { @@ -147,10 +94,7 @@ export const createGitHubPrEditorSyncController = ({ repository, activeContext, syncTargets: { - tabTargets: [ - { kind: 'component', path: resolvedComponentTabPath }, - { kind: 'styles', path: resolvedStylesTabPath }, - ], + tabTargets: requestedTargets, }, }) ) { @@ -161,39 +105,47 @@ export const createGitHubPrEditorSyncController = ({ } } - let updated = false - let componentSynced = false - let stylesSynced = false - - if (componentFile && typeof componentFile.content === 'string') { - setComponent(componentFile.content) - updated = true - componentSynced = true - } - - if (stylesFile && typeof stylesFile.content === 'string') { - setStyles(stylesFile.content) - updated = true - stylesSynced = true - } - - if (stylesTabPath === componentTabPath) { - stylesSynced = componentSynced - } + const syncedTabTargets = requestedTargets + .filter(target => typeof target.content === 'string') + .map(target => ({ + kind: target.kind, + path: target.path, + content: target.content, + })) - if (updated) { - schedule() + const componentTargets = requestedTargets.filter( + target => target.kind === 'component', + ) + const stylesTargets = requestedTargets.filter(target => target.kind === 'styles') + const componentSynced = + componentTargets.length > 0 && + componentTargets.every(target => typeof target.content === 'string') + const stylesSynced = + stylesTargets.length > 0 && + stylesTargets.every(target => typeof target.content === 'string') + const allTargetsSynced = syncedTabTargets.length === normalizedTabTargets.length + + if (!allTargetsSynced) { + return { + synced: false, + componentSynced, + stylesSynced, + syncedTabCount: syncedTabTargets.length, + totalTabCount: normalizedTabTargets.length, + syncTargets: { + tabTargets: normalizedTabTargets, + }, + } } return { - synced: componentSynced && stylesSynced, + synced: true, componentSynced, stylesSynced, + syncedTabCount: syncedTabTargets.length, + totalTabCount: normalizedTabTargets.length, syncTargets: { - tabTargets: [ - { kind: 'component', path: resolvedComponentTabPath }, - { kind: 'styles', path: resolvedStylesTabPath }, - ], + tabTargets: syncedTabTargets, }, } } diff --git a/src/modules/workspace/workspace-storage.js b/src/modules/workspace/workspace-storage.js index c5bb73a..2df6e74 100644 --- a/src/modules/workspace/workspace-storage.js +++ b/src/modules/workspace/workspace-storage.js @@ -7,6 +7,15 @@ const workspaceStoreName = 'prWorkspaces' const workspaceByRepoIndexName = 'byRepo' const workspaceByLastModifiedIndexName = 'byLastModified' +const toWorkspaceScope = value => { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '' + if (normalized === 'repository') { + return 'repository' + } + + return 'local' +} + const toTabRole = value => { const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '' return normalized === 'entry' ? 'entry' : 'module' @@ -74,6 +83,12 @@ const normalizeWorkspaceRecord = record => { const normalizedRepo = typeof record.repo === 'string' ? record.repo : '' const normalizedHead = typeof record.head === 'string' ? record.head : '' + const normalizedWorkspaceScope = + typeof record.workspaceScope === 'string' && record.workspaceScope.trim().length > 0 + ? toWorkspaceScope(record.workspaceScope) + : normalizedRepo + ? 'repository' + : 'local' const normalizedWorkspaceKey = toWorkspaceRecordKey({ repositoryFullName: normalizedRepo, @@ -82,6 +97,7 @@ const normalizeWorkspaceRecord = record => { return { id: record.id, + workspaceScope: normalizedWorkspaceScope, workspaceKey: normalizedWorkspaceKey, repo: normalizedRepo, base: typeof record.base === 'string' ? record.base : '', diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 03c219e..6f98951 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -1,47 +1,47 @@ -import { repositoryStarterSelectionIdPrefix } from '../../constants.js' - const toSafeText = value => (typeof value === 'string' ? value.trim() : '') const localRepositoryFilterValue = '__local__' - -const toRepositoryStarterSelectionId = repositoryFullName => { - const repository = toSafeText(repositoryFullName) - if (!repository || repository === localRepositoryFilterValue) { - return '' - } - - return `${repositoryStarterSelectionIdPrefix}${repository}` +const localWorkspaceScopeValue = 'local' +const repositoryWorkspaceScopeValue = 'repository' + +const drawerUiState = { + localEmpty: 'local-empty', + localWithWorkspaces: 'local-with-workspaces', + repositoryEmpty: 'repository-empty', + repositoryWithWorkspaces: 'repository-with-workspaces', } -const isRepositoryStarterSelectionId = value => - toSafeText(value).startsWith(repositoryStarterSelectionIdPrefix) +const toSafeWorkspaceScope = workspace => { + const scope = toSafeText(workspace?.workspaceScope).toLowerCase() + if (scope === repositoryWorkspaceScopeValue) { + return repositoryWorkspaceScopeValue + } -const isLocalWorkspaceEntry = workspace => { - const repository = toSafeText(workspace?.repo) - return !repository -} + if (scope === localWorkspaceScopeValue) { + return localWorkspaceScopeValue + } -const isLocalOnlyInactiveWorkspace = workspace => { - const state = toSafeText(workspace?.prContextState).toLowerCase() - const hasPrNumber = Number.isFinite(workspace?.prNumber) - return state === 'inactive' && !hasPrNumber + return toSafeText(workspace?.repo) + ? repositoryWorkspaceScopeValue + : localWorkspaceScopeValue } -const toWorkspaceLabel = workspace => { - const isLocalOnlyInactive = isLocalOnlyInactiveWorkspace(workspace) +const toWorkspaceLabel = (workspace, { forceLocalPrefix = false } = {}) => { + const isLocalScoped = + forceLocalPrefix || toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue const hasTitle = toSafeText(workspace?.prTitle) if (hasTitle) { - return isLocalOnlyInactive ? `local:${hasTitle}` : hasTitle + return isLocalScoped ? `local:${hasTitle}` : hasTitle } const hasHead = toSafeText(workspace?.head) if (hasHead) { - return isLocalOnlyInactive ? `local:${hasHead}` : hasHead + return isLocalScoped ? `local:${hasHead}` : hasHead } const fallbackLabel = toSafeText(workspace?.id) || 'workspace' - return isLocalOnlyInactive ? `local:${fallbackLabel}` : fallbackLabel + return isLocalScoped ? `local:${fallbackLabel}` : fallbackLabel } export const createWorkspacesDrawer = ({ @@ -50,6 +50,9 @@ export const createWorkspacesDrawer = ({ closeButton, statusNode, repositorySelect, + getActiveWorkspaceId, + initializeButton, + newButton, selectInput, openButton, removeButton, @@ -58,6 +61,8 @@ export const createWorkspacesDrawer = ({ getSelectedRepositoryFilter, onRepositoryFilterChange, onRefreshRequested, + onInitializeWorkspace, + onCreateWorkspace, onOpenSelected, onRemoveSelected, } = {}) => { @@ -66,20 +71,42 @@ export const createWorkspacesDrawer = ({ let selectedId = '' let selectedRepositoryFilter = localRepositoryFilterValue let hasUserSelectedRepositoryFilter = false + let currentUiState = drawerUiState.localEmpty const getNormalizedRepositoryFilter = value => { const normalized = toSafeText(value) return normalized || localRepositoryFilterValue } + const isInactiveWithoutPrNumber = workspace => { + const state = toSafeText(workspace?.prContextState).toLowerCase() + const hasPrNumber = + typeof workspace?.prNumber === 'number' && Number.isFinite(workspace.prNumber) + return state === 'inactive' && !hasPrNumber + } + + const shouldRenderAsLocalEntry = workspace => { + if (toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue) { + return true + } + + const activeWorkspaceId = + typeof getActiveWorkspaceId === 'function' + ? toSafeText(getActiveWorkspaceId()) + : toSafeText(selectedId) + + return ( + toSafeText(workspace?.id) === activeWorkspaceId && + isInactiveWithoutPrNumber(workspace) + ) + } + const getFilteredEntriesByRepository = () => { const normalizedRepositoryFilter = getNormalizedRepositoryFilter( selectedRepositoryFilter, ) if (normalizedRepositoryFilter === localRepositoryFilterValue) { - return entries.filter( - entry => isLocalWorkspaceEntry(entry) || isLocalOnlyInactiveWorkspace(entry), - ) + return entries.filter(entry => shouldRenderAsLocalEntry(entry)) } return entries.filter(entry => { @@ -87,14 +114,27 @@ export const createWorkspacesDrawer = ({ return false } - if (isLocalWorkspaceEntry(entry)) { + if (toSafeWorkspaceScope(entry) !== repositoryWorkspaceScopeValue) { return false } - return !isLocalOnlyInactiveWorkspace(entry) + return true }) } + const getUiState = ({ repositoryFilter, hasStoredWorkspaces }) => { + const isLocalScope = repositoryFilter === localRepositoryFilterValue + if (isLocalScope) { + return hasStoredWorkspaces + ? drawerUiState.localWithWorkspaces + : drawerUiState.localEmpty + } + + return hasStoredWorkspaces + ? drawerUiState.repositoryWithWorkspaces + : drawerUiState.repositoryEmpty + } + const setStatus = (text, level = 'neutral') => { if (!(statusNode instanceof HTMLElement)) { return @@ -105,16 +145,47 @@ export const createWorkspacesDrawer = ({ } const updateActions = () => { - const normalizedSelectedId = toSafeText(selectedId) + const normalizedSelectedId = + selectInput instanceof HTMLSelectElement + ? toSafeText(selectInput.value) + : toSafeText(selectedId) const hasSelection = normalizedSelectedId.length > 0 - const isStarterSelection = isRepositoryStarterSelectionId(normalizedSelectedId) + const canCreateWorkspace = typeof onCreateWorkspace === 'function' + const canInitializeWorkspace = typeof onInitializeWorkspace === 'function' + const hasStoredWorkspaces = + currentUiState === drawerUiState.localWithWorkspaces || + currentUiState === drawerUiState.repositoryWithWorkspaces + const showInitialize = currentUiState === drawerUiState.repositoryEmpty + const showNewWorkspace = !showInitialize + + const workspaceField = selectInput?.closest('label') + if (workspaceField instanceof HTMLElement) { + workspaceField.toggleAttribute('hidden', !hasStoredWorkspaces) + } + + const actionsRow = + openButton?.closest('.workspaces-drawer__actions') ?? + removeButton?.closest('.workspaces-drawer__actions') + if (actionsRow instanceof HTMLElement) { + actionsRow.toggleAttribute('hidden', !hasStoredWorkspaces) + } + + if (initializeButton instanceof HTMLButtonElement) { + initializeButton.toggleAttribute('hidden', !showInitialize) + initializeButton.disabled = !canInitializeWorkspace + } + + if (newButton instanceof HTMLButtonElement) { + newButton.toggleAttribute('hidden', !showNewWorkspace) + newButton.disabled = !canCreateWorkspace + } if (openButton instanceof HTMLButtonElement) { openButton.disabled = !hasSelection } if (removeButton instanceof HTMLButtonElement) { - removeButton.disabled = !hasSelection || isStarterSelection + removeButton.disabled = !hasSelection } } @@ -125,41 +196,39 @@ export const createWorkspacesDrawer = ({ const repositoryFilteredEntries = getFilteredEntriesByRepository() const filteredEntries = repositoryFilteredEntries + const hasStoredWorkspaces = filteredEntries.length > 0 const normalizedRepositoryFilter = getNormalizedRepositoryFilter( selectedRepositoryFilter, ) - const starterSelectionId = - filteredEntries.length === 0 - ? toRepositoryStarterSelectionId(normalizedRepositoryFilter) - : '' - const hasStarterSelection = Boolean(starterSelectionId) + + currentUiState = getUiState({ + repositoryFilter: normalizedRepositoryFilter, + hasStoredWorkspaces, + }) + + if (!hasStoredWorkspaces) { + updateActions() + return + } selectInput.replaceChildren() const placeholder = document.createElement('option') placeholder.value = '' - placeholder.textContent = - repositoryFilteredEntries.length === 0 - ? hasStarterSelection - ? 'Select to start a new local context' - : 'No saved local contexts' - : 'Select a stored local context' - placeholder.disabled = filteredEntries.length > 0 || hasStarterSelection + placeholder.textContent = 'Select a stored workspace' + placeholder.disabled = true placeholder.selected = !filteredEntries.some(entry => entry.id === selectedId) selectInput.append(placeholder) - if (hasStarterSelection) { - const starterOption = document.createElement('option') - starterOption.value = starterSelectionId - starterOption.textContent = `Start new context for ${normalizedRepositoryFilter}` - starterOption.selected = selectedId === starterSelectionId - selectInput.append(starterOption) - } - for (const entry of filteredEntries) { const option = document.createElement('option') option.value = toSafeText(entry.id) - option.textContent = toWorkspaceLabel(entry) + const shouldPrefixAsLocal = + normalizedRepositoryFilter === localRepositoryFilterValue && + shouldRenderAsLocalEntry(entry) + option.textContent = toWorkspaceLabel(entry, { + forceLocalPrefix: shouldPrefixAsLocal, + }) option.selected = option.value === selectedId selectInput.append(option) } @@ -167,12 +236,9 @@ export const createWorkspacesDrawer = ({ const hasSelectedFilteredEntry = filteredEntries.some( entry => entry.id === selectedId, ) - const hasSelectedStarterEntry = - hasStarterSelection && selectedId === starterSelectionId - if (!hasSelectedFilteredEntry && !hasSelectedStarterEntry) { - selectedId = hasStarterSelection ? starterSelectionId : '' - selectInput.value = selectedId + if (!hasSelectedFilteredEntry) { + selectInput.value = '' } updateActions() @@ -247,7 +313,7 @@ export const createWorkspacesDrawer = ({ } catch { entries = [] selectedId = '' - setStatus('Could not refresh stored local contexts.', 'error') + setStatus('Could not refresh stored workspaces.', 'error') renderOptions() return entries } @@ -294,11 +360,39 @@ export const createWorkspacesDrawer = ({ open = false toggleButton.setAttribute('aria-expanded', 'false') drawer.toggleAttribute('hidden', true) - setStatus('Could not open local workspaces drawer.', 'error') + setStatus('Could not open workspaces drawer.', 'error') + return + } + + updateActions() + + const workspaceField = selectInput?.closest('label') + if (workspaceField instanceof HTMLElement && !workspaceField.hasAttribute('hidden')) { + selectInput?.focus() return } - selectInput?.focus() + if ( + initializeButton instanceof HTMLButtonElement && + !initializeButton.hasAttribute('hidden') + ) { + initializeButton.focus() + return + } + + newButton?.focus() + } + + const closeDrawer = () => { + open = false + + if (toggleButton instanceof HTMLButtonElement) { + toggleButton.setAttribute('aria-expanded', 'false') + } + + if (drawer instanceof HTMLElement) { + drawer.toggleAttribute('hidden', true) + } } toggleButton?.addEventListener('click', () => { @@ -325,6 +419,55 @@ export const createWorkspacesDrawer = ({ updateActions() }) + initializeButton?.addEventListener('click', async () => { + if (typeof onInitializeWorkspace !== 'function') { + return + } + + let initialized = false + try { + initialized = await onInitializeWorkspace( + getNormalizedRepositoryFilter(selectedRepositoryFilter), + ) + } catch { + setStatus('Could not initialize workspace.', 'error') + return + } + + if (!initialized) { + return + } + + closeDrawer() + selectedId = '' + setStatus('Initialized workspace.', 'neutral') + }) + + newButton?.addEventListener('click', async () => { + if (typeof onCreateWorkspace !== 'function') { + return + } + + let created = false + try { + created = await onCreateWorkspace( + getNormalizedRepositoryFilter(selectedRepositoryFilter), + ) + } catch { + setStatus('Could not create workspace.', 'error') + return + } + + if (!created) { + return + } + + selectedId = + typeof getActiveWorkspaceId === 'function' ? toSafeText(getActiveWorkspaceId()) : '' + setStatus('Created workspace.', 'neutral') + await refresh({ preserveSelection: Boolean(selectedId) }) + }) + openButton?.addEventListener('click', async () => { const id = toSafeText(selectedId) if (!id || typeof onOpenSelected !== 'function') { @@ -337,7 +480,7 @@ export const createWorkspacesDrawer = ({ try { opened = await onOpenSelected(id) } catch { - setStatus('Could not load selected local context.', 'error') + setStatus('Could not load selected workspace.', 'error') return } @@ -345,8 +488,8 @@ export const createWorkspacesDrawer = ({ return } - setStatus('Loaded local workspace context.', 'neutral') - void refresh({ preserveSelection: true }) + closeDrawer() + setStatus('Loaded workspace.', 'neutral') }) removeButton?.addEventListener('click', async () => { @@ -359,7 +502,7 @@ export const createWorkspacesDrawer = ({ try { removed = await onRemoveSelected(id) } catch { - setStatus('Could not remove selected local context.', 'error') + setStatus('Could not remove selected workspace.', 'error') return } @@ -368,7 +511,7 @@ export const createWorkspacesDrawer = ({ } selectedId = '' - setStatus('Removed stored local context.', 'neutral') + setStatus('Removed stored workspace.', 'neutral') await refresh({ preserveSelection: false }) }) diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index 09d3d15..8ab6459 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -430,61 +430,6 @@ display: none; } -.diagnostics-toggle.github-pr-context-disconnect { - display: inline-flex; - align-items: center; - gap: 6px; - --github-pr-disconnect-icon-color: color-mix( - in srgb, - var(--accent) 78%, - var(--panel-text) - ); - --github-pr-disconnect-text-color: var(--shell-text); - --github-pr-disconnect-text-color-hover: color-mix( - in srgb, - var(--accent) 78%, - var(--panel-text) - ); - - color: var(--github-pr-disconnect-text-color); -} - -:root[data-theme='light'] .diagnostics-toggle.github-pr-context-disconnect { - --github-pr-disconnect-icon-color: color-mix(in srgb, #6d28d9 84%, var(--panel-text)); - --github-pr-disconnect-text-color-hover: color-mix( - in srgb, - #6d28d9 88%, - var(--panel-text) - ); -} - -.github-pr-context-disconnect__icon { - width: 14px; - height: 14px; - fill: var(--github-pr-disconnect-icon-color); -} - -.github-pr-context-disconnect__icon path { - fill: var(--github-pr-disconnect-icon-color); -} - -.diagnostics-toggle.github-pr-context-disconnect:hover:not(:disabled) { - color: var(--github-pr-disconnect-text-color-hover); -} - -.github-pr-context-disconnect__label { - color: inherit; -} - -.github-pr-context-disconnect:focus-visible:not(:disabled) { - outline: 2px solid var(--focus-ring); - outline-offset: 1px; -} - -.github-pr-context-disconnect[hidden] { - display: none; -} - .ai-chat-drawer { position: fixed; top: 82px; @@ -623,12 +568,41 @@ padding: 2px; } +.workspaces-drawer__repository-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: end; +} + +.workspaces-drawer__repository-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.workspaces-drawer__repository-row .github-pr-field--full { + grid-column: auto; +} + +.workspaces-drawer__repository-row .render-button { + height: 36px; + width: auto; + min-width: fit-content; + padding-inline: 12px; + white-space: nowrap; +} + .workspaces-drawer__actions { display: flex; justify-content: flex-end; gap: 8px; } +.workspaces-drawer__actions[hidden] { + display: none; +} + .github-pr-drawer__header { display: flex; align-items: center;