From e95b5d353b70e2925eb9267a288604e1561f4b1e Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 26 Apr 2026 14:00:43 -0500 Subject: [PATCH 1/5] feat: improved workspace ux baseline. --- docs/idb-workspace-state.md | 4 +- docs/issue-99-workspaces-drawer-plan.md | 71 ++++ docs/localstorage-state.md | 2 +- docs/pr-context-storage-matrix.md | 30 +- playwright/github-byot-ai.spec.ts | 13 +- .../active-context-switch.spec.ts | 358 ------------------ .../github-pr-drawer.helpers.ts | 46 +-- .../github-pr-drawer/open-pr-create.spec.ts | 51 ++- playwright/helpers/app-test-helpers.ts | 32 +- src/app.js | 20 +- src/index.html | 55 +-- src/modules/app-core/github-pr-context-ui.js | 6 - .../app-core/github-workflows-setup.js | 2 - src/modules/app-core/github-workflows.js | 93 ++--- src/modules/constants.js | 1 - src/modules/github/pr/drawer/common.js | 6 +- .../pr/drawer/controller/public-actions.js | 19 - .../workspace/workspaces-drawer/drawer.js | 116 +++--- src/styles/ai-controls.css | 78 ++-- 19 files changed, 321 insertions(+), 682 deletions(-) create mode 100644 docs/issue-99-workspaces-drawer-plan.md delete mode 100644 src/modules/constants.js diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md index 029a7ce..e361499 100644 --- a/docs/idb-workspace-state.md +++ b/docs/idb-workspace-state.md @@ -23,7 +23,7 @@ Each workspace record may include: - `head` - `prTitle` - `prNumber` - - `prContextState` (`inactive` | `active` | `disconnected` | `closed`) + - `prContextState` (`inactive` | `active` | `closed`) - Runtime/editor state: - `renderMode` - `activeTabId` @@ -37,7 +37,7 @@ 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 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..90eac89 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 diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md index 8a068fc..bee056a 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,11 @@ 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. | ## Current Workspace Selection On Load @@ -82,9 +81,8 @@ When the UI does not match expected PR state: - `prNumber` - `repo`, `head`, `prTitle` 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 +97,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/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index e5f61f4..577f9e9 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -768,21 +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 newWorkspaceButton = page.getByRole('button', { + name: 'New workspace', 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 expect(newWorkspaceButton).toBeVisible() + await newWorkspaceButton.click() await page.getByRole('button', { name: 'Close workspaces drawer' }).click() await ensureOpenPrDrawerOpen(page) diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts index b786b1d..e35e3ba 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,343 +20,6 @@ import { waitForAppReady, } from './github-pr-drawer.helpers.js' -test('Active PR context disconnect uses local-only confirmation flow', async ({ - page, -}) => { - let closePullRequestRequestCount = 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/pulls/2', - 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: 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 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 - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - - 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 dialog.getByRole('button', { name: 'Cancel' }).click() - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - const recordAfterCancel = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterCancel?.prContextState).toBe('active') - - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - await dialog.getByRole('button', { name: 'Disconnect' }).click() - - 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) - - 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) - - await waitForAppReady(page, `${appEntryPath}`) - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeHidden() - - const recordAfterReload = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterReload?.prContextState).toBe('disconnected') - expect(recordAfterReload?.prNumber).toBe(2) -}) - -test('Reopening a disconnected workspace from Workspaces restores active PR controls and editor state', 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, - }) - - 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, inactiveHeadBranch], - }) - - 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 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/${activeHeadBranch}`, - object: { type: 'commit', sha: 'existing-head-sha' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await seedActivePrWorkspaceContext(page, { - repositoryFullName, - headBranch: activeHeadBranch, - prTitle: 'Existing PR context from storage', - prNumber: 2, - renderMode: 'react', - }) - - await seedLocalWorkspaceContexts(page, [ - { - id: inactiveWorkspaceId, - repo: repositoryFullName, - base: 'main', - head: inactiveHeadBranch, - prTitle: '', - prNumber: null, - prContextState: 'inactive', - renderMode: 'dom', - tabs: [ - { - id: 'component', - name: 'App.tsx', - path: 'src/components/App.tsx', - language: 'javascript-jsx', - role: 'entry', - isActive: true, - content: 'export const App = () =>
Fallback workspace view
', - }, - { - id: 'styles', - name: 'app.css', - path: 'src/styles/app.css', - language: 'css', - role: 'module', - isActive: false, - content: 'main { color: #333; }', - }, - ], - activeTabId: 'component', - createdAt: Date.now() - 120_000, - lastModified: Date.now() - 120_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, }) => { @@ -368,16 +30,6 @@ test('Switching active workspace to inactive preserves switched-from record inte 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') -}) - test('Switching active workspace to closed preserves switched-from record integrity', async ({ page, }) => { @@ -398,16 +50,6 @@ test('Switching active workspace to cross-repo inactive preserves switched-from await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') }) -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') -}) - test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({ page, }) => { diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts index 150dab3..feb826a 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 = ( @@ -183,7 +183,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 () => { @@ -433,7 +433,7 @@ export const seedLocalWorkspaceContexts = async ( head: string prTitle: string prNumber?: number | null - prContextState?: 'inactive' | 'active' | 'disconnected' | 'closed' + prContextState?: 'inactive' | 'active' | 'closed' renderMode?: 'dom' | 'react' tabs?: Array> activeTabId?: string | null @@ -585,7 +585,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 +725,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' @@ -742,11 +742,8 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({ 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 + targetState === 'inactive' || targetState === 'closed' + const expectedTargetPrContextState = targetState await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -937,27 +934,11 @@ 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) - ) + return readSnapshot() }) - .toBe(true) + .toEqual(usesPromotedSourceSnapshot ? promotedSnapshot : originalSnapshot) } export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({ @@ -965,7 +946,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 +963,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..482d93c 100644 --- a/playwright/github-pr-drawer/open-pr-create.spec.ts +++ b/playwright/github-pr-drawer/open-pr-create.spec.ts @@ -509,15 +509,60 @@ 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 New workspace', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + 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() + + 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() + + const newWorkspaceButton = page.getByRole('button', { + name: 'New workspace', + exact: true, + }) + await expect(newWorkspaceButton).toBeVisible() + await newWorkspaceButton.click() + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/develop', + ) +}) + test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({ page, }) => { @@ -779,7 +824,7 @@ test('Changing head updates current workspace without creating a new record', as }) }) -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, diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index f4e8c3e..c2e9b17 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -412,8 +412,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 +420,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() } diff --git a/src/app.js b/src/app.js index 3601cdb..939e91b 100644 --- a/src/app.js +++ b/src/app.js @@ -123,7 +123,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 +140,7 @@ 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 workspacesNew = document.getElementById('workspaces-new') const workspacesSelect = document.getElementById('workspaces-select') const workspacesOpen = document.getElementById('workspaces-open') const workspacesRemove = document.getElementById('workspaces-remove') @@ -458,7 +458,6 @@ const prContextUi = createGitHubPrContextUiController({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - githubPrContextDisconnect, aiChatToggle, workspacesToggle, githubPrOpenIcon, @@ -830,7 +829,7 @@ const { getWorkspaceTabByKind, makeUniqueTabPath, createWorkspaceTabId, - onWorkspaceRecordApplied: (workspace, options = {}) => { + onWorkspaceRecordApplied: workspace => { if (!workspace || typeof workspace !== 'object') { return } @@ -844,14 +843,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 } @@ -1065,6 +1061,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesClose, workspacesStatus, workspacesRepository, + workspacesNew, workspacesSelect, workspacesOpen, workspacesRemove, @@ -1123,14 +1120,6 @@ 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: () => { @@ -1148,7 +1137,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, formatActivePrReference, githubPrContextClose, - githubPrContextDisconnect, }, actions: { applyRenderMode, diff --git a/src/index.html b/src/index.html index 98aceef..d510fa9 100644 --- a/src/index.html +++ b/src/index.html @@ -195,30 +195,6 @@

Close - -

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/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js index 5536e7d..a047410 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 = () => { @@ -126,10 +123,8 @@ export const createGitHubPrContextUiController = ({ if (contextState.activePrContext) { githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') } else { githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') } return } @@ -155,7 +150,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..90d210f 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -40,7 +40,6 @@ const createGitHubWorkflowsSetup = ({ onPrContextStateChange: runtime.onPrContextStateChange, onPrContextVerifiedClosed: runtime.onPrContextVerifiedClosed, onPrContextClosed: runtime.onPrContextClosed, - onPrContextDisconnected: runtime.onPrContextDisconnected, getTokenForVisibility: runtime.getTokenForVisibility, closeWorkspacesDrawer: runtime.closeWorkspacesDrawer, getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey, @@ -49,7 +48,6 @@ const createGitHubWorkflowsSetup = ({ 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..add666a 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,7 @@ const initializeGitHubWorkflows = ({ workspacesClose, workspacesStatus, workspacesRepository, + workspacesNew, workspacesSelect, workspacesOpen, workspacesRemove, @@ -66,7 +65,6 @@ const initializeGitHubWorkflows = ({ onPrContextStateChange, onPrContextVerifiedClosed, onPrContextClosed, - onPrContextDisconnected, getTokenForVisibility, closeWorkspacesDrawer, getActivePrEditorSyncKey, @@ -75,7 +73,6 @@ const initializeGitHubWorkflows = ({ applyStyleMode, formatActivePrReference, githubPrContextClose, - githubPrContextDisconnect, confirmAction, setStatus, showAppToast, @@ -102,18 +99,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' @@ -315,6 +300,7 @@ const initializeGitHubWorkflows = ({ closeButton: workspacesClose, statusNode: workspacesStatus, repositorySelect: workspacesRepository, + newButton: workspacesNew, selectInput: workspacesSelect, openButton: workspacesOpen, removeButton: workspacesRemove, @@ -348,25 +334,38 @@ const initializeGitHubWorkflows = ({ return 'right' }, onRefreshRequested: listLocalContextRecords, - onOpenSelected: async workspaceId => { + onCreateWorkspace: async repositoryFilter => { + const normalizedFilter = + typeof repositoryFilter === 'string' ? repositoryFilter.trim() : '' + const repositoryFullName = + normalizedFilter && normalizedFilter !== '__local__' ? normalizedFilter : '' + try { - const starterRepositoryFullName = parseRepositoryStarterSelectionId(workspaceId) - if (starterRepositoryFullName) { - setCurrentSelectedRepository?.(starterRepositoryFullName) - await syncActiveWorkspaceRepositoryScope?.(starterRepositoryFullName, { - rekeyRecord: true, - }) - await refreshLocalContextOptions() - prDrawerController.resetStatus?.() - prDrawerController.syncRepositories() - return true + if (!repositoryFullName) { + clearCurrentSelectedRepository?.() + } else { + setCurrentSelectedRepository?.(repositoryFullName) } + await syncActiveWorkspaceRepositoryScope?.(repositoryFullName, { + rekeyRecord: true, + }) + 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 +380,7 @@ const initializeGitHubWorkflows = ({ return applied } catch { workspacesDrawerController?.setStatus( - 'Could not load selected local context.', + 'Could not load selected workspace.', 'error', ) return false @@ -389,7 +388,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 +402,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 +473,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/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/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/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/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 03c219e..c776d80 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -1,21 +1,7 @@ -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 isRepositoryStarterSelectionId = value => - toSafeText(value).startsWith(repositoryStarterSelectionIdPrefix) - const isLocalWorkspaceEntry = workspace => { const repository = toSafeText(workspace?.repo) return !repository @@ -50,6 +36,7 @@ export const createWorkspacesDrawer = ({ closeButton, statusNode, repositorySelect, + newButton, selectInput, openButton, removeButton, @@ -58,6 +45,7 @@ export const createWorkspacesDrawer = ({ getSelectedRepositoryFilter, onRepositoryFilterChange, onRefreshRequested, + onCreateWorkspace, onOpenSelected, onRemoveSelected, } = {}) => { @@ -66,6 +54,7 @@ export const createWorkspacesDrawer = ({ let selectedId = '' let selectedRepositoryFilter = localRepositoryFilterValue let hasUserSelectedRepositoryFilter = false + let hasStoredWorkspacesInScope = false const getNormalizedRepositoryFilter = value => { const normalized = toSafeText(value) @@ -107,14 +96,21 @@ export const createWorkspacesDrawer = ({ const updateActions = () => { const normalizedSelectedId = toSafeText(selectedId) const hasSelection = normalizedSelectedId.length > 0 - const isStarterSelection = isRepositoryStarterSelectionId(normalizedSelectedId) + + if (newButton instanceof HTMLButtonElement) { + newButton.disabled = typeof onCreateWorkspace !== 'function' + } if (openButton instanceof HTMLButtonElement) { + openButton.toggleAttribute('hidden', !hasStoredWorkspacesInScope) + openButton.style.display = hasStoredWorkspacesInScope ? '' : 'none' openButton.disabled = !hasSelection } if (removeButton instanceof HTMLButtonElement) { - removeButton.disabled = !hasSelection || isStarterSelection + removeButton.toggleAttribute('hidden', !hasStoredWorkspacesInScope) + removeButton.style.display = hasStoredWorkspacesInScope ? '' : 'none' + removeButton.disabled = !hasSelection } } @@ -125,37 +121,29 @@ export const createWorkspacesDrawer = ({ const repositoryFilteredEntries = getFilteredEntriesByRepository() const filteredEntries = repositoryFilteredEntries - const normalizedRepositoryFilter = getNormalizedRepositoryFilter( - selectedRepositoryFilter, - ) - const starterSelectionId = - filteredEntries.length === 0 - ? toRepositoryStarterSelectionId(normalizedRepositoryFilter) - : '' - const hasStarterSelection = Boolean(starterSelectionId) + const workspaceField = selectInput.closest('label') + const hasStoredWorkspaces = filteredEntries.length > 0 + hasStoredWorkspacesInScope = hasStoredWorkspaces + + if (workspaceField instanceof HTMLElement) { + workspaceField.toggleAttribute('hidden', !hasStoredWorkspaces) + } + + if (!hasStoredWorkspaces) { + selectedId = '' + 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) @@ -167,11 +155,9 @@ export const createWorkspacesDrawer = ({ const hasSelectedFilteredEntry = filteredEntries.some( entry => entry.id === selectedId, ) - const hasSelectedStarterEntry = - hasStarterSelection && selectedId === starterSelectionId - if (!hasSelectedFilteredEntry && !hasSelectedStarterEntry) { - selectedId = hasStarterSelection ? starterSelectionId : '' + if (!hasSelectedFilteredEntry) { + selectedId = '' selectInput.value = selectedId } @@ -247,7 +233,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 +280,17 @@ 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 } - selectInput?.focus() + const workspaceField = selectInput?.closest('label') + if (workspaceField instanceof HTMLElement && !workspaceField.hasAttribute('hidden')) { + selectInput?.focus() + return + } + + newButton?.focus() } toggleButton?.addEventListener('click', () => { @@ -325,6 +317,30 @@ export const createWorkspacesDrawer = ({ updateActions() }) + 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 = '' + setStatus('Created workspace.', 'neutral') + await refresh({ preserveSelection: false }) + }) + openButton?.addEventListener('click', async () => { const id = toSafeText(selectedId) if (!id || typeof onOpenSelected !== 'function') { @@ -337,7 +353,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,7 +361,7 @@ export const createWorkspacesDrawer = ({ return } - setStatus('Loaded local workspace context.', 'neutral') + setStatus('Loaded workspace.', 'neutral') void refresh({ preserveSelection: true }) }) @@ -359,7 +375,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 +384,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..222f3f7 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,35 @@ padding: 2px; } +.workspaces-drawer__repository-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: end; +} + +.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; From 5ef284db7a34bf5f4642ee50e984399c333fbfd5 Mon Sep 17 00:00:00 2001 From: KCM Date: Wed, 29 Apr 2026 14:05:21 -0500 Subject: [PATCH 2/5] fix: workspace identity on changing for same repo. --- docs/idb-workspace-state.md | 7 + docs/localstorage-state.md | 4 + docs/workspaces-behavior-algorithm.md | 96 ++ playwright/github-byot-ai.spec.ts | 9 +- ...-context-switch-debounce-essential.spec.ts | 341 +++++++ .../active-context-switch.spec.ts | 485 +++++++++- .../active-context-sync.spec.ts | 748 +++++++++++++++- .../github-pr-drawer.helpers.ts | 26 +- .../github-pr-drawer/open-pr-create.spec.ts | 831 +++++++++++++++++- playwright/helpers/app-test-helpers.ts | 39 +- src/app.js | 83 +- src/index.html | 48 +- src/modules/app-core/app-bindings-startup.js | 14 - .../app-core/github-workflows-setup.js | 3 +- src/modules/app-core/github-workflows.js | 118 ++- .../app-core/workspace-context-controller.js | 33 +- .../app-core/workspace-controllers-setup.js | 17 + .../app-core/workspace-editor-helpers.js | 28 +- ...workspace-pr-session-handoff-controller.js | 7 + .../app-core/workspace-save-controller.js | 199 ++++- .../app-core/workspace-scope-fork-actions.js | 183 ++++ .../app-core/workspace-sync-controller.js | 89 +- src/modules/github/api/editor-content.js | 124 ++- .../pr/drawer/controller/context-sync.js | 33 +- .../pr/drawer/controller/create-controller.js | 2 + .../github/pr/drawer/controller/run-submit.js | 28 +- src/modules/github/pr/editor-sync.js | 169 ++-- src/modules/workspace/workspace-storage.js | 16 + .../workspace/workspaces-drawer/drawer.js | 201 ++++- src/styles/ai-controls.css | 6 + 30 files changed, 3508 insertions(+), 479 deletions(-) create mode 100644 docs/workspaces-behavior-algorithm.md create mode 100644 playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts create mode 100644 src/modules/app-core/workspace-scope-fork-actions.js diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md index e361499..752afa4 100644 --- a/docs/idb-workspace-state.md +++ b/docs/idb-workspace-state.md @@ -17,6 +17,7 @@ Each workspace record may include: - `id` - `createdAt` - `lastModified` + - `workspaceScope` (`local` | `repository`) - Repository and PR context: - `repo` - `base` @@ -44,3 +45,9 @@ IDB supports that by storing: 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. + +## Behavioral Spec + +For action-level drawer semantics and state machine behavior, see: + +- `docs/workspaces-behavior-algorithm.md` diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md index 90eac89..ae273a2 100644 --- a/docs/localstorage-state.md +++ b/docs/localstorage-state.md @@ -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/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 577f9e9..add40b3 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -768,17 +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 newWorkspaceButton = page.getByRole('button', { - name: 'New workspace', + 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(newWorkspaceButton).toBeVisible() - await newWorkspaceButton.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..c5b9fbb --- /dev/null +++ b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts @@ -0,0 +1,341 @@ +import { expect, test } from '@playwright/test' +import { + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + getWorkspaceTabsRecord, + openStoredWorkspaceContextByHead, + seedLocalWorkspaceContexts, + setComponentEditorSource, + toRecordIntegritySnapshot, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +const repositoryFullName = 'knightedcodemonkey/develop' + +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' + + 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 connectByotWithSingleRepo(page, { + branchesByRepo: { + [repository]: ['main', pHeadBranch, ppHeadBranch], + }, + }) + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + 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 + + 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(''), + } + }) + .toEqual({ + pId: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + ppId: 'ws_d6502674-64fd-46a6-9418-596f31067779', + pKey: 'knightedcodemonkey-develop::feat-p', + ppKey: 'knightedcodemonkey-develop::feat-pp', + pTabCount: 3, + ppTabCount: 4, + pHasPContent: true, + ppHasPPContent: true, + }) +}) diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts index e35e3ba..5923a0b 100644 --- a/playwright/github-pr-drawer/active-context-switch.spec.ts +++ b/playwright/github-pr-drawer/active-context-switch.spec.ts @@ -50,6 +50,474 @@ test('Switching active workspace to cross-repo inactive preserves switched-from 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({ + 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', alphaHeadBranch, betaHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/21', + async route => { + 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: 22, + state: 'open', + title: 'Beta active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/22', + head: { ref: betaHeadBranch }, + 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/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'active-sync-switch-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 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; }', + } + + const content = contentByBranchPath[keyedPath] + 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-${ref}-${path}`, + content: Buffer.from(content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + 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 connectByotWithSingleRepo(page) + + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + 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 + }) + + const alphaTabs = Array.isArray(alphaRecord?.tabs) + ? (alphaRecord.tabs as Array>) + : [] + const betaTabs = Array.isArray(betaRecord?.tabs) + ? (betaRecord.tabs as Array>) + : [] + + 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 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('Switching active repository workspaces B->A->B preserves each workspace tab content', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + 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({ + 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', alphaHeadBranch, betaHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/31', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 31, + state: 'open', + 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' }, + }), + }) + }, + ) + + 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/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'roundtrip-active-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 waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: alphaHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: alphaHeadBranch, + prTitle: 'Alpha active workspace', + prNumber: 31, + 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 = () =>
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: '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: now - 60_000, + lastModified: now - 60_000, + }, + ]) + + await connectByotWithSingleRepo(page) + + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + + 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, + ) + + 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 + + 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 ({ page, }) => { @@ -541,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..fa4a99b 100644 --- a/playwright/github-pr-drawer/active-context-sync.spec.ts +++ b/playwright/github-pr-drawer/active-context-sync.spec.ts @@ -6,12 +6,14 @@ import { connectByotWithSingleRepo, ensureOpenPrDrawerOpen, getAllWorkspaceRecords, + getWorkspaceComponentContent, getWorkspaceTabsRecord, mockRepositoryBranches, openMostRecentStoredWorkspaceContext, renameWorkspaceTab, seedActivePrWorkspaceContext, seedLocalWorkspaceContexts, + selectWorkspacesRepositoryFilter, setComponentEditorSource, setStylesEditorSource, submitOpenPrAndConfirm, @@ -245,6 +247,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 => { @@ -385,24 +411,432 @@ 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 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/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 +1543,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 +2009,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 +2387,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 +2592,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 feb826a..99b78c0 100644 --- a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts +++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts @@ -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__' } @@ -429,6 +430,7 @@ export const seedLocalWorkspaceContexts = async ( contexts: Array<{ id: string repo: string + workspaceScope?: 'local' | 'repository' base?: string head: string prTitle: string @@ -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, diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts index 482d93c..b91e85f 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 ({ @@ -454,6 +892,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', @@ -518,10 +957,24 @@ test('Workspaces repository selector filters contexts and keeps local-only conte expect(localLabels).not.toContain('Alpha active context') }) -test('Workspaces repository with no stored entries hides Workspace select and supports New workspace', async ({ +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({ @@ -545,22 +998,193 @@ test('Workspaces repository with no stored entries hides Workspace select and su .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 newWorkspaceButton = page.getByRole('button', { - name: 'New workspace', + const initializeButton = page.getByRole('button', { + name: 'Initialize', exact: true, }) - await expect(newWorkspaceButton).toBeVisible() - await newWorkspaceButton.click() + await expect(initializeButton).toBeVisible() + await initializeButton.click() await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toHaveValue( - 'knightedcodemonkey/develop', - ) + await expect(pullRequestRepository).toHaveValue('knightedcodemonkey/develop') + + await expect + .poll(async () => { + const updatedRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === seededRecordId, + ) + return { + seededRepo: typeof updatedRecord?.repo === 'string' ? updatedRecord.repo : '', + seededWorkspaceKey: + typeof updatedRecord?.workspaceKey === 'string' + ? updatedRecord.workspaceKey + : '', + 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: '', + seededWorkspaceKey: '', + 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 ({ @@ -613,8 +1237,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 () => { @@ -669,7 +1297,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) @@ -702,6 +1332,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) @@ -713,43 +1344,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' @@ -784,6 +1411,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) @@ -798,7 +1426,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) @@ -820,7 +1448,7 @@ 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 : '', }) }) @@ -924,7 +1552,7 @@ for (const prContextState of ['inactive', '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 @@ -1118,6 +1746,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) @@ -1435,6 +2064,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, }) => { @@ -1497,12 +2229,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') @@ -1677,15 +2411,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 c2e9b17..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( @@ -518,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 939e91b..9c5910d 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' @@ -140,6 +141,7 @@ 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') @@ -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' } } @@ -568,7 +578,9 @@ const getPersistedActivePrContext = createPersistedActivePrContextGetter({ const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ getCurrentSelectedRepository: () => - workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName(), + workspaceScopeMarker === 'repository' + ? workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName() + : '', githubPrBaseBranch, githubPrHeadBranch, githubPrTitle, @@ -629,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, @@ -713,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 }) @@ -763,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), @@ -874,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, @@ -938,6 +968,7 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr getWorkspacePrNumber: () => workspacePrNumber, setWorkspacePrContextState, setWorkspacePrNumber, + setWorkspaceScopeMarker, getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), @@ -1061,6 +1092,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesClose, workspacesStatus, workspacesRepository, + workspacesInitialize, workspacesNew, workspacesSelect, workspacesOpen, @@ -1075,6 +1107,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({ refreshLocalContextOptions, applyWorkspaceRecord, syncActiveWorkspaceRepositoryScope, + forkWorkspaceFromCurrentState, + flushWorkspaceSave, getWorkspacePrFileCommits, getEditorSyncTargets, reconcileWorkspaceTabsWithPushUpdates, @@ -1122,9 +1156,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, getPersistedActivePrContext, getTokenForVisibility: () => githubAiContextState.token, - closeWorkspacesDrawer: () => { - void workspacesDrawerController?.setOpen(false) - }, getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, syncFromActiveContext: ({ tabTargets }) => { const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId() diff --git a/src/index.html b/src/index.html index d510fa9..423d5c2 100644 --- a/src/index.html +++ b/src/index.html @@ -140,39 +140,39 @@

+
+ + +