From 762eb4f4fa7f3842f6e7b708fcba63bbc6054d64 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 26 Apr 2026 08:57:49 -0500 Subject: [PATCH 1/6] fix: prevent workspace overwrites. --- docs/pr-context-storage-matrix.md | 19 +- playwright/github-byot-ai.spec.ts | 14 +- playwright/github-pr-drawer.spec.ts | 1757 +++++++++++++++-- playwright/helpers/app-test-helpers.ts | 56 +- src/app.js | 303 ++- src/index.html | 15 +- src/modules/app-core/app-bindings-startup.js | 16 +- .../app-core/github-workflows-setup.js | 2 + src/modules/app-core/github-workflows.js | 134 +- .../app-core/persisted-active-pr-context.js | 4 - src/modules/app-core/pr-context-records.js | 54 + .../pr-context-state-change-handler.js | 85 + src/modules/app-core/pr-context-state.js | 58 + src/modules/app-core/pr-context-transition.js | 65 + .../app-core/workspace-context-controller.js | 48 +- .../app-core/workspace-controllers-setup.js | 15 +- ...workspace-pr-session-handoff-controller.js | 20 +- .../app-core/workspace-save-controller.js | 66 +- .../app-core/workspace-sync-controller.js | 22 +- src/modules/github/byot-controls.js | 67 +- .../pr/drawer/controller/create-controller.js | 31 +- .../github/pr/drawer/controller/events.js | 29 - .../pr/drawer/controller/public-actions.js | 46 +- .../pr/drawer/controller/repository-form.js | 28 +- .../github/pr/drawer/controller/run-submit.js | 2 + .../github/pr/drawer/controller/ui-state.js | 18 +- src/modules/github/pr/editor-sync.js | 41 + src/modules/workspace/workspace-storage.js | 14 +- .../workspace/workspace-tab-helpers.js | 61 +- .../workspace/workspaces-drawer/drawer.js | 179 +- 30 files changed, 2691 insertions(+), 578 deletions(-) create mode 100644 src/modules/app-core/pr-context-records.js create mode 100644 src/modules/app-core/pr-context-state-change-handler.js create mode 100644 src/modules/app-core/pr-context-state.js create mode 100644 src/modules/app-core/pr-context-transition.js diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md index db7ea94..8a068fc 100644 --- a/docs/pr-context-storage-matrix.md +++ b/docs/pr-context-storage-matrix.md @@ -43,19 +43,22 @@ Use this matrix as the source of truth when debugging UI/storage mismatch. ## Current Workspace Selection On Load -When the app loads or the selected repository changes, the app selects a workspace from IndexedDB using repository-scoped records only. +When the app loads, workspace restore scope depends on whether a repository is selected. + +- If a repository is selected: use repository-scoped records only (`repo` match). +- If no repository is selected: evaluate all stored workspace records. Selection order: -1. Load records for the currently selected repository (`repo` match). -2. Compute a preferred id from in-memory state: +1. Load candidate records using the scope above. +2. Compute preferred candidates from in-memory state: -- Existing in-memory active record id when available. -- Otherwise canonical id derived from current repository + head. +- Preferred by id: existing in-memory active record id when available. +- Preferred by workspace key: current repository + head (`workspaceKey`). -3. If the preferred record exists and is `active`, select it. -4. Otherwise select the first `active` record in that repository. -5. Otherwise select the preferred record if present. +3. If preferred-by-id or preferred-by-key exists and is `active`, select it. +4. Otherwise select the first `active` record in candidates. +5. Otherwise select preferred-by-id or preferred-by-key if present. 6. Otherwise fall back to the first record returned by IDB ordering. Notes: diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index c473920..5b82a7a 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -760,15 +760,20 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => { .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() - await ensureOpenPrDrawerOpen(page) - const repoSelect = page.getByLabel('Pull request repository') - await expect(repoSelect).toBeEnabled() + await expect(repoSelect).toBeDisabled() await expect(page.getByRole('status', { name: 'App status' })).toHaveText( 'Loaded 2 writable repositories', ) - await repoSelect.selectOption('knightedcodemonkey/develop') + await page.getByRole('button', { name: 'Workspaces' }).click() + const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter') + await expect(workspaceRepositoryFilter).toBeVisible() + await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop') + await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop') + await page.getByRole('button', { name: 'Close workspaces drawer' }).click() + + await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') await page.reload() @@ -783,4 +788,5 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => { await expect(page.getByRole('button', { name: 'Delete GitHub token' })).toBeVisible() await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() }) diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index c3c73f5..25c252d 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -9,6 +9,7 @@ import { appEntryPath, connectByotWithSingleRepo, ensureOpenPrDrawerOpen, + ensureWorkspacesDrawerClosed, mockRepositoryBranches, resetWorkbenchStorage, setComponentEditorSource, @@ -91,7 +92,22 @@ const expectOpenPrConfirmationPrompt = async (page: Page) => { } const removeSavedGitHubToken = async (page: Page) => { - await page.getByRole('button', { name: 'Delete GitHub token' }).click() + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } + + await page.getByRole('button', { name: 'Delete GitHub token' }).evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) const dialog = page.getByRole('dialog', { name: 'Remove saved GitHub token?', @@ -103,58 +119,239 @@ const removeSavedGitHubToken = async (page: Page) => { await expect(dialog).not.toHaveAttribute('open', '') } -const openStoredWorkspaceContextById = async (page: Page, workspaceId: string) => { +const ensureWorkspacesDrawerOpen = async (page: Page) => { const select = page.getByLabel('Stored local editor contexts') - const openButton = page.locator('#workspaces-open') - if (!(await select.isVisible())) { - await page.getByRole('button', { name: 'Workspaces' }).click() + if (await select.isVisible()) { + return } + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.click() + } + + await page.getByRole('button', { name: 'Workspaces' }).click() await expect(select).toBeVisible() +} - await expect - .poll(async () => { - return select.evaluate( - (element, id) => - element instanceof HTMLSelectElement && - Array.from(element.options).some(option => option.value === id), - workspaceId, - ) - }) - .toBe(true) +const getWorkspaceRecordId = (record: Record | null | undefined) => + typeof record?.id === 'string' ? record.id : '' + +const getWorkspacesRepositoryFilterForRecord = ({ + repo, + prContextState, + prNumber, +}: { + repo?: unknown + prContextState?: unknown + prNumber?: unknown +}) => { + const normalizedRepo = typeof repo === 'string' ? repo.trim() : '' + const normalizedState = + typeof prContextState === 'string' ? prContextState.trim().toLowerCase() : '' + const hasPrNumber = typeof prNumber === 'number' && Number.isFinite(prNumber) + + if (!normalizedRepo) { + return '__local__' + } + + if (normalizedState === 'inactive' && !hasPrNumber) { + return '__local__' + } + + return normalizedRepo +} + +const openStoredWorkspaceContextById = async ( + page: Page, + workspaceId: string, + { + repositoryFilter, + }: { + repositoryFilter?: string + } = {}, +) => { + const select = page.getByLabel('Stored local editor contexts') + const openButton = page.locator('#workspaces-open') + + if (typeof repositoryFilter === 'string' && repositoryFilter.trim()) { + await selectWorkspacesRepositoryFilter(page, repositoryFilter) + } + + await ensureWorkspacesDrawerOpen(page) await expect .poll(async () => { - await select.selectOption(workspaceId) - const selectedValue = await select.inputValue() - return selectedValue === workspaceId && (await openButton.isEnabled()) + const options = await select.locator('option').all() + for (const option of options) { + if ((await option.getAttribute('value')) === workspaceId) { + return true + } + } + + return false }) .toBe(true) + await select.selectOption(workspaceId) + await expect(select).toHaveValue(workspaceId) + await expect(openButton).toBeEnabled() await openButton.click() + await ensureWorkspacesDrawerClosed(page) } const openMostRecentStoredWorkspaceContext = async (page: Page) => { - const select = page.getByLabel('Stored local editor contexts') + const mostRecentContext = await page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') - if (!(await select.isVisible())) { - await page.getByRole('button', { name: 'Workspaces' }).click() - } + 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.')) + }) - await expect(select).toBeVisible() + 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 = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) - const firstContextId = await select.evaluate(element => { - if (!(element instanceof HTMLSelectElement)) { - return '' + const byLastModified = ( + left: Record, + right: Record, + ) => { + 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 + } + + const sortedAll = records.slice().sort(byLastModified) + const mostRecent = sortedAll[0] + const id = typeof mostRecent?.id === 'string' ? mostRecent.id : '' + const repo = typeof mostRecent?.repo === 'string' ? mostRecent.repo : '' + const prContextState = + typeof mostRecent?.prContextState === 'string' ? mostRecent.prContextState : '' + const prNumber = + typeof mostRecent?.prNumber === 'number' && Number.isFinite(mostRecent.prNumber) + ? mostRecent.prNumber + : null + return { id, repo, prContextState, prNumber } + } finally { + db.close() } + }) - const option = Array.from(element.options).find(candidate => candidate.value) - return option?.value ?? '' + expect(mostRecentContext?.id).not.toBe('') + const repositoryFilter = getWorkspacesRepositoryFilterForRecord(mostRecentContext) + await openStoredWorkspaceContextById(page, mostRecentContext.id, { + repositoryFilter, }) +} + +const selectWorkspacesRepositoryFilter = async (page: Page, repositoryFilter: string) => { + const workspacesToggle = page.getByRole('button', { name: 'Workspaces' }) + const repositorySelect = page.getByLabel('Workspace repository filter') - expect(firstContextId).not.toBe('') - await openStoredWorkspaceContextById(page, firstContextId) + if (!(await repositorySelect.isVisible())) { + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.click() + } + + await expect(workspacesToggle).toBeVisible() + await workspacesToggle.click() + await expect(repositorySelect).toBeVisible() + } + + await expect + .poll(async () => { + await repositorySelect.evaluate((element, value) => { + if (!(element instanceof HTMLSelectElement)) { + return '' + } + + element.value = value + element.dispatchEvent(new Event('change', { bubbles: true })) + return element.value + }, repositoryFilter) + + return repositorySelect.inputValue() + }) + .toBe(repositoryFilter) +} + +const openStoredWorkspaceContextByHead = async (page: Page, headBranch: string) => { + const workspace = await page.evaluate(async inputHeadBranch => { + 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 = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const normalizedHeadBranch = + typeof inputHeadBranch === 'string' ? inputHeadBranch.trim().toLowerCase() : '' + const matched = records.find(record => { + const recordHead = + typeof record?.head === 'string' ? record.head.trim().toLowerCase() : '' + return recordHead === normalizedHeadBranch + }) + + const id = typeof matched?.id === 'string' ? matched.id : '' + const repo = typeof matched?.repo === 'string' ? matched.repo : '' + const prContextState = + typeof matched?.prContextState === 'string' ? matched.prContextState : '' + const prNumber = + typeof matched?.prNumber === 'number' && Number.isFinite(matched.prNumber) + ? matched.prNumber + : null + return { id, repo, prContextState, prNumber } + } finally { + db.close() + } + }, headBranch) + + expect(workspace?.id).not.toBe('') + + const repositoryFilter = getWorkspacesRepositoryFilterForRecord(workspace) + + await openStoredWorkspaceContextById(page, workspace.id, { repositoryFilter }) } const seedLocalWorkspaceContexts = async ( @@ -413,11 +610,489 @@ const getAllWorkspaceRecords = async (page: Page) => { }, ) - return records - } finally { - db.close() - } - }) + return records + } finally { + db.close() + } + }) +} + +const getWorkspaceComponentContent = (record: Record | null) => { + if (!record || typeof record !== 'object') { + return '' + } + + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const componentTab = tabs.find(tab => { + if (!tab || typeof tab !== 'object') { + return false + } + + return (tab as { id?: unknown }).id === 'component' + }) as { content?: unknown } | undefined + + return typeof componentTab?.content === 'string' ? componentTab.content : '' +} + +const toRecordIntegritySnapshot = (record: Record | null) => { + return { + repo: typeof record?.repo === 'string' ? record.repo : '', + base: typeof record?.base === 'string' ? record.base : '', + head: typeof record?.head === 'string' ? record.head : '', + prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '', + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : 'inactive', + componentContent: getWorkspaceComponentContent(record), + } +} + +const runActiveWorkspaceSwitchIntegrityScenario = async ({ + page, + targetState, +}: { + page: Page + targetState: 'inactive' | 'disconnected' | 'closed' +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/issue-97-active-a' + const targetHeadBranch = `develop/issue-97-target-${targetState}` + const activeWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: activeHeadBranch, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: targetHeadBranch, + }) + const targetPrTitle = + targetState === 'inactive' ? '' : `Target ${targetState} workspace` + const targetPrNumber = targetState === 'inactive' ? null : 9 + const expectedTargetPrContextState = + targetState === 'disconnected' ? 'active' : targetState + + 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', activeHeadBranch, targetHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/**', + async route => { + const url = route.request().url() + const pullRequestNumberMatch = url.match(/\/pulls\/(\d+)/) + const pullRequestNumber = pullRequestNumberMatch + ? Number.parseInt(pullRequestNumberMatch[1] ?? '', 10) + : 0 + const isTargetPullRequest = pullRequestNumber === 9 + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: isTargetPullRequest ? 9 : 2, + state: isTargetPullRequest && targetState === 'closed' ? 'closed' : 'open', + title: isTargetPullRequest ? targetPrTitle : 'Active A workspace', + html_url: `https://github.com/knightedcodemonkey/develop/pull/${isTargetPullRequest ? 9 : 2}`, + head: { ref: isTargetPullRequest ? targetHeadBranch : activeHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + const url = route.request().url() + const isTargetHeadRef = url.includes(targetHeadBranch) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${isTargetHeadRef ? targetHeadBranch : activeHeadBranch}`, + object: { + type: 'commit', + sha: isTargetHeadRef ? 'target-head-sha' : 'active-head-sha', + }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: activeWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Active A workspace', + 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 = () =>
Active A content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, + { + id: targetWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: targetState, + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
Target ${targetState} content
`, + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 120_000, + lastModified: Date.now() - 120_000, + }, + ]) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', activeHeadBranch, targetHeadBranch], + }, + }) + await openMostRecentStoredWorkspaceContext(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await openStoredWorkspaceContextById(page, targetWorkspaceId) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText(`Target ${targetState} content`) + + await expect + .poll(async () => { + const activeRecord = await getWorkspaceTabsRecord(page, { + headBranch: activeHeadBranch, + }) + return toRecordIntegritySnapshot(activeRecord as Record | null) + }) + .toEqual({ + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Active A workspace', + prNumber: 2, + prContextState: 'active', + componentContent: 'export const App = () =>
Active A content
', + }) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + return toRecordIntegritySnapshot(targetRecord as Record | null) + }) + .toEqual({ + repo: repositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: expectedTargetPrContextState, + componentContent: `export const App = () =>
Target ${targetState} content
`, + }) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const activeRecord = records.find(record => record?.head === activeHeadBranch) + const targetRecord = records.find(record => record?.head === targetHeadBranch) + return Boolean(activeRecord) && Boolean(targetRecord) + }) + .toBe(true) +} + +const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({ + page, + targetState, +}: { + page: Page + targetState: 'inactive' | 'disconnected' | 'closed' +}) => { + const sourceRepositoryFullName = 'knightedcodemonkey/develop' + const targetRepositoryFullName = 'knightedcodemonkey/css' + const sourceHeadBranch = 'develop/issue-97-cross-source-active' + const targetHeadBranch = `css/issue-97-cross-target-${targetState}` + const sourceWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranch, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: targetRepositoryFullName, + headBranch: targetHeadBranch, + }) + const targetPrTitle = + targetState === 'inactive' ? '' : `Cross target ${targetState} workspace` + const targetPrNumber = 9 + const expectedTargetPrContextState = + targetState === 'disconnected' ? 'active' : targetState + + 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: sourceRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: targetRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [sourceRepositoryFullName]: ['main', sourceHeadBranch], + [targetRepositoryFullName]: ['main', targetHeadBranch], + }) + + 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: 'Cross source active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: sourceHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/9', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 9, + state: targetState === 'closed' ? 'closed' : 'open', + title: targetPrTitle, + html_url: 'https://github.com/knightedcodemonkey/css/pull/9', + head: { ref: targetHeadBranch }, + 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/${sourceHeadBranch}`, + object: { type: 'commit', sha: 'cross-source-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${targetHeadBranch}`, + object: { type: 'commit', sha: 'cross-target-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: sourceWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranch, + prTitle: 'Cross source active workspace', + 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 = () =>
Cross source active content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, + { + id: targetWorkspaceId, + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: targetState, + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
Cross target ${targetState} content
`, + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 120_000, + lastModified: Date.now() - 120_000, + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await selectWorkspacesRepositoryFilter(page, sourceRepositoryFullName) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText(`Cross target ${targetState} content`) + + await expect + .poll(async () => { + const sourceRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranch, + }) + return toRecordIntegritySnapshot(sourceRecord as Record | null) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranch, + prTitle: 'Cross source active workspace', + prNumber: 2, + prContextState: 'active', + componentContent: + 'export const App = () =>
Cross source active content
', + }) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + return toRecordIntegritySnapshot(targetRecord as Record | null) + }) + .toEqual({ + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: expectedTargetPrContextState, + componentContent: `export const App = () =>
Cross target ${targetState} content
`, + }) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const sourceRecord = records.find( + record => + record?.repo === sourceRepositoryFullName && record?.head === sourceHeadBranch, + ) + const targetRecord = records.find( + record => + record?.repo === targetRepositoryFullName && record?.head === targetHeadBranch, + ) + return Boolean(sourceRecord) && Boolean(targetRecord) + }) + .toBe(true) } test('Open PR drawer confirms and submits component/styles filepaths', async ({ @@ -860,6 +1535,7 @@ test('Open PR drawer can filter stored local contexts by search', async ({ page await connectByotWithSingleRepo(page) await page.getByRole('button', { name: 'Workspaces' }).click() + await page.getByLabel('Workspace repository filter').selectOption('__local__') const search = page.getByLabel('Search stored local contexts') await expect(search).toBeEnabled() @@ -869,6 +1545,147 @@ test('Open PR drawer can filter stored local contexts by search', async ({ page expect(labels).toEqual(['Select a stored local context', 'local:Beta local context']) }) +test('Workspaces repository selector filters contexts and keeps local-only contexts under Local', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_knightedcodemonkey_develop_feat-local-alpha', + repo: 'knightedcodemonkey/develop', + head: 'feat/local-alpha', + prTitle: 'Alpha local context', + prContextState: 'inactive', + prNumber: null, + }, + { + id: 'workspace_feat-active-alpha', + repo: 'knightedcodemonkey/develop', + head: 'feat/active-alpha', + prTitle: 'Alpha active context', + prContextState: 'active', + prNumber: 41, + }, + { + id: 'repo_knightedcodemonkey_css_feat-active-css', + repo: 'knightedcodemonkey/css', + head: 'feat/active-css', + prTitle: 'CSS active context', + prContextState: 'active', + prNumber: 51, + }, + ]) + + 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 }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + 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') + const developLabels = await getLocalContextOptionLabels(page) + expect(developLabels).toEqual(['Select a stored local context', 'Alpha active context']) + + await selectWorkspacesRepositoryFilter(page, '__local__') + const localLabels = await getLocalContextOptionLabels(page) + expect(localLabels).toContain('Select a stored local context') + expect(localLabels).toContain('local:Alpha local context') + expect(localLabels).not.toContain('Alpha active context') +}) + +test('Switching Workspaces repository scope to Local clears repo on active inactive workspace record', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/contract-case' + const headBranch = 'feat/component-v8zw' + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_knightedcodemonkey_contract-case_feat-component-v8zw', + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: '', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', headBranch], + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await openStoredWorkspaceContextByHead(page, headBranch) + await selectWorkspacesRepositoryFilter(page, '__local__') + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch, + }) + + return typeof record?.repo === 'string' ? record.repo : null + }) + .toBe('') + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => record?.head === headBranch).length + }) + .toBe(1) +}) + test('Blank-slate startup persists inactive local workspace before PAT', async ({ page, }) => { @@ -936,8 +1753,15 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page } .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const initialRecord = await getWorkspaceTabsRecord(page) + const initialRecordId = getWorkspaceRecordId(initialRecord) + expect(initialRecordId).not.toBe('') await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('develop/fresh-pat-bootstrap') + await page.getByLabel('Head').blur() await expect .poll(async () => { @@ -970,6 +1794,86 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page } ) }) .toBe(true) + + const record = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/fresh-pat-bootstrap', + }) + expect(record?.id).toBe(initialRecordId) +}) + +test('Changing head updates current workspace without creating a new record', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/contract-case' + + await resetWorkbenchStorage(page) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const initialRecord = await getWorkspaceTabsRecord(page) + const initialRecordId = getWorkspaceRecordId(initialRecord) + expect(initialRecordId).not.toBe('') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('develop/head-first') + await page.getByLabel('Head').blur() + await page.getByLabel('Head').fill('develop/head-second') + await page.getByLabel('Head').blur() + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const matching = records.filter(record => record?.repo === repositoryFullName) + 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] + + return { + count: matching.length, + id: typeof latest?.id === 'string' ? latest.id : '', + head: typeof latest?.head === 'string' ? latest.head : '', + } + }) + .toEqual({ + count: 1, + id: initialRecordId, + head: 'develop/head-second', + }) }) for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) { @@ -1070,7 +1974,9 @@ for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) { await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository) await expect(page.getByLabel('Head')).toHaveValue(workspaceHead) - await page.getByLabel('Pull request repository').selectOption(targetRepository) + await selectWorkspacesRepositoryFilter(page, targetRepository) + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(targetRepository) await expect(page.getByLabel('Head')).toHaveValue(workspaceHead) await expect @@ -1259,13 +2165,13 @@ test('Open PR keeps inactive workspace record when repository changes', async ({ .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() - const repoSelect = page.getByLabel('Pull request repository') - await expect(repoSelect).toHaveValue(oldRepository) - await openStoredWorkspaceContextById(page, oldWorkspaceId) await ensureOpenPrDrawerOpen(page) - await repoSelect.selectOption(newRepository) + await expect(page.getByLabel('Pull request repository')).toHaveValue(oldRepository) + await selectWorkspacesRepositoryFilter(page, newRepository) + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(newRepository) await page.getByLabel('Head').fill(headBranch) await page.getByLabel('PR title').fill('Promote inactive context to active PR') @@ -1287,16 +2193,17 @@ test('Open PR keeps inactive workspace record when repository changes', async ({ headBranch, }) - expect(recordsByHead).toHaveLength(1) - expect(recordsByHead[0]?.id).toBe(expectedWorkspaceId) - expect(recordsByHead[0]?.repo).toBe(newRepository) - expect(recordsByHead[0]?.prContextState).toBe('active') - expect(recordsByHead[0]?.prNumber).toBe(88) + const promotedActiveRecord = recordsByHead.find( + record => record?.repo === newRepository && record?.prContextState === 'active', + ) + + expect(promotedActiveRecord?.id).toBe(expectedWorkspaceId) + expect(promotedActiveRecord?.prNumber).toBe(88) - const staleRepositoryRecords = workspaceRecords.filter( - record => record?.repo === oldRepository, + const preservedSourceRecord = recordsByHead.find( + record => record?.repo === oldRepository && record?.prContextState === 'inactive', ) - expect(staleRepositoryRecords).toHaveLength(0) + expect(Boolean(preservedSourceRecord)).toBe(true) }) test('Open PR drawer uses Git Database API atomic commit path by default', async ({ @@ -1647,28 +2554,92 @@ test('Open PR drawer base dropdown updates from mocked repo branches', async ({ const repoSelect = page.getByLabel('Pull request repository') const baseSelect = page.getByLabel('Pull request base branch') + await expect(repoSelect).toBeDisabled() - await repoSelect.selectOption('knightedcodemonkey/develop') + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') await expect(baseSelect).toHaveValue('main') await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next']) - await repoSelect.selectOption('knightedcodemonkey/css') + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/css') await expect(baseSelect).toHaveValue('stable') await expect(baseSelect.getByRole('option')).toHaveText(['stable', 'release/1.x']) - expect( - branchRequestUrls.some(url => - url.includes('https://api.github.com/repos/knightedcodemonkey/develop/branches'), - ), - ).toBe(true) - expect( - branchRequestUrls.some(url => - url.includes('https://api.github.com/repos/knightedcodemonkey/css/branches'), - ), - ).toBe(true) + expect( + branchRequestUrls.some(url => + url.includes('https://api.github.com/repos/knightedcodemonkey/develop/branches'), + ), + ).toBe(true) + expect( + branchRequestUrls.some(url => + url.includes('https://api.github.com/repos/knightedcodemonkey/css/branches'), + ), + ).toBe(true) +}) + +test('Open PR drawer does not persist active PR context in localStorage', async ({ + page, +}) => { + 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 }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'develop-next'], + 'knightedcodemonkey/css': ['stable', 'release/1.x'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await ensureOpenPrDrawerOpen(page) + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('examples/develop/head') + await page.getByLabel('Head').blur() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('examples/css/head') + await page.getByLabel('Head').blur() + + const legacyKeys = await page.evaluate(() => { + const storagePrefix = 'knighted:develop:github-pr-config:' + return Object.keys(localStorage).filter(key => key.startsWith(storagePrefix)) + }) + + expect(legacyKeys).toHaveLength(0) }) -test('Open PR drawer does not persist active PR context in localStorage', async ({ +test('Open PR drawer never writes repo PR context keys in localStorage', async ({ page, }) => { await page.route('https://api.github.com/user/repos**', async route => { @@ -1709,15 +2680,12 @@ test('Open PR drawer does not persist active PR context in localStorage', async await page.getByRole('button', { name: 'Add GitHub token' }).click() await ensureOpenPrDrawerOpen(page) - const repoSelect = page.getByLabel('Pull request repository') - - await repoSelect.selectOption('knightedcodemonkey/develop') + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) await page.getByLabel('Head').fill('examples/develop/head') await page.getByLabel('Head').blur() - await repoSelect.selectOption('knightedcodemonkey/css') - await page.getByLabel('Head').fill('examples/css/head') - await page.getByLabel('Head').blur() + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') const legacyKeys = await page.evaluate(() => { const storagePrefix = 'knighted:develop:github-pr-config:' @@ -1727,7 +2695,7 @@ test('Open PR drawer does not persist active PR context in localStorage', async expect(legacyKeys).toHaveLength(0) }) -test('Open PR drawer never writes repo PR context keys in localStorage', async ({ +test('Open PR repository field stays read-only while Workspaces controls repository selection', async ({ page, }) => { await page.route('https://api.github.com/user/repos**', async route => { @@ -1766,22 +2734,20 @@ test('Open PR drawer never writes repo PR context keys in localStorage', async ( .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() - await ensureOpenPrDrawerOpen(page) + await ensureOpenPrDrawerOpen(page) const repoSelect = page.getByLabel('Pull request repository') + await expect(repoSelect).toBeDisabled() - await repoSelect.selectOption('knightedcodemonkey/develop') - await page.getByLabel('Head').fill('examples/develop/head') - await page.getByLabel('Head').blur() - - await repoSelect.selectOption('knightedcodemonkey/css') - - const legacyKeys = await page.evaluate(() => { - const storagePrefix = 'knighted:develop:github-pr-config:' - return Object.keys(localStorage).filter(key => key.startsWith(storagePrefix)) - }) + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() - expect(legacyKeys).toHaveLength(0) + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/css') + await expect(repoSelect).toBeDisabled() }) test('Active PR context disconnect uses local-only confirmation flow', async ({ @@ -1941,13 +2907,10 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ const localRecord = records.find( record => typeof record?.id === 'string' && - record.id.startsWith('local_') && - record?.repo === 'knightedcodemonkey/develop' && + record.id.startsWith('ws_') && record?.prContextState === 'inactive', ) - - const localHead = typeof localRecord?.head === 'string' ? localRecord.head : '' - return /^feat\/component-[a-z0-9]+-[a-z0-9]+(?:-\d+)?$/.test(localHead) + return Boolean(localRecord) }) .toBe(true) expect(closePullRequestRequestCount).toBe(0) @@ -2062,66 +3025,412 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont language: 'javascript-jsx', role: 'entry', isActive: true, - content: 'export const App = () =>
Fallback workspace view
', - }, - { - id: 'styles', - name: 'app.css', - path: 'src/styles/app.css', - language: 'css', - role: 'module', - isActive: false, - content: 'main { color: #333; }', + 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, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to disconnected preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'disconnected', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to closed preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'closed', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active 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, +}) => { + const sourceRepositoryFullName = 'knightedcodemonkey/css' + const targetRepositoryFullName = 'knightedcodemonkey/develop' + const sourceHeadBranchPrimary = 'css/issue-123-primary' + const sourceHeadBranchSibling = 'css/issue-123-sibling' + const targetHeadBranch = 'develop/issue-123-target' + + const sourcePrimaryWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranchPrimary, + }) + const sourceSiblingWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranchSibling, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: targetRepositoryFullName, + headBranch: targetHeadBranch, + }) + + 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: 'css', + full_name: sourceRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: targetRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [sourceRepositoryFullName]: [ + 'main', + sourceHeadBranchPrimary, + sourceHeadBranchSibling, + ], + [targetRepositoryFullName]: ['main', targetHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/9', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 9, + state: 'open', + title: 'Source primary active workspace', + html_url: 'https://github.com/knightedcodemonkey/css/pull/9', + head: { ref: sourceHeadBranchPrimary }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/10', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 10, + state: 'open', + title: 'Source sibling active workspace', + html_url: 'https://github.com/knightedcodemonkey/css/pull/10', + head: { ref: sourceHeadBranchSibling }, + base: { ref: 'main' }, + }), + }) + }, + ) + + 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: 'Target active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: targetHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${sourceHeadBranchPrimary}`, + object: { type: 'commit', sha: 'source-primary-sha' }, + }), + }) + }, + ) + + 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/${targetHeadBranch}`, + object: { type: 'commit', sha: 'target-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: sourcePrimaryWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchPrimary, + prTitle: 'Source primary active workspace', + prNumber: 9, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Source primary content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 180_000, + lastModified: Date.now() - 180_000, + }, + { + id: sourceSiblingWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchSibling, + prTitle: 'Source sibling active workspace', + prNumber: 10, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Source sibling content
', }, ], activeTabId: 'component', createdAt: Date.now() - 120_000, lastModified: Date.now() - 120_000, }, + { + id: targetWorkspaceId, + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: 'Target active workspace', + 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 = () =>
Target content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, ]) - await connectByotWithSingleRepo(page) - await openStoredWorkspaceContextById(page, activeWorkspaceId) + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openStoredWorkspaceContextByHead(page, sourceHeadBranchPrimary) 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 openStoredWorkspaceContextByHead(page, targetHeadBranch) await expect( page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), - ).toContainText('Fallback workspace view') + ).toContainText('Target content') - await openStoredWorkspaceContextById(page, activeWorkspaceId) + await expect + .poll(async () => { + const sourcePrimaryRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranchPrimary, + }) + return toRecordIntegritySnapshot( + sourcePrimaryRecord as Record | null, + ) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchPrimary, + prTitle: 'Source primary active workspace', + prNumber: 9, + prContextState: 'active', + componentContent: 'export const App = () =>
Source primary content
', + }) - 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') + await expect + .poll(async () => { + const sourceSiblingRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranchSibling, + }) + return toRecordIntegritySnapshot( + sourceSiblingRecord as Record | null, + ) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchSibling, + prTitle: 'Source sibling active workspace', + prNumber: 10, + prContextState: 'active', + componentContent: 'export const App = () =>
Source sibling content
', + }) - const reactivatedRecord = await getWorkspaceTabsRecord(page, { - headBranch: activeHeadBranch, + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + return toRecordIntegritySnapshot(targetRecord as Record | null) + }) + .toEqual({ + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: 'Target active workspace', + prNumber: 2, + prContextState: 'active', + componentContent: 'export const App = () =>
Target content
', + }) +}) + +test('Switching active workspace to cross-repo closed preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ + page, + targetState: 'closed', }) - expect(reactivatedRecord?.prContextState).toBe('active') - expect(reactivatedRecord?.prNumber).toBe(2) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') }) test('Active PR context updates controls and can be closed from AI controls', async ({ @@ -2376,6 +3685,20 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page }, ) + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${githubHeadBranch}`, + object: { type: 'commit', sha: 'rehydrate-head-sha' }, + }), + }) + }, + ) + await waitForAppReady(page, `${appEntryPath}`) await page.evaluate(() => { @@ -2394,30 +3717,31 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) await ensureOpenPrDrawerOpen(page) await expect(page.getByLabel('Pull request repository')).toHaveValue( 'knightedcodemonkey/css', ) await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), + page.getByRole('button', { name: 'Open pull request', exact: true }), ).toBeVisible() - await expect - .poll(async () => page.getByRole('textbox', { name: 'Head' }).inputValue()) - .toBe(githubHeadBranch) + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() + await expect(page.getByLabel('Head')).toHaveValue(staleLocalHeadBranch) await expect .poll(async () => { const records = await getAllWorkspaceRecords(page) - const syncedActiveRecord = records.find( + const restoredRecord = records.find( record => record?.repo === 'knightedcodemonkey/css' && - record?.prContextState === 'active' && record?.prNumber === 7 && - record?.head === githubHeadBranch, + record?.prTitle === 'Saved css PR context', ) - return Boolean(syncedActiveRecord) + return Boolean(restoredRecord) }) .toBe(true) @@ -2430,19 +3754,22 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) await ensureOpenPrDrawerOpen(page) await expect(page.getByLabel('Pull request repository')).toHaveValue( 'knightedcodemonkey/css', ) await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), + page.getByRole('button', { name: 'Open pull request', exact: true }), ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() - const selectedRepository = await page.evaluate(() => - localStorage.getItem('knighted:develop:github-repository'), + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', ) - expect(selectedRepository).toBe('knightedcodemonkey/css') }) test('Active PR context deactivates after token remove and re-add when PR is closed', async ({ @@ -2498,6 +3825,20 @@ test('Active PR context deactivates after token remove and re-add when PR is clo }, ) + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/css/rehydrate-test', + object: { type: 'commit', sha: 'rehydrate-closed-head-sha' }, + }), + }) + }, + ) + await waitForAppReady(page, `${appEntryPath}`) await page.evaluate(() => { @@ -2516,9 +3857,17 @@ test('Active PR context deactivates after token remove and re-add when PR is clo .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), + page.getByRole('button', { name: 'Open pull request', exact: true }), ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() await removeSavedGitHubToken(page) await expect(page.getByRole('status', { name: 'App status' })).toHaveText( @@ -2530,6 +3879,7 @@ test('Active PR context deactivates after token remove and re-add when PR is clo .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) await ensureOpenPrDrawerOpen(page) await expect(page.getByLabel('Pull request repository')).toHaveValue( @@ -2543,7 +3893,7 @@ test('Active PR context deactivates after token remove and re-add when PR is clo ).toBeHidden() await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Saved pull request context is not open on GitHub.') + ).toContainText('Repository is selected from Workspaces.') }) test('Active PR context recovers when saved head branch is missing but PR metadata exists', async ({ @@ -2588,6 +3938,20 @@ test('Active PR context recovers when saved head branch is missing but PR metada }, ) + 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: 'recovered-pr-head-sha' }, + }), + }) + }, + ) + await waitForAppReady(page, `${appEntryPath}`) await seedActivePrWorkspaceContext(page, { @@ -4025,6 +5389,119 @@ test('Reload keeps persisted active PR workspace context active', async ({ page expect(activeRecordsForPr).toHaveLength(1) }) +test('Reload restores active PR context when title is empty but PR identity exists', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-empty-title' + + 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/37', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 37, + state: 'open', + title: 'Recovered PR title from GitHub', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/37', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: '', + prNumber: 37, + 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 = () =>
Active identity restore
', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate( + ({ repo }) => { + localStorage.setItem( + 'knighted:develop:github-pat', + 'github_pat_fake_chat_1234567890', + ) + localStorage.setItem('knighted:develop:github-repository', repo) + }, + { repo: repositoryFullName }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch, + }) + + return { + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : null, + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + } + }) + .toEqual({ + prContextState: 'active', + prNumber: 37, + }) +}) + test('Reload prefers active PR workspace when mixed workspace records exist', async ({ page, }) => { diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index c8763a5..901e22a 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -357,6 +357,32 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => { ).toBeVisible() } +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 closeButton = page.locator('#workspaces-close') + if (await closeButton.isVisible()) { + await closeButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } else { + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } + } + + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden() +} + export const mockRepositoryBranches = async ( page: Page, branchesByRepo: BranchesByRepo = {}, @@ -379,7 +405,14 @@ export const mockRepositoryBranches = async ( }) } -export const connectByotWithSingleRepo = async (page: Page) => { +export const connectByotWithSingleRepo = async ( + page: Page, + { + branchesByRepo, + }: { + branchesByRepo?: BranchesByRepo + } = {}, +) => { await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ status: 200, @@ -397,17 +430,32 @@ export const connectByotWithSingleRepo = async (page: Page) => { }) }) - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) + await mockRepositoryBranches( + page, + branchesByRepo ?? { + 'knightedcodemonkey/develop': ['main', 'release'], + }, + ) await page .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + const workspacesToggle = page.getByRole('button', { name: 'Workspaces' }) + await expect(workspacesToggle).toBeVisible() + await workspacesToggle.click() + + const workspacesRepositoryFilter = page.getByLabel('Workspace repository filter') + await expect(workspacesRepositoryFilter).toBeVisible() + await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') + await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') + + await ensureWorkspacesDrawerClosed(page) + const repoSelect = page.getByLabel('Pull request repository') await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() await expect( page.getByRole('button', { diff --git a/src/app.js b/src/app.js index 1e2cc5f..216c424 100644 --- a/src/app.js +++ b/src/app.js @@ -46,6 +46,8 @@ import { createWorkspaceSyncController } from './modules/app-core/workspace-sync import { createWorkspaceTabAddMenuUiController } from './modules/app-core/workspace-tab-add-menu-ui.js' import { createPersistedActivePrContextGetter } from './modules/app-core/persisted-active-pr-context.js' import { createWorkspacePrSessionHandoffController } from './modules/app-core/workspace-pr-session-handoff-controller.js' +import { persistClosedPrContextRecords } from './modules/app-core/pr-context-records.js' +import { createPrContextStateChangeHandler } from './modules/app-core/pr-context-state-change-handler.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' @@ -76,6 +78,7 @@ import { } from './modules/workspace/workspace-tab-factory.js' import { createEnsureWorkspaceTabsShape } from './modules/workspace/workspace-tab-shape.js' import { + createWorkspaceRecordId, getDirtyStateForTabChange, getPathFileName, getTabKind, @@ -90,7 +93,7 @@ import { resolveWorkspaceActiveTabId, resolveWorkspaceRecordIdentity, toNonEmptyWorkspaceText, - toWorkspaceRecordId, + toWorkspaceRecordKey, toWorkspaceSyncSha, toWorkspaceSyncedContent, toWorkspaceSyncTimestamp, @@ -137,6 +140,7 @@ const workspacesToggle = document.getElementById('workspaces-toggle') 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 workspacesSearch = document.getElementById('workspaces-search') const workspacesSelect = document.getElementById('workspaces-select') const workspacesOpen = document.getElementById('workspaces-open') @@ -405,6 +409,7 @@ const githubAiContextState = { let workspacePrContextState = 'inactive' let workspacePrNumber = null +let workspaceRepositoryFullName = '' let hasObservedActivePrContextInSession = false const toPullRequestNumber = value => { @@ -417,6 +422,9 @@ const toPullRequestNumber = value => { const setActiveWorkspaceRecordId = nextValue => { activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue) + if (!activeWorkspaceRecordId) { + workspaceRepositoryFullName = '' + } } let chatDrawerController = { @@ -432,6 +440,7 @@ let prDrawerController = { getActivePrContext: () => null, hydrateActivePrContext: () => false, clearActivePrContext: () => {}, + clearSelectedRepositoryActivePrContext: () => false, closeActivePullRequestOnGitHub: async () => null, setToken: () => {}, syncRepositories: () => {}, @@ -480,64 +489,33 @@ const byotControls = createGitHubByotControls({ chatDrawerController.setSelectedRepository(repository) prDrawerController.setSelectedRepository(repository) hasObservedActivePrContextInSession = false - - const hasActiveWorkspaceRecord = - typeof activeWorkspaceRecordId === 'string' && - activeWorkspaceRecordId.trim().length > 0 - const shouldPreserveExistingInactiveWorkspace = - hasActiveWorkspaceRecord && - workspacePrContextState === 'inactive' && - hasCompletedInitialWorkspaceBootstrap && - activeWorkspaceCreatedAt !== null - - if (shouldPreserveExistingInactiveWorkspace) { - prDrawerController.syncRepositories() - return - } - - setActiveWorkspaceRecordId('') - activeWorkspaceCreatedAt = null - void loadPreferredWorkspaceContext() - .then(() => { - prDrawerController.syncRepositories() - }) - .catch(() => { - /* noop */ - }) + prDrawerController.syncRepositories() }, onWritableRepositoriesChange: ({ repositories, selectedRepository }) => { githubAiContextState.writableRepositories = Array.isArray(repositories) ? [...repositories] : [] - if (selectedRepository) { - githubAiContextState.selectedRepository = selectedRepository + if (selectedRepository || githubAiContextState.selectedRepository) { + githubAiContextState.selectedRepository = selectedRepository ?? null chatDrawerController.setSelectedRepository(selectedRepository) prDrawerController.setSelectedRepository(selectedRepository) - const isBootstrappingTokenSession = - typeof githubAiContextState.token !== 'string' || - githubAiContextState.token.trim().length === 0 - - if (!activeWorkspaceRecordId || activeWorkspaceCreatedAt === null) { - void loadPreferredWorkspaceContext() - .then(() => { - prDrawerController.syncRepositories() - }) - .catch(() => { - /* noop */ - }) - } else if (isBootstrappingTokenSession) { - void loadPreferredWorkspaceContext() - .then(() => { - prDrawerController.syncRepositories() - }) - .catch(() => { - /* noop */ - }) - } + prDrawerController.syncRepositories() + return } - prDrawerController.syncRepositories() + const workspaceScopedRepository = toNonEmptyWorkspaceText(workspaceRepositoryFullName) + if (!workspaceScopedRepository) { + return + } + + if (byotControls.setSelectedRepository(workspaceScopedRepository)) { + const synchronizedRepository = byotControls.getSelectedRepository() + githubAiContextState.selectedRepository = synchronizedRepository + chatDrawerController.setSelectedRepository(synchronizedRepository) + prDrawerController.setSelectedRepository(synchronizedRepository) + prDrawerController.syncRepositories() + } }, onTokenDeleteRequest: onConfirm => { confirmAction({ @@ -575,12 +553,7 @@ const getCurrentSelectedRepositoryFullName = () => { return selectedRepositoryFullName.trim() } - try { - const storedRepository = localStorage.getItem('knighted:develop:github-repository') - return typeof storedRepository === 'string' ? storedRepository.trim() : '' - } catch { - return '' - } + return '' } const getPersistedActivePrContext = createPersistedActivePrContextGetter({ @@ -596,7 +569,8 @@ const getPersistedActivePrContext = createPersistedActivePrContextGetter({ }) const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ - getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, + getCurrentSelectedRepository: () => + workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName(), githubPrBaseBranch, githubPrHeadBranch, githubPrTitle, @@ -652,6 +626,7 @@ const workspaceSyncController = createWorkspaceSyncController({ toWorkspaceSyncedContent, toWorkspaceSyncSha, toNonEmptyWorkspaceText, + toWorkspaceRecordKey, hasTabCommittedSyncState, getJsxSource: () => getJsxSource(), getCssSource: () => getCssSource(), @@ -740,6 +715,28 @@ 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 }) @@ -795,7 +792,7 @@ const { updateRenderModeEditability: () => updateRenderModeEditability(), getHasCompletedInitialWorkspaceBootstrap: () => hasCompletedInitialWorkspaceBootstrap, maybeRender: () => maybeRender(), - toWorkspaceRecordId, + toWorkspaceRecordKey, workspaceTabsStrip, getWorkspaceTabRenameState: () => workspaceTabRenameState, getDraggedWorkspaceTabId: () => draggedWorkspaceTabId, @@ -839,6 +836,15 @@ const { return } + const nextWorkspaceRepositoryFullName = + typeof workspace.repo === 'string' ? workspace.repo.trim() : '' + if (nextWorkspaceRepositoryFullName) { + workspaceRepositoryFullName = nextWorkspaceRepositoryFullName + byotControls.setSelectedRepository(nextWorkspaceRepositoryFullName) + } + + prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false }) + const isSilentRestore = options?.silent === true const state = @@ -851,19 +857,25 @@ const { return } - prDrawerController.hydrateActivePrContext({ - baseBranch: typeof workspace.base === 'string' ? workspace.base : '', - headBranch: typeof workspace.head === 'string' ? workspace.head : '', - prTitle: typeof workspace.prTitle === 'string' ? workspace.prTitle : '', - prBody: typeof githubPrBody?.value === 'string' ? githubPrBody.value : '', - pullRequestNumber: - typeof workspace.prNumber === 'number' && Number.isFinite(workspace.prNumber) - ? workspace.prNumber - : null, - pullRequestUrl: '', - renderMode: normalizeRenderMode(workspace.renderMode), - styleMode: styleMode.value, - }) + prDrawerController.hydrateActivePrContext( + { + baseBranch: typeof workspace.base === 'string' ? workspace.base : '', + headBranch: typeof workspace.head === 'string' ? workspace.head : '', + prTitle: typeof workspace.prTitle === 'string' ? workspace.prTitle : '', + prBody: typeof githubPrBody?.value === 'string' ? githubPrBody.value : '', + pullRequestNumber: + typeof workspace.prNumber === 'number' && Number.isFinite(workspace.prNumber) + ? workspace.prNumber + : null, + pullRequestUrl: '', + renderMode: normalizeRenderMode(workspace.renderMode), + styleMode: styleMode.value, + }, + { + repositoryFullName: + typeof workspace.repo === 'string' ? workspace.repo.trim() : '', + }, + ) }, }) @@ -916,8 +928,8 @@ const setWorkspacePrNumber = nextValue => { const persistWorkspacePrContextState = nextState => { setWorkspacePrContextState(nextState) - queueWorkspaceSave() - void flushWorkspaceSave().catch(() => { + queueWorkspaceSave({ preserveRecordId: true }) + void flushWorkspaceSave({ preserveRecordId: true }).catch(() => { /* Save failures are already surfaced through saver onError. */ }) } @@ -963,9 +975,42 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr }, utils: { toNonEmptyWorkspaceText, + createWorkspaceRecordId, + toWorkspaceRecordKey, }, }) +const archivePrSessionAndStartFreshLocal = ({ result, archivedState, statusMessage }) => { + hasObservedActivePrContextInSession = false + setWorkspacePrNumber(result?.pullRequestNumber) + byotControls.clearSelectedRepositoryPreference() + workspaceRepositoryFullName = '' + workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({ + archivedState, + statusMessage, + }) +} + +const onPrContextStateChange = createPrContextStateChangeHandler({ + toNonEmptyWorkspaceText, + toPullRequestNumber, + parsePullRequestNumberFromUrl, + getCurrentSelectedRepositoryFullName, + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + setWorkspaceRepositoryFullName: value => (workspaceRepositoryFullName = value), + getWorkspacePrContextState: () => workspacePrContextState, + getHasObservedActivePrContextInSession: () => hasObservedActivePrContextInSession, + setHasObservedActivePrContextInSession: value => + (hasObservedActivePrContextInSession = Boolean(value)), + githubPrStatus, + githubPrHeadBranch, + githubPrTitle, + workspacePrSessionHandoffController, + setWorkspacePrNumber, + persistWorkspacePrContextState, + editedIndicatorVisibilityController, +}) + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -988,6 +1033,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getCurrentSelectedRepository, setCurrentSelectedRepository: fullName => byotControls.setSelectedRepository(fullName), + clearCurrentSelectedRepository: () => + byotControls.clearSelectedRepositoryPreference(), }, ui: { aiChatToggle, @@ -1018,6 +1065,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesDrawer, workspacesClose, workspacesStatus, + workspacesRepository, workspacesSearch, workspacesSelect, workspacesOpen, @@ -1031,6 +1079,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ listLocalContextRecords, refreshLocalContextOptions, applyWorkspaceRecord, + syncActiveWorkspaceRepositoryScope, getWorkspacePrFileCommits, getEditorSyncTargets, reconcileWorkspaceTabsWithPushUpdates, @@ -1040,50 +1089,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getStyleMode: () => styleMode.value, getActivePrContextSyncKey, prContextUi, - onPrContextStateChange: activeContext => { - if (activeContext?.prTitle) { - hasObservedActivePrContextInSession = true - workspacePrSessionHandoffController.setLastKnownPrContextMeta({ - baseBranch: - typeof activeContext.baseBranch === 'string' ? activeContext.baseBranch : '', - headBranch: - typeof activeContext.headBranch === 'string' ? activeContext.headBranch : '', - prTitle: typeof activeContext.prTitle === 'string' ? activeContext.prTitle : '', - }) - const nextPrNumber = - toPullRequestNumber(activeContext.pullRequestNumber) ?? - parsePullRequestNumberFromUrl(activeContext.pullRequestUrl) - setWorkspacePrNumber(nextPrNumber) - persistWorkspacePrContextState('active') - } else if (workspacePrContextState === 'active') { - const statusText = - typeof githubPrStatus?.textContent === 'string' - ? githubPrStatus.textContent - : '' - const hasClosedStatus = statusText.includes( - 'Saved pull request context is not open on GitHub.', - ) - const hasHeadBranch = - typeof githubPrHeadBranch?.value === 'string' && - githubPrHeadBranch.value.trim().length > 0 - const hasPrTitle = - typeof githubPrTitle?.value === 'string' && - githubPrTitle.value.trim().length > 0 - - if (hasClosedStatus) { - hasObservedActivePrContextInSession = false - persistWorkspacePrContextState('closed') - } else if ( - hasObservedActivePrContextInSession && - (!hasHeadBranch || !hasPrTitle) - ) { - hasObservedActivePrContextInSession = false - setWorkspacePrNumber(null) - persistWorkspacePrContextState('inactive') - } - } - editedIndicatorVisibilityController.refreshIndicators() - }, + onPrContextStateChange, onPrContextVerifiedClosed: result => { hasObservedActivePrContextInSession = false const nextPrNumber = @@ -1095,53 +1101,16 @@ const githubWorkflows = createGitHubWorkflowsSetup({ persistWorkspacePrContextState('closed') const persistClosedRecords = async () => { - const selectedRepository = toNonEmptyWorkspaceText( - getCurrentSelectedRepositoryFullName(), - ) - const normalizedHead = toNonEmptyWorkspaceText(githubPrHeadBranch?.value) - const siblingRecords = selectedRepository - ? await workspaceStorage.listWorkspaces({ repo: selectedRepository }) - : await workspaceStorage.listWorkspaces() - - const activeRecordsForContext = siblingRecords.filter(record => { - if (!record || typeof record !== 'object') { - return false - } - - if (toNonEmptyWorkspaceText(record.prContextState).toLowerCase() !== 'active') { - return false - } - - const hasMatchingPrNumber = - typeof nextPrNumber === 'number' && - Number.isFinite(nextPrNumber) && - typeof record.prNumber === 'number' && - Number.isFinite(record.prNumber) && - record.prNumber === nextPrNumber - - const hasMatchingHead = - normalizedHead && toNonEmptyWorkspaceText(record.head) === normalizedHead - - return hasMatchingPrNumber || hasMatchingHead - }) - - if (activeRecordsForContext.length === 0) { - return - } - - const now = Date.now() - await Promise.all( - activeRecordsForContext.map(record => - workspaceStorage.upsertWorkspace({ - ...record, - prContextState: 'closed', - prNumber: nextPrNumber, - lastModified: now, - }), + await persistClosedPrContextRecords({ + workspaceStorage, + selectedRepository: toNonEmptyWorkspaceText( + getCurrentSelectedRepositoryFullName(), ), - ) - - await refreshLocalContextOptions() + nextPrNumber, + normalizedHead: toNonEmptyWorkspaceText(githubPrHeadBranch?.value), + toNonEmptyWorkspaceText, + refreshLocalContextOptions, + }) } void persistClosedRecords().catch(() => { @@ -1149,18 +1118,16 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }) }, onPrContextClosed: result => { - hasObservedActivePrContextInSession = false - setWorkspacePrNumber(result?.pullRequestNumber) - workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({ + archivePrSessionAndStartFreshLocal({ + result, archivedState: 'closed', statusMessage: 'PR context closed. Open Workspaces to load a saved workspace or continue with this local workspace.', }) }, onPrContextDisconnected: result => { - hasObservedActivePrContextInSession = false - setWorkspacePrNumber(result?.pullRequestNumber) - workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({ + archivePrSessionAndStartFreshLocal({ + result, archivedState: 'disconnected', statusMessage: 'PR context disconnected. Open Workspaces to load a saved workspace or continue with this local workspace.', diff --git a/src/index.html b/src/index.html index f3c62eb..20fee1c 100644 --- a/src/index.html +++ b/src/index.html @@ -808,10 +808,20 @@

Workspaces

aria-label="Workspaces status" data-level="neutral" > - Manage local workspace contexts stored in this browser. + Choose a repository scope, then manage local workspace contexts.

+ +