diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md index dff8446..80a8903 100644 --- a/docs/pr-context-storage-matrix.md +++ b/docs/pr-context-storage-matrix.md @@ -28,6 +28,7 @@ See the full storage ownership docs for non-PR keys: ### localStorage - Not used for PR context state. +- No legacy PR-context migration/cleanup path is supported. ## Status Matrix @@ -40,6 +41,29 @@ Use this matrix as the source of truth when debugging UI/storage mismatch. | 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. | +## 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. + +Selection order: + +1. Load records for the currently selected repository (`repo` match). +2. Compute a preferred id from in-memory state: + +- Existing in-memory active record id when available. +- Otherwise canonical id derived from current repository + head. + +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. +6. Otherwise fall back to the first record returned by IDB ordering. + +Notes: + +- No `active workspace` pointer is stored in `localStorage`. +- Restore behavior is intentionally derived from IDB workspace records + in-memory runtime state. +- This avoids cross-storage drift between `localStorage` and IndexedDB. + ## Why PR Context Lives In IDB Only PR workflow state is part of workspace state. diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 91ce10c..ab9b910 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -337,6 +337,38 @@ const getWorkspaceTabsRecord = async ( ) } +const getAllWorkspaceRecords = async (page: Page) => { + return page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records + } finally { + db.close() + } + }) +} + test('Open PR drawer confirms and submits component/styles filepaths', async ({ page, }) => { @@ -783,7 +815,219 @@ 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', 'Local: Beta local context']) + expect(labels).toEqual(['Select a stored local context', 'Beta local context']) +}) + +test('Open PR keeps inactive workspace record when repository changes', async ({ + page, +}) => { + const oldRepository = 'knightedcodemonkey/contract-case' + const newRepository = 'knightedcodemonkey/develop-sandbox' + const headBranch = 'feat/component-sync' + const oldWorkspaceId = 'repo_knightedcodemonkey_contract-case_feat-component-sync' + + 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: oldRepository, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 13, + owner: { login: 'knightedcodemonkey' }, + name: 'develop-sandbox', + full_name: newRepository, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [oldRepository]: ['main'], + [newRepository]: ['main', 'release'], + }) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/ref/**`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ object: { sha: 'branch-head-sha' } }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/refs`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/commits/branch-head-sha`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'branch-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/trees`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/commits`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/refs/**`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/contents/**`, + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route(`https://api.github.com/repos/${newRepository}/pulls`, async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 88, + html_url: `https://github.com/${newRepository}/pull/88`, + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: oldWorkspaceId, + repo: oldRepository, + base: 'main', + head: headBranch, + prTitle: 'Seeded inactive context', + 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 = () =>
Seeded workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + }, + ], + activeTabId: 'component', + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .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 page.getByRole('button', { name: 'Workspaces' }).click() + await page.locator('#workspaces-select').selectOption(oldWorkspaceId) + await page.locator('#workspaces-open').click() + + await ensureOpenPrDrawerOpen(page) + await repoSelect.selectOption(newRepository) + + await page.getByLabel('Head').fill(headBranch) + await page.getByLabel('PR title').fill('Promote inactive context to active PR') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText(`Pull request opened: https://github.com/${newRepository}/pull/88`) + + const workspaceRecords = await getAllWorkspaceRecords(page) + const recordsByHead = workspaceRecords.filter( + record => + typeof record?.head === 'string' && record.head.trim().toLowerCase() === headBranch, + ) + + const expectedWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: newRepository, + 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 staleRepositoryRecords = workspaceRecords.filter( + record => record?.repo === oldRepository, + ) + expect(staleRepositoryRecords).toHaveLength(0) }) test('Open PR drawer uses Git Database API atomic commit path by default', async ({ @@ -2703,6 +2947,243 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re expect(contentsPutRequests).toHaveLength(0) }) +test('Reload keeps persisted active PR workspace context active', async ({ page }) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName, + headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + const workspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }) + + 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() + + const activeRecord = await getWorkspaceTabsRecord(page, { headBranch }) + expect(activeRecord?.id).toBe(workspaceId) + expect(activeRecord?.prContextState).toBe('active') + expect(activeRecord?.prNumber).toBe(2) + + const workspaceRecords = await getAllWorkspaceRecords(page) + const activeRecordsForPr = workspaceRecords.filter( + record => + record?.repo === repositoryFullName && + record?.prContextState === 'active' && + record?.prNumber === 2, + ) + expect(activeRecordsForPr).toHaveLength(1) +}) + +test('Reload prefers active PR workspace when mixed workspace records exist', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/open-pr-test' + const inactiveHeadBranch = 'feat/stale-local-workspace' + + 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 waitForAppReady(page, `${appEntryPath}`) + + const activeWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: activeHeadBranch, + }) + const inactiveWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: inactiveHeadBranch, + }) + + 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 = () =>
Inactive workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #444; }', + }, + ], + activeTabId: 'component', + }, + { + id: activeWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Active workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: tomato; }', + }, + ], + 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() + + const selectedRecord = await getWorkspaceTabsRecord(page, { + headBranch: activeHeadBranch, + }) + expect(selectedRecord?.id).toBe(activeWorkspaceId) + expect(selectedRecord?.prContextState).toBe('active') + expect(selectedRecord?.prNumber).toBe(2) +}) + test('Reloaded active PR context syncs editor content from GitHub branch and restores style mode', async ({ page, }) => { diff --git a/src/app.js b/src/app.js index ef8db78..59d298f 100644 --- a/src/app.js +++ b/src/app.js @@ -43,10 +43,6 @@ import { } from './modules/app-core/github-pr-icons.js' import { createWorkspaceSyncController } from './modules/app-core/workspace-sync-controller.js' import { createWorkspaceTabAddMenuUiController } from './modules/app-core/workspace-tab-add-menu-ui.js' -import { - clearLegacyPrConfigStorage, - legacyPrConfigStoragePrefix, -} from './modules/app-core/legacy-pr-config-storage.js' import { createPersistedActivePrContextGetter } from './modules/app-core/persisted-active-pr-context.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' @@ -342,8 +338,6 @@ const workspaceTabAddMenuUi = createWorkspaceTabAddMenuUiController({ addModuleButton: workspaceTabAddModule, }) -clearLegacyPrConfigStorage({ prefix: legacyPrConfigStoragePrefix }) - const { panelToolsState, applyEditorToolsVisibility, @@ -400,6 +394,7 @@ const githubAiContextState = { let workspacePrContextState = 'inactive' let workspacePrNumber = null +let hasObservedActivePrContextInSession = false const toPullRequestNumber = value => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { @@ -409,6 +404,10 @@ const toPullRequestNumber = value => { return null } +const setActiveWorkspaceRecordId = nextValue => { + activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue) +} + let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, @@ -469,8 +468,23 @@ const byotControls = createGitHubByotControls({ githubAiContextState.selectedRepository = repository 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 + } - activeWorkspaceRecordId = '' + setActiveWorkspaceRecordId('') activeWorkspaceCreatedAt = null void loadPreferredWorkspaceContext() .then(() => { @@ -490,7 +504,7 @@ const byotControls = createGitHubByotControls({ chatDrawerController.setSelectedRepository(selectedRepository) prDrawerController.setSelectedRepository(selectedRepository) - if (!activeWorkspaceRecordId) { + if (!activeWorkspaceRecordId || activeWorkspaceCreatedAt === null) { void loadPreferredWorkspaceContext() .then(() => { prDrawerController.syncRepositories() @@ -699,7 +713,7 @@ const { setStatus, getIsApplyingWorkspaceSnapshot: () => isApplyingWorkspaceSnapshot, getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, - setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), + setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), setWorkspacePrContextState: value => (workspacePrContextState = value), setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)), @@ -899,7 +913,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspace: { workspaceStorage, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, - setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), + setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), listLocalContextRecords, refreshLocalContextOptions, @@ -915,6 +929,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ prContextUi, onPrContextStateChange: activeContext => { if (activeContext?.prTitle) { + hasObservedActivePrContextInSession = true const nextPrNumber = toPullRequestNumber(activeContext.pullRequestNumber) ?? parsePullRequestNumberFromUrl(activeContext.pullRequestUrl) @@ -928,11 +943,17 @@ const githubWorkflows = createGitHubWorkflowsSetup({ typeof githubPrTitle?.value === 'string' && githubPrTitle.value.trim().length > 0 - if (workspacePrNumber !== null && hasHeadBranch && hasPrTitle) { + if ( + hasObservedActivePrContextInSession && + workspacePrNumber !== null && + hasHeadBranch && + hasPrTitle + ) { persistWorkspacePrContextState('closed') } - if (!hasHeadBranch || !hasPrTitle) { + if (hasObservedActivePrContextInSession && (!hasHeadBranch || !hasPrTitle)) { + hasObservedActivePrContextInSession = false setWorkspacePrNumber(null) persistWorkspacePrContextState('inactive') } @@ -940,10 +961,12 @@ const githubWorkflows = createGitHubWorkflowsSetup({ editedIndicatorVisibilityController.refreshIndicators() }, onPrContextClosed: result => { + hasObservedActivePrContextInSession = false setWorkspacePrNumber(result?.pullRequestNumber) persistWorkspacePrContextState('closed') }, onPrContextDisconnected: result => { + hasObservedActivePrContextInSession = false setWorkspacePrNumber(result?.pullRequestNumber) persistWorkspacePrContextState('disconnected') }, diff --git a/src/modules/app-core/legacy-pr-config-storage.js b/src/modules/app-core/legacy-pr-config-storage.js deleted file mode 100644 index 66edc6c..0000000 --- a/src/modules/app-core/legacy-pr-config-storage.js +++ /dev/null @@ -1,27 +0,0 @@ -const legacyPrConfigStoragePrefix = 'knighted:develop:github-pr-config:' - -const clearLegacyPrConfigStorage = ({ - storage = localStorage, - prefix = legacyPrConfigStoragePrefix, -} = {}) => { - try { - const keysToRemove = [] - - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index) - if (!key || !key.startsWith(prefix)) { - continue - } - - keysToRemove.push(key) - } - - for (const key of keysToRemove) { - storage.removeItem(key) - } - } catch { - /* noop */ - } -} - -export { clearLegacyPrConfigStorage, legacyPrConfigStoragePrefix } diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js index 603df40..818d354 100644 --- a/src/modules/app-core/workspace-context-controller.js +++ b/src/modules/app-core/workspace-context-controller.js @@ -29,6 +29,9 @@ const createWorkspaceContextController = ({ toWorkspaceRecordId, getHeadBranchValue, }) => { + const toWorkspacePrContextState = value => + typeof value === 'string' ? value.trim().toLowerCase() : '' + const listLocalContextRecords = async () => { const selectedRepository = getCurrentSelectedRepository() return workspaceStorage.listWorkspaces({ @@ -144,7 +147,14 @@ const createWorkspaceContextController = ({ }) const preferred = options.find(workspace => workspace.id === preferredId) - const next = preferred ?? options[0] + const preferredIsActive = + toWorkspacePrContextState(preferred?.prContextState) === 'active' + const activeContextOption = options.find( + workspace => toWorkspacePrContextState(workspace?.prContextState) === 'active', + ) + const next = preferredIsActive + ? preferred + : (activeContextOption ?? preferred ?? options[0]) if (!next) { return diff --git a/src/modules/app-core/workspace-save-controller.js b/src/modules/app-core/workspace-save-controller.js index e4dc1d3..66c9376 100644 --- a/src/modules/app-core/workspace-save-controller.js +++ b/src/modules/app-core/workspace-save-controller.js @@ -23,28 +23,63 @@ const createWorkspaceSaveController = ({ ? await workspaceStorage.listWorkspaces({ repo: normalizedSavedRepo }) : await workspaceStorage.listWorkspaces() - const duplicateRecordIds = siblingRecords - .filter(record => { + const duplicateRecordIds = new Set( + siblingRecords + .filter(record => { + if (!record || typeof record !== 'object') { + return false + } + + if ( + toNonEmptyWorkspaceText(record.id) === toNonEmptyWorkspaceText(saved.id) + ) { + return false + } + + return ( + toNonEmptyWorkspaceText(record.repo) === normalizedSavedRepo && + toNonEmptyWorkspaceText(record.head) === normalizedSavedHead + ) + }) + .map(record => toNonEmptyWorkspaceText(record.id)) + .filter(Boolean), + ) + + const isSavedActiveContext = + toNonEmptyWorkspaceText(saved.prContextState).toLowerCase() === 'active' + const hasSavedPrNumber = + typeof saved.prNumber === 'number' && Number.isFinite(saved.prNumber) + + if (isSavedActiveContext && hasSavedPrNumber && normalizedSavedRepo) { + for (const record of siblingRecords) { if (!record || typeof record !== 'object') { - return false + continue } + const recordId = toNonEmptyWorkspaceText(record.id) + if (!recordId || recordId === toNonEmptyWorkspaceText(saved.id)) { + continue + } + + const isRecordActiveContext = + toNonEmptyWorkspaceText(record.prContextState).toLowerCase() === 'active' + const hasMatchingPrNumber = + typeof record.prNumber === 'number' && + Number.isFinite(record.prNumber) && + record.prNumber === saved.prNumber + if ( - toNonEmptyWorkspaceText(record.id) === toNonEmptyWorkspaceText(saved.id) + isRecordActiveContext && + hasMatchingPrNumber && + toNonEmptyWorkspaceText(record.repo) === normalizedSavedRepo ) { - return false + duplicateRecordIds.add(recordId) } - - return ( - toNonEmptyWorkspaceText(record.repo) === normalizedSavedRepo && - toNonEmptyWorkspaceText(record.head) === normalizedSavedHead - ) - }) - .map(record => toNonEmptyWorkspaceText(record.id)) - .filter(Boolean) + } + } await Promise.all( - duplicateRecordIds.map(duplicateId => + [...duplicateRecordIds].map(duplicateId => workspaceStorage.removeWorkspace(duplicateId), ), ) diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js index a9bda65..3439e6d 100644 --- a/src/modules/app-core/workspace-sync-controller.js +++ b/src/modules/app-core/workspace-sync-controller.js @@ -231,6 +231,7 @@ const createWorkspaceSyncController = ({ repositoryFullName: context.repositoryFullName, headBranch: context.headBranch, activeRecordId: getActiveWorkspaceRecordId(), + prContextState: context.prContextState, }) return { diff --git a/src/modules/workspace/workspace-tab-helpers.js b/src/modules/workspace/workspace-tab-helpers.js index 7cbe06a..44205b7 100644 --- a/src/modules/workspace/workspace-tab-helpers.js +++ b/src/modules/workspace/workspace-tab-helpers.js @@ -32,9 +32,12 @@ const resolveWorkspaceRecordIdentity = ({ repositoryFullName, headBranch, activeRecordId, + prContextState, } = {}) => { const canonicalId = toWorkspaceRecordId({ repositoryFullName, headBranch }) const currentId = toNonEmptyWorkspaceText(activeRecordId) + const normalizedPrContextState = toNonEmptyWorkspaceText(prContextState).toLowerCase() + const isActivePrContext = normalizedPrContextState === 'active' if (!currentId) { return { @@ -61,6 +64,16 @@ const resolveWorkspaceRecordIdentity = ({ } } + const shouldRekeyRepositoryIdentity = + hasRepository && isActivePrContext && currentId.startsWith('repo_') + + if (shouldRekeyRepositoryIdentity) { + return { + id: canonicalId, + supersededId: currentId, + } + } + return { id: currentId, supersededId: '', diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 1e91ca0..9b63b7b 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -5,15 +5,15 @@ const normalizeQuery = value => toSafeText(value).toLowerCase() const toWorkspaceLabel = workspace => { const hasTitle = toSafeText(workspace?.prTitle) if (hasTitle) { - return `Local: ${hasTitle}` + return hasTitle } const hasHead = toSafeText(workspace?.head) if (hasHead) { - return `Local: ${hasHead}` + return hasHead } - return `Local: ${toSafeText(workspace?.id) || 'workspace'}` + return toSafeText(workspace?.id) || 'workspace' } const matchesQuery = (workspace, query) => {