diff --git a/AGENTS.md b/AGENTS.md index 51531b0..f2ee6ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ Repository structure: - Preserve current project formatting: single quotes, no semicolons, print width 90, arrowParens avoid. - Do not use index files or barrel-file architecture; prefer explicit file names and explicit import paths. +- Prefer modular, colocated architecture; split focused features into nearby files and avoid monolithic modules. - Keep UI changes intentional and lightweight; avoid broad visual rewrites unless requested. - Keep runtime logic defensive for flaky/slow CDN conditions. - Preserve progressive loading behavior (lazy-load optional compilers/runtime pieces where possible). diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 45f896b..e639438 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -5,6 +5,7 @@ import type { PullRequestCreateBody, } from './helpers/app-test-helpers.js' import { + addWorkspaceTab, appEntryPath, connectByotWithSingleRepo, ensureOpenPrDrawerOpen, @@ -147,6 +148,71 @@ const getLocalContextOptionLabels = async (page: Page) => { .evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || '')) } +const getWorkspaceTabsRecord = async ( + page: Page, + { headBranch = '' }: { headBranch?: string } = {}, +) => { + return page.evaluate( + async input => { + 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 normalizedHead = + typeof input?.headBranch === 'string' + ? input.headBranch.trim().toLowerCase() + : '' + + if (normalizedHead) { + const matched = records.find(record => { + const headValue = + typeof record?.head === 'string' ? record.head.trim().toLowerCase() : '' + return headValue === normalizedHead + }) + + if (matched) { + return matched + } + } + + const sortedByLastModified = [...records].sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' ? left.lastModified : 0 + const rightModified = + typeof right?.lastModified === 'number' ? right.lastModified : 0 + return rightModified - leftModified + }) + + return sortedByLastModified[0] ?? null + } finally { + db.close() + } + }, + { headBranch }, + ) +} + test('Open PR drawer confirms and submits component/styles filepaths', async ({ page, }) => { @@ -339,6 +405,234 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ ).toBeVisible() }) +test('Open PR success normalizes trailing newline without showing Edited indicators', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 62, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/62', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await addWorkspaceTab(page, { kind: 'styles' }) + + const moduleStylesEditor = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() + await moduleStylesEditor.fill('.button { padding: 20px; }') + await moduleStylesEditor.press('End') + await moduleStylesEditor.type(' ') + await moduleStylesEditor.press('Backspace') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Normalize trailing newline after open PR') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/62', + ) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'Develop/Open-Pr-Test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const componentTab = tabs.find(tab => tab?.id === 'component') + const appStylesTab = tabs.find( + tab => + typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const moduleStylesTab = tabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim().startsWith('src/styles/module') && + tab.path.trim().endsWith('.css'), + ) + + const componentContent = + typeof componentTab?.content === 'string' ? componentTab.content : '' + const appStylesContent = + typeof appStylesTab?.content === 'string' ? appStylesTab.content : '' + const moduleStylesContent = + typeof moduleStylesTab?.content === 'string' ? moduleStylesTab.content : '' + + return { + componentHasTrailingNewline: componentContent.endsWith('\n'), + appStylesHasTrailingNewline: appStylesContent.endsWith('\n'), + moduleStylesHasTrailingNewline: moduleStylesContent.endsWith('\n'), + componentNotDirty: componentTab?.isDirty === false, + appStylesNotDirty: appStylesTab?.isDirty === false, + moduleStylesNotDirty: moduleStylesTab?.isDirty === false, + componentSynced: componentTab?.syncedContent === componentContent, + appStylesSynced: appStylesTab?.syncedContent === appStylesContent, + moduleStylesSynced: moduleStylesTab?.syncedContent === moduleStylesContent, + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + componentHasTrailingNewline: true, + appStylesHasTrailingNewline: true, + moduleStylesHasTrailingNewline: true, + componentNotDirty: true, + appStylesNotDirty: true, + moduleStylesNotDirty: true, + componentSynced: true, + appStylesSynced: true, + moduleStylesSynced: true, + }) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect( + page + .getByRole('listitem', { name: 'Workspace tab app.css' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect(page.locator('#styles-dirty-status')).toBeHidden() + + const treePayload = treeRequests[0]?.tree as Array> + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + const stylesBlob = treePayload?.find(file => file.path === 'src/styles/app.css') + expect(typeof componentBlob?.content).toBe('string') + expect(typeof stylesBlob?.content).toBe('string') + expect(String(componentBlob?.content).endsWith('\n')).toBe(true) + expect(String(stylesBlob?.content).endsWith('\n')).toBe(true) +}) + test('Open PR drawer can filter stored local contexts by search', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) @@ -1734,17 +2028,13 @@ test('Active PR context uses Push commit flow without creating a new pull reques await expect(page.getByLabel('Pull request base branch')).toBeDisabled() await expect(page.getByLabel('Head')).toHaveJSProperty('readOnly', true) await expect(page.getByLabel('PR title')).toHaveJSProperty('readOnly', true) - await expect( - page.getByLabel('Include entry tab source in committed output'), - ).toBeEnabled() + await expect(page.getByLabel('Include entry tab')).toBeEnabled() await expect(page.getByLabel('Commit message')).toBeEditable() await expect(page.getByLabel('PR description')).toBeHidden() await expect(page.getByLabel('Commit message')).toBeVisible() - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) + const includeWrapperToggle = page.getByLabel('Include entry tab') await expect(includeWrapperToggle).toBeEnabled() await includeWrapperToggle.check() await expect(includeWrapperToggle).toBeChecked() @@ -1786,15 +2076,10 @@ test('Active PR context uses Push commit flow without creating a new pull reques expect(contentsPutRequests).toHaveLength(0) }) -test('Active PR context push commit uses Git Database API atomic path by default', async ({ +test('Active PR context push with no local changes shows neutral status', async ({ page, }) => { - let createRefRequestCount = 0 - let pullRequestRequestCount = 0 - const treeRequests: Array> = [] - const commitRequests: Array> = [] const updateRefRequests: Array> = [] - const contentsPutRequests: string[] = [] await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -1849,33 +2134,6 @@ test('Active PR context push commit uses Git Database API atomic path by default }, ) - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - createRefRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/unexpected' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - pullRequestRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 999, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', - }), - }) - }, - ) - await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', async route => { @@ -1893,11 +2151,10 @@ test('Active PR context push commit uses Git Database API atomic path by default await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', async route => { - treeRequests.push(route.request().postDataJSON() as Record) await route.fulfill({ status: 201, contentType: 'application/json', - body: JSON.stringify({ sha: 'push-tree-sha' }), + body: JSON.stringify({ sha: 'new-tree-sha' }), }) }, ) @@ -1905,11 +2162,10 @@ test('Active PR context push commit uses Git Database API atomic path by default await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', async route => { - commitRequests.push(route.request().postDataJSON() as Record) await route.fulfill({ status: 201, contentType: 'application/json', - body: JSON.stringify({ sha: 'push-commit-sha' }), + body: JSON.stringify({ sha: 'new-commit-sha' }), }) }, ) @@ -1932,10 +2188,6 @@ test('Active PR context push commit uses Git Database API atomic path by default await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', async route => { - if (route.request().method() === 'PUT') { - contentsPutRequests.push(route.request().url()) - } - await route.fulfill({ status: 404, contentType: 'application/json', @@ -1970,38 +2222,31 @@ test('Active PR context push commit uses Git Database API atomic path by default await ensureOpenPrDrawerOpen(page) await setComponentEditorSource(page, 'const commitMarker = 2') - await setStylesEditorSource(page, '.commit-marker { color: blue; }') - const pushCommitMessage = 'chore: push active context sync (atomic)' - await page.getByLabel('Commit message').fill(pushCommitMessage) + await ensureOpenPrDrawerOpen(page) await page.getByRole('button', { name: 'Push commit' }).last().click() - const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await dialog.getByRole('button', { name: 'Push commit' }).click() await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') - - expect(createRefRequestCount).toBe(0) - expect(pullRequestRequestCount).toBe(0) - expect(treeRequests).toHaveLength(1) - expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) - expect(commitRequests).toHaveLength(1) - expect(commitRequests[0]?.message).toBe(pushCommitMessage) + ).toContainText('Commit pushed to develop/open-pr-test') expect(updateRefRequests).toHaveLength(1) - expect(updateRefRequests[0]?.sha).toBe('push-commit-sha') - expect(contentsPutRequests).toHaveLength(0) + + await ensureOpenPrDrawerOpen(page) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('No local editor changes to push.') + await expect(page.locator('#clear-confirm-dialog')).toBeHidden() }) -test('Reloaded active PR context from URL metadata keeps Push mode and status reference', async ({ +test('New workspace tabs show Edited indicator in active PR context', async ({ page, }) => { - const contentsPutRequests: string[] = [] - let createRefRequestCount = 0 - let pullRequestRequestCount = 0 - await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ status: 200, @@ -2055,23 +2300,413 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re }, ) - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - createRefRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/unexpected-branch' }), - }) - }, - ) + await waitForAppReady(page, `${appEntryPath}`) - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - pullRequestRequestCount += 1 - await route.fulfill({ + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/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 addWorkspaceTab(page) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab module.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(1) +}) + +test('Dirty tabs expose Edited in accessible names during active PR context', 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: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/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 addWorkspaceTab(page) + + await expect( + page.getByRole('button', { name: 'Open tab module.tsx (Edited)' }), + ).toBeVisible() + await expect( + page.getByRole('listitem', { name: 'Workspace tab module.tsx (Edited)' }), + ).toBeVisible() +}) + +test('Active PR context push commit uses Git Database API atomic path by default', async ({ + page, +}) => { + let createRefRequestCount = 0 + let pullRequestRequestCount = 0 + const treeRequests: Array> = [] + const commitRequests: Array> = [] + const updateRefRequests: Array> = [] + const contentsPutRequests: string[] = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/unexpected' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 999, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/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 ensureOpenPrDrawerOpen(page) + + await setComponentEditorSource(page, 'const commitMarker = 2') + await setStylesEditorSource(page, '.commit-marker { color: blue; }') + const pushCommitMessage = 'chore: push active context sync (atomic)' + await page.getByLabel('Commit message').fill(pushCommitMessage) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') + + expect(createRefRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(pushCommitMessage) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('push-commit-sha') + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Reloaded active PR context from URL metadata keeps Push mode and status reference', async ({ + page, +}) => { + const contentsPutRequests: string[] = [] + let createRefRequestCount = 0 + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/unexpected-branch' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ @@ -2393,28 +3028,26 @@ test('Open PR drawer confirmation does not report path traversal errors', async ).not.toContainText('File path cannot include parent directory traversal.') }) -test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({ +test('Open PR drawer include entry tab checkbox defaults on and resets on reopen', async ({ page, }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await ensureOpenPrDrawerOpen(page) - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) - await expect(includeWrapperToggle).not.toBeChecked() - - await includeWrapperToggle.check() + const includeWrapperToggle = page.getByLabel('Include entry tab') await expect(includeWrapperToggle).toBeChecked() + await includeWrapperToggle.uncheck() + await expect(includeWrapperToggle).not.toBeChecked() + await page.getByRole('button', { name: 'Close open pull request drawer' }).click() await ensureOpenPrDrawerOpen(page) - await expect(includeWrapperToggle).not.toBeChecked() + await expect(includeWrapperToggle).toBeChecked() }) -test('Open PR drawer strips App wrapper from committed component source by default', async ({ +test('Open PR drawer includes App wrapper in committed component source by default', async ({ page, }) => { const treeRequests: Array> = [] @@ -2550,7 +3183,7 @@ test('Open PR drawer strips App wrapper from committed component source by defau await ensureOpenPrDrawerOpen(page) await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') - await page.getByLabel('PR title').fill('Strip App wrapper by default') + await page.getByLabel('PR title').fill('Include App wrapper by default') await submitOpenPrAndConfirm(page) await expect( @@ -2562,13 +3195,13 @@ test('Open PR drawer strips App wrapper from committed component source by defau const treePayload = treeRequests[0]?.tree as Array> const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') expect(componentBlob?.content).toEqual(expect.any(String)) - const strippedComponentSource = String(componentBlob?.content) + const fullComponentSource = String(componentBlob?.content) - expect(strippedComponentSource).toContain('const CounterButton = () =>') - expect(strippedComponentSource).not.toContain('const App = () =>') + expect(fullComponentSource).toContain('const CounterButton = () =>') + expect(fullComponentSource).toContain('const App = () =>') }) -test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ +test('Open PR drawer strips App wrapper from committed source when toggled off', async ({ page, }) => { const treeRequests: Array> = [] @@ -2704,13 +3337,11 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', ) await ensureOpenPrDrawerOpen(page) - const includeWrapperToggle = page.getByLabel( - 'Include entry tab source in committed output', - ) - await includeWrapperToggle.check() + const includeWrapperToggle = page.getByLabel('Include entry tab') + await includeWrapperToggle.uncheck() await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') - await page.getByLabel('PR title').fill('Include App wrapper in commit') + await page.getByLabel('PR title').fill('Strip App wrapper in commit') await submitOpenPrAndConfirm(page) await expect( @@ -2722,7 +3353,7 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', const treePayload = treeRequests[0]?.tree as Array> const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') expect(componentBlob?.content).toEqual(expect.any(String)) - const fullComponentSource = String(componentBlob?.content) - expect(fullComponentSource).toContain('const CounterButton = () =>') - expect(fullComponentSource).toContain('const App = () =>') + const strippedComponentSource = String(componentBlob?.content) + expect(strippedComponentSource).toContain('const CounterButton = () =>') + expect(strippedComponentSource).not.toContain('const App = () =>') }) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index 7d7035b..becfd4f 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -266,7 +266,9 @@ test('startup restores last active workspace tab after reload', async ({ page }) await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '') }) -test('editing a synced tab marks it dirty', async ({ page }) => { +test('editing a synced tab keeps dirty state local without Edited indicators', async ({ + page, +}) => { await waitForInitialRender(page) await seedSyncedComponentTab(page) @@ -282,7 +284,8 @@ test('editing a synced tab marks it dirty', async ({ page }) => { const componentTab = page .getByRole('listitem', { name: 'Workspace tab App.tsx' }) .first() - await expect(componentTab).toContainText('Dirty') + await expect(componentTab.locator('.workspace-tab__dirty-indicator')).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() }) test('removed default styles tab stays removed after reload', async ({ page }) => { diff --git a/src/app.js b/src/app.js index 22ce793..636bec1 100644 --- a/src/app.js +++ b/src/app.js @@ -29,6 +29,8 @@ import { toStyleModeForTabLanguage, } from './modules/app-core/workspace-local-helpers.js' import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js' +import { createEditedIndicatorVisibilityController } from './modules/app-core/edited-indicator-visibility-controller.js' +import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js' import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' @@ -135,8 +137,14 @@ const componentPrSyncIcon = document.getElementById('component-pr-sync-icon') const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path') const stylesPrSyncIcon = document.getElementById('styles-pr-sync-icon') const stylesPrSyncIconPath = document.getElementById('styles-pr-sync-icon-path') -const componentEditorHeaderLabel = document.querySelector('#editor-header-component span') -const stylesEditorHeaderLabel = document.querySelector('#editor-header-styles span') +const componentEditorHeaderLabel = document.querySelector( + '#editor-header-component [data-editor-header-label]', +) +const stylesEditorHeaderLabel = document.querySelector( + '#editor-header-styles [data-editor-header-label]', +) +const componentEditorDirtyStatus = document.getElementById('component-dirty-status') +const stylesEditorDirtyStatus = document.getElementById('styles-dirty-status') const aiControlsToggle = document.getElementById('ai-controls-toggle') const appThemeButtons = document.querySelectorAll('[data-app-theme]') const workspaceTabsShell = document.getElementById('workspace-tabs-shell') @@ -194,6 +202,10 @@ const editorHeaderLabelByKind = { component: componentEditorHeaderLabel, styles: stylesEditorHeaderLabel, } +const editorHeaderDirtyStatusByKind = { + component: componentEditorDirtyStatus, + styles: stylesEditorDirtyStatus, +} const defaultTabNameByKind = { component: defaultComponentTabName, styles: defaultStylesTabName, @@ -382,6 +394,8 @@ const githubAiContextState = { hasSyncedActivePrEditorContent: false, } +let workspacePrContextState = 'inactive' + let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, @@ -426,6 +440,11 @@ const prContextUi = createGitHubPrContextUiController({ closeWorkspacesDrawer: () => workspacesDrawerController?.setOpen(false), }) +const editedIndicatorVisibilityController = createEditedIndicatorVisibilityController({ + getToken: () => githubAiContextState.token, + getActivePrContext: () => githubAiContextState.activePrContext, +}) + const byotControls = createGitHubByotControls({ controlsRoot: githubAiControls, tokenInput: githubTokenInput, @@ -462,6 +481,7 @@ const byotControls = createGitHubByotControls({ prContextUi.syncAiChatTokenVisibility(token) chatDrawerController.setToken(token) prDrawerController.setToken(token) + editedIndicatorVisibilityController.refreshIndicators() }, setStatus, }) @@ -475,11 +495,16 @@ const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.g const getCurrentSelectedRepository = () => githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() +const getCurrentSelectedRepositoryFullName = () => + getCurrentSelectedRepository()?.fullName ?? '' + const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ - getCurrentSelectedRepository, + getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, githubPrBaseBranch, githubPrHeadBranch, githubPrTitle, + getActivePrContext: () => githubAiContextState.activePrContext, + getPrContextState: () => workspacePrContextState, }) let loadedComponentTabId = 'component' @@ -499,6 +524,9 @@ const { editorKinds, editorPanelsByKind, editorHeaderLabelByKind, + editorHeaderDirtyStatusByKind, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, defaultTabNameByKind, toNonEmptyWorkspaceText, getLoadedStylesTabId: () => loadedStylesTabId, @@ -577,11 +605,8 @@ const ensureWorkspaceTabsShape = createEnsureWorkspaceTabsShape({ const buildWorkspaceTabsSnapshot = () => workspaceSyncController.buildWorkspaceTabsSnapshot() -const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => - workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - -const getWorkspacePrFileCommits = () => - workspaceSyncController.getWorkspacePrFileCommits() +const getWorkspacePrFileCommits = options => + workspaceSyncController.getWorkspacePrFileCommits(options) const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets() @@ -614,7 +639,7 @@ const { getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), - getCurrentSelectedRepository, + getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), ensureWorkspaceTabsShape, @@ -649,6 +674,8 @@ const { setHasPendingWorkspaceTabsRender: value => (hasPendingWorkspaceTabsRender = value), persistActiveTabEditorContent, getWorkspaceTabDisplay, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, workspaceTabsShell, workspaceTabAddWrap, setWorkspaceTabRenameState: value => (workspaceTabRenameState = value), @@ -673,6 +700,50 @@ const { createWorkspaceTabId, }) +editedIndicatorVisibilityController.setRefreshHandlers({ + syncHeaderLabels, + renderWorkspaceTabs, +}) + +const normalizeWorkspaceEditorsTrailingNewlineAfterPublish = + createPublishTrailingNewlineNormalizer({ + workspaceTabsState, + getTabPublishPath: tab => + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '', + normalizePublishPath: path => normalizeWorkspacePathValue(path), + getLoadedComponentTabId: () => loadedComponentTabId, + getLoadedStylesTabId: () => loadedStylesTabId, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + setJsxSource, + setCssSource, + setSuppressEditorChangeSideEffects: value => { + suppressEditorChangeSideEffects = value + }, + queueWorkspaceSave: () => queueWorkspaceSave(), + }) + +const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { + normalizeWorkspaceEditorsTrailingNewlineAfterPublish({ fileUpdates }) + return workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) +} + +const setWorkspacePrContextState = nextState => { + if (typeof nextState !== 'string' || !nextState.trim()) { + return + } + + workspacePrContextState = nextState.trim() +} + +const persistWorkspacePrContextState = nextState => { + setWorkspacePrContextState(nextState) + queueWorkspaceSave() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) +} + const githubWorkflows = createGitHubWorkflowsSetup({ factories: { createGitHubPrEditorSyncController, @@ -747,6 +818,20 @@ const githubWorkflows = createGitHubWorkflowsSetup({ getStyleMode: () => styleMode.value, getActivePrContextSyncKey, prContextUi, + onPrContextStateChange: activeContext => { + if (activeContext?.prTitle) { + setWorkspacePrContextState('active') + } else if (workspacePrContextState === 'active') { + setWorkspacePrContextState('inactive') + } + editedIndicatorVisibilityController.refreshIndicators() + }, + onPrContextClosed: () => { + persistWorkspacePrContextState('closed') + }, + onPrContextDisconnected: () => { + persistWorkspacePrContextState('disconnected') + }, getTokenForVisibility: () => githubAiContextState.token, closeWorkspacesDrawer: () => { void workspacesDrawerController?.setOpen(false) diff --git a/src/index.html b/src/index.html index f9d8962..d625d9a 100644 --- a/src/index.html +++ b/src/index.html @@ -376,7 +376,15 @@

>

- Component + Component + >

- Styles + Styles + Open Pull Request

class="github-pr-field github-pr-field--full github-pr-field--checkbox" for="github-pr-include-app-wrapper" > - - Include entry tab source in committed output + + Include entry tab