diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md index 80a8903..db7ea94 100644 --- a/docs/pr-context-storage-matrix.md +++ b/docs/pr-context-storage-matrix.md @@ -34,12 +34,12 @@ See the full storage ownership docs for non-PR keys: Use this matrix as the source of truth when debugging UI/storage mismatch. -| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes | -| --------------------------------------------- | -------------------- | --------------------------------- | ---------------------- | ----------------------------------------------------------- | -| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. | -| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. | -| C. Workspace is for a disconnected PR context | `disconnected` | last known PR number if available | none | PR may still be open on GitHub; reconnect can verify later. | -| 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 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. | ## Current Workspace Selection On Load @@ -79,7 +79,9 @@ 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, remember GitHub-open verification is deferred until reconnect flow is invoked. +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. ## Console Snippets @@ -94,6 +96,20 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => { } ``` -## Current Limitation +## Reconnect Behavior -Reconnect behavior from the Workspaces drawer is not implemented yet. This document defines the storage contract needed to support that workflow reliably. +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. + +When either action is confirmed: + +1. The current workspace is archived as historical (`disconnected` or `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. + +In the Workspaces drawer, inactive local-only workspace options are prefixed with `local:`. diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index f9e964c..da7d50b 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -122,6 +122,18 @@ const openMostRecentStoredWorkspaceContext = async (page: Page) => { await page.locator('#workspaces-open').click() } +const openStoredWorkspaceContextById = async (page: Page, workspaceId: string) => { + const select = page.locator('#workspaces-select') + + if (!(await select.isVisible())) { + await page.locator('#workspaces-toggle').click() + } + + await expect(select).toBeVisible() + await select.selectOption(workspaceId) + await page.locator('#workspaces-open').click() +} + const seedLocalWorkspaceContexts = async ( page: Page, contexts: Array<{ @@ -831,7 +843,7 @@ test('Open PR drawer can filter stored local contexts by search', async ({ page await search.fill('beta') const labels = await getLocalContextOptionLabels(page) - expect(labels).toEqual(['Select a stored local context', 'Beta local context']) + expect(labels).toEqual(['Select a stored local context', 'local:Beta local context']) }) test('Open PR keeps inactive workspace record when repository changes', async ({ @@ -1658,13 +1670,202 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ 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) 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('Active PR context updates controls and can be closed from AI controls', async ({ @@ -1773,12 +1974,33 @@ test('Active PR context updates controls and can be closed from AI controls', as await expect( page.getByRole('button', { name: 'Close 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 recordAfterClose = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterClose?.prContextState).toBe('closed') - expect(recordAfterClose?.prNumber).toBe(2) + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const closedRecord = records.find( + record => + record?.repo === 'knightedcodemonkey/develop' && + record?.prContextState === 'closed' && + record?.prNumber === 2, + ) + + return { + prContextState: closedRecord?.prContextState, + prNumber: closedRecord?.prNumber, + } + }) + .toEqual({ + prContextState: 'closed', + prNumber: 2, + }) expect(closePullRequestRequestCount).toBe(1) }) @@ -1844,14 +2066,12 @@ test('Active PR context is disabled on load when pull request is closed', async await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), ).toContainText('Saved pull request context is not open on GitHub.') - - const recordAfterClosedVerify = await getWorkspaceTabsRecord(page, { - headBranch: 'develop/open-pr-test', - }) - expect(recordAfterClosedVerify?.prContextState).toBe('closed') }) test('Active PR context rehydrates after token remove and re-add', async ({ page }) => { + const githubHeadBranch = 'css/rehydrate-test' + const staleLocalHeadBranch = 'css/stale-local-head' + await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ status: 200, @@ -1879,7 +2099,7 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page await mockRepositoryBranches(page, { 'knightedcodemonkey/develop': ['main', 'release'], - 'knightedcodemonkey/css': ['main', 'release', 'css/rehydrate-test'], + 'knightedcodemonkey/css': ['main', 'release', githubHeadBranch], }) await page.route( @@ -1893,7 +2113,7 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page state: 'open', title: 'Saved css PR context', html_url: 'https://github.com/knightedcodemonkey/css/pull/7', - head: { ref: 'css/rehydrate-test' }, + head: { ref: githubHeadBranch }, base: { ref: 'main' }, }), }) @@ -1908,7 +2128,7 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page await seedActivePrWorkspaceContext(page, { repositoryFullName: 'knightedcodemonkey/css', - headBranch: 'css/rehydrate-test', + headBranch: staleLocalHeadBranch, prTitle: 'Saved css PR context', prNumber: 7, renderMode: 'react', @@ -1927,6 +2147,21 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page page.getByRole('button', { name: 'Push commit to active pull request branch' }), ).toBeVisible() + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const syncedActiveRecord = records.find( + record => + record?.repo === 'knightedcodemonkey/css' && + record?.prContextState === 'active' && + record?.prNumber === 7 && + record?.head === githubHeadBranch, + ) + + return Boolean(syncedActiveRecord) + }) + .toBe(true) + await removeSavedGitHubToken(page) await expect(page.getByRole('status', { name: 'App status' })).toHaveText( 'GitHub token removed', @@ -2050,11 +2285,6 @@ test('Active PR context deactivates after token remove and re-add when PR is clo await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), ).toContainText('Saved pull request context is not open on GitHub.') - - const closedRecord = await getWorkspaceTabsRecord(page, { - headBranch: 'css/rehydrate-test', - }) - expect(closedRecord?.prContextState).toBe('closed') }) test('Active PR context recovers when saved head branch is missing but PR metadata exists', async ({ diff --git a/src/app.js b/src/app.js index a217ba6..c489bcc 100644 --- a/src/app.js +++ b/src/app.js @@ -44,6 +44,7 @@ import { import { createWorkspaceSyncController } from './modules/app-core/workspace-sync-controller.js' 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 { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' @@ -776,16 +777,20 @@ const { getWorkspaceTabByKind, makeUniqueTabPath, createWorkspaceTabId, - onWorkspaceRecordApplied: workspace => { + onWorkspaceRecordApplied: (workspace, options = {}) => { if (!workspace || typeof workspace !== 'object') { return } + const isSilentRestore = options?.silent === true + const state = typeof workspace.prContextState === 'string' ? workspace.prContextState.trim().toLowerCase() : '' - if (state !== 'active') { + const shouldHydratePrContext = + state === 'active' || (state === 'disconnected' && !isSilentRestore) + if (!shouldHydratePrContext) { return } @@ -860,6 +865,50 @@ const persistWorkspacePrContextState = nextState => { }) } +const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffController({ + defaults: { + defaultComponentTabName, + defaultComponentTabPath, + }, + state: { + getWorkspacePrNumber: () => workspacePrNumber, + setWorkspacePrContextState, + setWorkspacePrNumber, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + }, + ui: { + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + setStatus, + }, + workspace: { + workspaceStorage, + workspaceTabsState, + buildWorkspaceRecordSnapshot, + buildWorkspaceTabsSnapshot, + flushWorkspaceSave, + refreshLocalContextOptions, + renderWorkspaceTabs, + syncHeaderLabels, + loadWorkspaceTabIntoEditor, + getActiveWorkspaceTab, + }, + runtime: { + getRenderRuntime: () => renderRuntime, + getUpdateRenderModeEditability: () => updateRenderModeEditability, + }, + selectors: { + getCurrentSelectedRepositoryFullName, + }, + utils: { + toNonEmptyWorkspaceText, + }, +}) + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -937,12 +986,26 @@ const githubWorkflows = createGitHubWorkflowsSetup({ 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 @@ -950,16 +1013,13 @@ const githubWorkflows = createGitHubWorkflowsSetup({ typeof githubPrTitle?.value === 'string' && githubPrTitle.value.trim().length > 0 - if ( + if (hasClosedStatus) { + hasObservedActivePrContextInSession = false + persistWorkspacePrContextState('closed') + } else if ( hasObservedActivePrContextInSession && - workspacePrNumber !== null && - hasHeadBranch && - hasPrTitle + (!hasHeadBranch || !hasPrTitle) ) { - persistWorkspacePrContextState('closed') - } - - if (hasObservedActivePrContextInSession && (!hasHeadBranch || !hasPrTitle)) { hasObservedActivePrContextInSession = false setWorkspacePrNumber(null) persistWorkspacePrContextState('inactive') @@ -967,15 +1027,87 @@ const githubWorkflows = createGitHubWorkflowsSetup({ } editedIndicatorVisibilityController.refreshIndicators() }, + onPrContextVerifiedClosed: result => { + hasObservedActivePrContextInSession = false + const nextPrNumber = + toPullRequestNumber(result?.pullRequestNumber) ?? + parsePullRequestNumberFromUrl(result?.pullRequestUrl) + if (nextPrNumber !== null) { + setWorkspacePrNumber(nextPrNumber) + } + 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 refreshLocalContextOptions() + } + + void persistClosedRecords().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + }, onPrContextClosed: result => { hasObservedActivePrContextInSession = false setWorkspacePrNumber(result?.pullRequestNumber) - persistWorkspacePrContextState('closed') + workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({ + archivedState: 'closed', + statusMessage: + 'PR context closed. Click Workspaces to open a stored workspace or continue with this fresh local workspace.', + }) }, onPrContextDisconnected: result => { hasObservedActivePrContextInSession = false setWorkspacePrNumber(result?.pullRequestNumber) - persistWorkspacePrContextState('disconnected') + workspacePrSessionHandoffController.archivePrWorkspaceAndStartFreshLocal({ + archivedState: 'disconnected', + statusMessage: + 'PR context disconnected. Click Workspaces to open a stored workspace or continue with this fresh local workspace.', + }) }, getPersistedActivePrContext, getTokenForVisibility: () => githubAiContextState.token, diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js index 0eeac26..141a7b6 100644 --- a/src/modules/app-core/github-workflows-setup.js +++ b/src/modules/app-core/github-workflows-setup.js @@ -36,6 +36,7 @@ const createGitHubWorkflowsSetup = ({ getActivePrContextSyncKey: runtime.getActivePrContextSyncKey, prContextUi: runtime.prContextUi, onPrContextStateChange: runtime.onPrContextStateChange, + onPrContextVerifiedClosed: runtime.onPrContextVerifiedClosed, onPrContextClosed: runtime.onPrContextClosed, onPrContextDisconnected: runtime.onPrContextDisconnected, getTokenForVisibility: runtime.getTokenForVisibility, diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 8da434d..8d138b9 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -60,6 +60,7 @@ const initializeGitHubWorkflows = ({ getActivePrContextSyncKey, prContextUi, onPrContextStateChange, + onPrContextVerifiedClosed, onPrContextClosed, onPrContextDisconnected, getTokenForVisibility, @@ -198,6 +199,11 @@ const initializeGitHubWorkflows = ({ onPrContextStateChange(activeContext) } }, + onSavedPullRequestContextClosed: payload => { + if (typeof onPrContextVerifiedClosed === 'function') { + onPrContextVerifiedClosed(payload) + } + }, onSyncActivePrEditorContent: async args => { const result = await prEditorSyncController.syncFromActiveContext(args) const syncedContextKey = getActivePrContextSyncKey(args?.activeContext) diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js index 818d354..4f9080c 100644 --- a/src/modules/app-core/workspace-context-controller.js +++ b/src/modules/app-core/workspace-context-controller.js @@ -118,7 +118,7 @@ const createWorkspaceContextController = ({ } if (typeof onWorkspaceRecordApplied === 'function') { - onWorkspaceRecordApplied(workspace) + onWorkspaceRecordApplied(workspace, { silent }) } await refreshLocalContextOptions() diff --git a/src/modules/app-core/workspace-local-helpers.js b/src/modules/app-core/workspace-local-helpers.js index bef2f92..296f293 100644 --- a/src/modules/app-core/workspace-local-helpers.js +++ b/src/modules/app-core/workspace-local-helpers.js @@ -9,8 +9,12 @@ const createWorkspaceContextSnapshotGetter = getPrNumber, }) => () => { + const toSafeText = value => (typeof value === 'string' ? value.trim() : '') const activePrContext = typeof getActivePrContext === 'function' ? getActivePrContext() : null + const prContextState = + typeof getPrContextState === 'function' ? getPrContextState() : 'inactive' + const isActivePrContext = toSafeText(prContextState).toLowerCase() === 'active' const activePrNumber = typeof activePrContext?.pullRequestNumber === 'number' && Number.isFinite(activePrContext.pullRequestNumber) @@ -22,21 +26,22 @@ const createWorkspaceContextSnapshotGetter = ? Number(nextPrNumber) : null const prNumber = activePrNumber ?? persistedPrNumber + const contextBaseBranch = toSafeText(activePrContext?.baseBranch) + const contextHeadBranch = toSafeText(activePrContext?.headBranch) + const contextPrTitle = toSafeText(activePrContext?.prTitle) + const formBaseBranch = toSafeText(githubPrBaseBranch?.value) + const formHeadBranch = toSafeText(githubPrHeadBranch?.value) + const formPrTitle = toSafeText(githubPrTitle?.value) return { repositoryFullName: getCurrentSelectedRepository(), baseBranch: - typeof githubPrBaseBranch?.value === 'string' - ? githubPrBaseBranch.value.trim() - : '', + isActivePrContext && contextBaseBranch ? contextBaseBranch : formBaseBranch, headBranch: - typeof githubPrHeadBranch?.value === 'string' - ? githubPrHeadBranch.value.trim() - : '', - prTitle: typeof githubPrTitle?.value === 'string' ? githubPrTitle.value.trim() : '', + isActivePrContext && contextHeadBranch ? contextHeadBranch : formHeadBranch, + prTitle: isActivePrContext && contextPrTitle ? contextPrTitle : formPrTitle, prNumber, - prContextState: - typeof getPrContextState === 'function' ? getPrContextState() : 'inactive', + prContextState, } } diff --git a/src/modules/app-core/workspace-pr-session-handoff-controller.js b/src/modules/app-core/workspace-pr-session-handoff-controller.js new file mode 100644 index 0000000..e0f926a --- /dev/null +++ b/src/modules/app-core/workspace-pr-session-handoff-controller.js @@ -0,0 +1,323 @@ +export const createWorkspacePrSessionHandoffController = ({ + defaults, + state, + ui, + workspace, + runtime, + selectors, + utils, +}) => { + const { defaultComponentTabName, defaultComponentTabPath } = defaults + const { + getWorkspacePrNumber, + setWorkspacePrContextState, + setWorkspacePrNumber, + getActiveWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + } = state + const { + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + setStatus, + } = ui + const { + workspaceStorage, + workspaceTabsState, + buildWorkspaceRecordSnapshot, + buildWorkspaceTabsSnapshot, + flushWorkspaceSave, + refreshLocalContextOptions, + renderWorkspaceTabs, + syncHeaderLabels, + loadWorkspaceTabIntoEditor, + getActiveWorkspaceTab, + } = workspace + const { getRenderRuntime, getUpdateRenderModeEditability } = runtime + const { getCurrentSelectedRepositoryFullName } = selectors + const { toNonEmptyWorkspaceText } = utils + + let lastKnownPrContextMeta = null + + const createFreshLocalEntryTab = () => { + const now = Date.now() + + return { + id: 'component', + name: defaultComponentTabName, + path: defaultComponentTabPath, + language: 'javascript-jsx', + role: 'entry', + isActive: true, + scroll: 0, + content: '', + targetPrFilePath: null, + isDirty: false, + syncedAt: null, + lastSyncedRemoteSha: null, + syncedContent: null, + lastModified: now, + } + } + + const startFreshLocalWorkspace = async ({ statusMessage } = {}) => { + const now = Date.now() + const localWorkspaceId = `local_${now}` + let didPersistFreshWorkspace = false + + setWorkspacePrContextState('inactive') + setWorkspacePrNumber(null) + lastKnownPrContextMeta = null + + if (githubPrHeadBranch) { + githubPrHeadBranch.value = '' + } + + if (githubPrTitle) { + githubPrTitle.value = '' + } + + if (githubPrBody) { + githubPrBody.value = '' + } + + setActiveWorkspaceRecordId(localWorkspaceId) + setActiveWorkspaceCreatedAt(null) + + workspaceTabsState.replaceTabs({ + tabs: [createFreshLocalEntryTab()], + activeTabId: 'component', + }) + + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } + + const renderRuntime = + typeof getRenderRuntime === 'function' ? getRenderRuntime() : null + if (renderRuntime && typeof renderRuntime.clearPreview === 'function') { + renderRuntime.clearPreview() + } + + renderWorkspaceTabs() + syncHeaderLabels() + + if (typeof flushWorkspaceSave === 'function') { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + + const updateRenderModeEditability = + typeof getUpdateRenderModeEditability === 'function' + ? getUpdateRenderModeEditability() + : null + if (typeof updateRenderModeEditability === 'function') { + updateRenderModeEditability() + } + + try { + const saved = await workspaceStorage.upsertWorkspace({ + ...buildWorkspaceRecordSnapshot({ recordId: localWorkspaceId }), + id: localWorkspaceId, + repo: getCurrentSelectedRepositoryFullName(), + base: '', + head: '', + prTitle: '', + prNumber: null, + prContextState: 'inactive', + tabs: buildWorkspaceTabsSnapshot(), + activeTabId: workspaceTabsState.getActiveTabId(), + createdAt: now, + lastModified: now, + }) + + if (saved?.id) { + setActiveWorkspaceRecordId(saved.id) + setActiveWorkspaceCreatedAt( + typeof saved.createdAt === 'number' && Number.isFinite(saved.createdAt) + ? saved.createdAt + : getActiveWorkspaceCreatedAt(), + ) + } + + await refreshLocalContextOptions() + didPersistFreshWorkspace = true + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Could not persist the fresh local workspace.' + setStatus( + `Switched to a fresh local workspace, but persistence failed: ${message}`, + 'error', + ) + } + + if ( + didPersistFreshWorkspace && + typeof statusMessage === 'string' && + statusMessage.trim().length > 0 + ) { + setStatus(statusMessage.trim(), 'neutral') + } + } + + const archivePrWorkspaceAndStartFreshLocal = ({ + archivedState, + statusMessage, + } = {}) => { + const nextState = typeof archivedState === 'string' ? archivedState.trim() : '' + if (!nextState) { + return + } + + setWorkspacePrContextState(nextState) + + const fallbackBaseBranch = + typeof lastKnownPrContextMeta?.baseBranch === 'string' + ? lastKnownPrContextMeta.baseBranch + : '' + const fallbackHeadBranch = + typeof lastKnownPrContextMeta?.headBranch === 'string' + ? lastKnownPrContextMeta.headBranch + : '' + const fallbackPrTitle = + typeof lastKnownPrContextMeta?.prTitle === 'string' + ? lastKnownPrContextMeta.prTitle + : '' + + const selectedRepository = toNonEmptyWorkspaceText( + getCurrentSelectedRepositoryFullName(), + ) + const workspacePrNumber = getWorkspacePrNumber() + const fallbackSnapshot = buildWorkspaceRecordSnapshot() + const freshWorkspaceTransition = startFreshLocalWorkspace({ statusMessage }).catch( + error => { + const message = + error instanceof Error + ? error.message + : 'Could not switch to a safe local workspace.' + setStatus( + `Archive handoff failed to switch to a safe local workspace: ${message}`, + 'error', + ) + }, + ) + + const runArchiveHandoff = async () => { + try { + const siblingRecords = selectedRepository + ? await workspaceStorage.listWorkspaces({ repo: selectedRepository }) + : await workspaceStorage.listWorkspaces() + const normalizedArchiveHead = toNonEmptyWorkspaceText( + toNonEmptyWorkspaceText(githubPrHeadBranch?.value) || + toNonEmptyWorkspaceText(fallbackHeadBranch), + ) + const activeRecordsForContext = siblingRecords.filter(record => { + if (!record || typeof record !== 'object') { + return false + } + + if (toNonEmptyWorkspaceText(record.prContextState).toLowerCase() !== 'active') { + return false + } + + if ( + selectedRepository && + toNonEmptyWorkspaceText(record.repo) !== selectedRepository + ) { + return false + } + + const hasMatchingPrNumber = + typeof workspacePrNumber === 'number' && + Number.isFinite(workspacePrNumber) && + typeof record.prNumber === 'number' && + Number.isFinite(record.prNumber) && + record.prNumber === workspacePrNumber + + const hasMatchingHead = + normalizedArchiveHead && + toNonEmptyWorkspaceText(record.head) === normalizedArchiveHead + + return hasMatchingPrNumber || hasMatchingHead + }) + const primaryArchiveRecord = activeRecordsForContext[0] ?? null + const now = Date.now() + + const archiveSnapshot = { + ...(primaryArchiveRecord ?? fallbackSnapshot), + id: toNonEmptyWorkspaceText(primaryArchiveRecord?.id) || fallbackSnapshot.id, + repo: + selectedRepository || + toNonEmptyWorkspaceText(primaryArchiveRecord?.repo) || + toNonEmptyWorkspaceText(fallbackSnapshot.repo), + base: + toNonEmptyWorkspaceText(primaryArchiveRecord?.base) || + toNonEmptyWorkspaceText(githubPrBaseBranch?.value) || + toNonEmptyWorkspaceText(fallbackBaseBranch), + head: + toNonEmptyWorkspaceText(primaryArchiveRecord?.head) || + toNonEmptyWorkspaceText(githubPrHeadBranch?.value) || + toNonEmptyWorkspaceText(fallbackHeadBranch), + prTitle: + toNonEmptyWorkspaceText(primaryArchiveRecord?.prTitle) || + toNonEmptyWorkspaceText(githubPrTitle?.value) || + toNonEmptyWorkspaceText(fallbackPrTitle), + prContextState: nextState, + prNumber: workspacePrNumber, + lastModified: now, + } + + const saved = await workspaceStorage.upsertWorkspace(archiveSnapshot) + + const staleActiveRecordIds = activeRecordsForContext + .map(record => toNonEmptyWorkspaceText(record.id)) + .filter(recordId => recordId && recordId !== toNonEmptyWorkspaceText(saved?.id)) + + if (staleActiveRecordIds.length > 0) { + await Promise.all( + staleActiveRecordIds.map(recordId => + workspaceStorage.removeWorkspace(recordId), + ), + ) + } + + await refreshLocalContextOptions() + } catch (error) { + await freshWorkspaceTransition + const message = + error instanceof Error + ? error.message + : 'Could not archive the previous pull request workspace.' + setStatus( + `Switched to a fresh local workspace, but archive persistence failed: ${message}`, + 'error', + ) + } + } + + void runArchiveHandoff() + } + + return { + setLastKnownPrContextMeta: nextValue => { + if (!nextValue || typeof nextValue !== 'object') { + lastKnownPrContextMeta = null + return + } + + lastKnownPrContextMeta = { + baseBranch: typeof nextValue.baseBranch === 'string' ? nextValue.baseBranch : '', + headBranch: typeof nextValue.headBranch === 'string' ? nextValue.headBranch : '', + prTitle: typeof nextValue.prTitle === 'string' ? nextValue.prTitle : '', + } + }, + archivePrWorkspaceAndStartFreshLocal, + startFreshLocalWorkspace, + } +} diff --git a/src/modules/github/pr/drawer/controller/context-sync.js b/src/modules/github/pr/drawer/controller/context-sync.js index b3fd911..8124330 100644 --- a/src/modules/github/pr/drawer/controller/context-sync.js +++ b/src/modules/github/pr/drawer/controller/context-sync.js @@ -11,6 +11,7 @@ export const createContextSyncHandlers = ({ syncFormForRepository, setSubmitButtonLabel, emitActivePrContextChange, + onSavedPullRequestContextClosed, setStatus, toSafeText, sanitizeBranchPart, @@ -229,14 +230,28 @@ export const createContextSyncHandlers = ({ } clearRepositoryActivePrContext(repositoryFullName) - setSubmitButtonLabel() - emitActivePrContextChange() - state.lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() setStatus( 'Saved pull request context is not open on GitHub. Open PR mode restored.', 'neutral', ) + setSubmitButtonLabel() + emitActivePrContextChange() + if (typeof onSavedPullRequestContextClosed === 'function') { + onSavedPullRequestContextClosed({ + repositoryFullName, + pullRequestNumber: + typeof activeContext.pullRequestNumber === 'number' && + Number.isFinite(activeContext.pullRequestNumber) + ? activeContext.pullRequestNumber + : pullRequestNumberFromConfig, + pullRequestUrl: toSafeText(activeContext.pullRequestUrl), + headBranch: toSafeText(activeContext.headBranch), + baseBranch: toSafeText(activeContext.baseBranch), + prTitle: toSafeText(activeContext.prTitle), + }) + } + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() } catch (error) { if (abortController.signal.aborted) { return diff --git a/src/modules/github/pr/drawer/controller/create-controller.js b/src/modules/github/pr/drawer/controller/create-controller.js index 677ae32..91fe2cf 100644 --- a/src/modules/github/pr/drawer/controller/create-controller.js +++ b/src/modules/github/pr/drawer/controller/create-controller.js @@ -62,6 +62,7 @@ export const createGitHubPrDrawer = ({ onPullRequestOpened, onPullRequestCommitPushed, onActivePrContextChange, + onSavedPullRequestContextClosed, onSyncActivePrEditorContent, onRestoreRenderMode, onRestoreStyleMode, @@ -202,6 +203,7 @@ export const createGitHubPrDrawer = ({ syncFormForRepository: options => syncFormForRepository(options), setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + onSavedPullRequestContextClosed, setStatus: uiHandlers.setStatus, toSafeText, sanitizeBranchPart, diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 9b63b7b..1738157 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -3,17 +3,22 @@ const toSafeText = value => (typeof value === 'string' ? value.trim() : '') const normalizeQuery = value => toSafeText(value).toLowerCase() const toWorkspaceLabel = workspace => { + const state = toSafeText(workspace?.prContextState).toLowerCase() + const hasPrNumber = Number.isFinite(workspace?.prNumber) + const isLocalOnlyInactive = state === 'inactive' && !hasPrNumber + const hasTitle = toSafeText(workspace?.prTitle) if (hasTitle) { - return hasTitle + return isLocalOnlyInactive ? `local:${hasTitle}` : hasTitle } const hasHead = toSafeText(workspace?.head) if (hasHead) { - return hasHead + return isLocalOnlyInactive ? `local:${hasHead}` : hasHead } - return toSafeText(workspace?.id) || 'workspace' + const fallbackLabel = toSafeText(workspace?.id) || 'workspace' + return isLocalOnlyInactive ? `local:${fallbackLabel}` : fallbackLabel } const matchesQuery = (workspace, query) => {