diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 7d550bc..bf31180 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -496,6 +496,179 @@ test('Open PR drawer does not prune saved PR context on repo switch before save' expect(contexts[0]?.parsed?.componentFilePath).toBe('examples/develop/App.tsx') }) +test('Active PR context disconnect uses local-only confirmation flow', async ({ + page, +}) => { + let closePullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + if (route.request().method() === 'PATCH') { + closePullRequestRequestCount += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'closed', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + componentFilePath: 'examples/component/App.tsx', + stylesFilePath: 'examples/styles/app.css', + renderMode: 'react', + baseBranch: 'main', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prBody: 'Saved body', + isActivePr: true, + pullRequestNumber: 2, + pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', + }), + ) + }) + + await connectByotWithSingleRepo(page) + + await expect( + page.getByRole('button', { name: 'Disconnect active pull request context' }), + ).toBeVisible() + + await page + .getByRole('button', { name: 'Disconnect active pull request context' }) + .click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('Disconnect PR context?') + await expect(dialog).toContainText( + 'This will disconnect the active pull request context in this app only.', + ) + await expect(dialog).toContainText('Your pull request will stay open on GitHub.') + await expect(dialog).toContainText( + 'Your GitHub token and selected repository will stay connected.', + ) + + await dialog.getByRole('button', { name: 'Cancel' }).click() + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + const savedActiveStateAfterCancel = await page.evaluate(() => { + const raw = localStorage.getItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + ) + + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) + return parsed?.isActivePr === true + } catch { + return null + } + }) + + expect(savedActiveStateAfterCancel).toBe(true) + + await page + .getByRole('button', { name: 'Disconnect active pull request context' }) + .click() + await dialog.getByRole('button', { name: 'Disconnect' }).click() + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Disconnect active pull request context' }), + ).toBeHidden() + + const savedContextAfterDisconnect = await page.evaluate(() => { + const raw = localStorage.getItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + ) + + if (!raw) { + return null + } + + try { + return JSON.parse(raw) + } catch { + return null + } + }) + + expect(savedContextAfterDisconnect).not.toBeNull() + expect(savedContextAfterDisconnect?.isActivePr).toBe(false) + expect(savedContextAfterDisconnect?.pullRequestNumber).toBe(2) + expect(closePullRequestRequestCount).toBe(0) +}) + test('Active PR context updates controls and can be closed from AI controls', async ({ page, }) => { diff --git a/src/app.js b/src/app.js index ed193d6..81d6ef8 100644 --- a/src/app.js +++ b/src/app.js @@ -47,6 +47,7 @@ const githubPrToggleLabel = document.getElementById('github-pr-toggle-label') const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon') const githubPrToggleIconPath = document.getElementById('github-pr-toggle-icon-path') const githubPrContextClose = document.getElementById('github-pr-context-close') +const githubPrContextDisconnect = document.getElementById('github-pr-context-disconnect') const githubPrDrawer = document.getElementById('github-pr-drawer') const openPrTitle = document.getElementById('open-pr-title') const githubPrClose = document.getElementById('github-pr-close') @@ -634,16 +635,14 @@ const syncActivePrContextUi = activeContext => { setGitHubPrToggleVisual(hasActiveContext ? 'push-commit' : 'open-pr') syncEditorPrContextIndicators(shouldShowEditorSyncIndicators) - if (!(githubPrContextClose instanceof HTMLButtonElement)) { - return - } - if (!hasActiveContext) { - githubPrContextClose.setAttribute('hidden', '') + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') return } - githubPrContextClose.removeAttribute('hidden') + githubPrContextClose?.removeAttribute('hidden') + githubPrContextDisconnect?.removeAttribute('hidden') } const syncAiChatTokenVisibility = token => { @@ -656,8 +655,10 @@ const syncAiChatTokenVisibility = token => { if (githubAiContextState.activePrContext) { githubPrContextClose?.removeAttribute('hidden') + githubPrContextDisconnect?.removeAttribute('hidden') } else { githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') } return } @@ -672,6 +673,7 @@ const syncAiChatTokenVisibility = token => { githubPrToggle?.setAttribute('hidden', '') githubPrToggle?.setAttribute('aria-expanded', 'false') githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') chatDrawerController.setOpen(false) prDrawerController.setOpen(false) } @@ -919,6 +921,31 @@ githubPrContextClose?.addEventListener('click', () => { }) }) +githubPrContextDisconnect?.addEventListener('click', () => { + if (!githubAiContextState.activePrContext) { + return + } + + const activePrReference = formatActivePrReference(githubAiContextState.activePrContext) + const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' + + confirmAction({ + title: 'Disconnect PR context?', + copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`, + confirmButtonText: 'Disconnect', + onConfirm: () => { + const result = prDrawerController.disconnectActivePrContext() + const reference = result?.reference + setStatus( + reference + ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.` + : 'Disconnected PR context. Pull request remains open on GitHub.', + 'neutral', + ) + }, + }) +}) + const getStyleEditorLanguage = mode => { if (mode === 'less') return 'less' if (mode === 'sass') return 'sass' diff --git a/src/index.html b/src/index.html index 2fec4a3..e4cd022 100644 --- a/src/index.html +++ b/src/index.html @@ -177,6 +177,30 @@

Close + + diff --git a/src/modules/github-pr-drawer.js b/src/modules/github-pr-drawer.js index 4ed0516..e744291 100644 --- a/src/modules/github-pr-drawer.js +++ b/src/modules/github-pr-drawer.js @@ -1524,6 +1524,45 @@ export const createGitHubPrDrawer = ({ setOpen, isOpen: () => open, getActivePrContext: () => getCurrentActivePrContext(), + disconnectActivePrContext: () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + if (!repositoryFullName) { + return { reference: '' } + } + + const savedConfig = readRepositoryPrConfig(repositoryFullName) + const previousActiveContext = + savedConfig?.isActivePr === true + ? { + repositoryFullName, + pullRequestNumber: + typeof savedConfig.pullRequestNumber === 'number' && + Number.isFinite(savedConfig.pullRequestNumber) + ? savedConfig.pullRequestNumber + : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl), + } + : null + + if (Object.keys(savedConfig).length > 0) { + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...savedConfig, + isActivePr: false, + }, + }) + } + + lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + setSubmitButtonLabel() + emitActivePrContextChange() + + return { + reference: formatActivePrReference(previousActiveContext), + } + }, clearActivePrContext: () => { const repository = getSelectedRepositoryObject() const repositoryFullName = getRepositoryFullName(repository) diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css index dc141ff..475f4c0 100644 --- a/src/styles/ai-controls.css +++ b/src/styles/ai-controls.css @@ -1,3 +1,11 @@ +.app-grid { + --ai-chat-sparkle-color: color-mix(in srgb, #fbbf24 86%, var(--panel-text)); +} + +:root[data-theme='light'] .app-grid { + --ai-chat-sparkle-color: color-mix(in srgb, #b7791f 90%, var(--panel-text)); +} + .app-grid-layout-controls { flex-wrap: wrap; position: relative; @@ -268,17 +276,39 @@ color: var(--select-option-disabled); } -.ai-chat-toggle { +.diagnostics-toggle.ai-chat-toggle { + --ai-chat-icon-color: var(--ai-chat-sparkle-color); + --ai-chat-text-color: var(--shell-text); + --ai-chat-text-color-hover: var(--ai-chat-sparkle-color); margin-left: 2px; display: inline-flex; align-items: center; gap: 6px; + color: var(--ai-chat-text-color); + transition: color 140ms ease; +} + +.diagnostics-toggle.ai-chat-toggle:hover:not(:disabled) { + color: var(--ai-chat-text-color-hover); +} + +.ai-chat-toggle__icon { + width: 16px; + height: 16px; + fill: var(--ai-chat-icon-color); +} + +.ai-chat-toggle__icon path { + fill: var(--ai-chat-icon-color); } .diagnostics-toggle.github-pr-toggle { --github-pr-icon-color: color-mix(in srgb, #2da44e 82%, var(--panel-text)); --github-pr-icon-color-hover: color-mix(in srgb, #2da44e 92%, var(--panel-text)); --github-pr-icon-color-active: color-mix(in srgb, #1f883d 94%, var(--panel-text)); + --github-pr-text-color: color-mix(in srgb, var(--panel-text) 84%, var(--text-subtle)); + --github-pr-text-color-hover: color-mix(in srgb, #2da44e 86%, var(--panel-text)); + --github-pr-text-color-active: color-mix(in srgb, #1f883d 90%, var(--panel-text)); --github-pr-icon-color-disabled: color-mix( in srgb, var(--shell-text) 58%, @@ -289,7 +319,9 @@ display: inline-flex; align-items: center; gap: 6px; + color: var(--github-pr-text-color); transition: + color 140ms ease, box-shadow 140ms ease, transform 140ms ease; } @@ -298,6 +330,9 @@ --github-pr-icon-color: color-mix(in srgb, #1f883d 88%, var(--panel-text)); --github-pr-icon-color-hover: color-mix(in srgb, #1a7f37 92%, var(--panel-text)); --github-pr-icon-color-active: color-mix(in srgb, #116329 95%, var(--panel-text)); + --github-pr-text-color: color-mix(in srgb, var(--panel-text) 82%, var(--text-subtle)); + --github-pr-text-color-hover: color-mix(in srgb, #1a7f37 88%, var(--panel-text)); + --github-pr-text-color-active: color-mix(in srgb, #116329 92%, var(--panel-text)); --github-pr-icon-color-disabled: color-mix( in srgb, var(--shell-text) 52%, @@ -318,10 +353,12 @@ .diagnostics-toggle.github-pr-toggle:hover:not(:disabled) { --github-pr-icon-color: var(--github-pr-icon-color-hover); + color: var(--github-pr-text-color-hover); } .diagnostics-toggle.github-pr-toggle[aria-expanded='true']:not(:disabled) { --github-pr-icon-color: var(--github-pr-icon-color-active); + color: var(--github-pr-text-color-active); } .diagnostics-toggle.github-pr-toggle:focus-visible:not(:disabled) { @@ -388,29 +425,67 @@ display: none !important; } -.github-pr-toggle[hidden] { - display: none !important; +.diagnostics-toggle.github-pr-context-disconnect { + display: inline-flex; + align-items: center; + gap: 6px; + --github-pr-disconnect-icon-color: color-mix( + in srgb, + var(--accent) 78%, + var(--panel-text) + ); + --github-pr-disconnect-text-color: var(--shell-text); + --github-pr-disconnect-text-color-hover: color-mix( + in srgb, + var(--accent) 78%, + var(--panel-text) + ); + + color: var(--github-pr-disconnect-text-color); } -.ai-chat-toggle[hidden] { - display: none !important; +:root[data-theme='light'] .diagnostics-toggle.github-pr-context-disconnect { + --github-pr-disconnect-icon-color: color-mix(in srgb, #6d28d9 84%, var(--panel-text)); + --github-pr-disconnect-text-color-hover: color-mix( + in srgb, + #6d28d9 88%, + var(--panel-text) + ); } -.ai-chat-toggle__emoji--light { - display: none; +.github-pr-context-disconnect__icon { + width: 14px; + height: 14px; + fill: var(--github-pr-disconnect-icon-color); } -.ai-chat-toggle__emoji { - font-size: 1.25em; - line-height: 1; +.github-pr-context-disconnect__icon path { + fill: var(--github-pr-disconnect-icon-color); } -:root[data-theme='light'] .ai-chat-toggle__emoji--dark { - display: none; +.diagnostics-toggle.github-pr-context-disconnect:hover:not(:disabled) { + color: var(--github-pr-disconnect-text-color-hover); +} + +.github-pr-context-disconnect__label { + color: inherit; } -:root[data-theme='light'] .ai-chat-toggle__emoji--light { - display: inline; +.github-pr-context-disconnect:focus-visible:not(:disabled) { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.github-pr-context-disconnect[hidden] { + display: none !important; +} + +.github-pr-toggle[hidden] { + display: none !important; +} + +.ai-chat-toggle[hidden] { + display: none !important; } .ai-chat-drawer { @@ -428,7 +503,7 @@ backdrop-filter: blur(8px); overflow: hidden; display: grid; - grid-template-rows: auto auto auto minmax(120px, 1fr) auto auto; + grid-template-rows: auto auto minmax(120px, 1fr) auto auto auto; gap: 10px; z-index: 95; } @@ -687,7 +762,7 @@ .ai-chat-title svg { width: 1.05rem; height: 1.05rem; - color: color-mix(in srgb, var(--accent) 58%, var(--panel-text)); + color: var(--ai-chat-sparkle-color); fill: currentColor; } @@ -802,6 +877,8 @@ display: flex; flex-wrap: wrap; align-items: center; + align-content: flex-start; + align-self: start; gap: 6px; }