From 2ba6e10e3a7cbede466d33b9b1d897caca15886b Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 12 Apr 2026 11:07:07 -0500 Subject: [PATCH 1/7] feat: phases 4a-c of github sync. --- ...rawer-workspace-context-separation-plan.md | 166 +++++ playwright/github-pr-drawer.spec.ts | 598 ++++++++++++++++++ playwright/workspace-tabs.spec.ts | 92 +++ src/app.js | 243 ++++++- src/index.html | 8 + src/modules/github-api.js | 285 ++++++++- src/modules/github-pr-drawer.js | 1 + src/modules/workspace-storage.js | 33 + src/modules/workspace-tabs-state.js | 17 + src/styles/ai-controls.css | 4 + src/styles/panels-editor.css | 6 + 11 files changed, 1402 insertions(+), 51 deletions(-) create mode 100644 docs/pr-drawer-workspace-context-separation-plan.md diff --git a/docs/pr-drawer-workspace-context-separation-plan.md b/docs/pr-drawer-workspace-context-separation-plan.md new file mode 100644 index 0000000..1f4a4ab --- /dev/null +++ b/docs/pr-drawer-workspace-context-separation-plan.md @@ -0,0 +1,166 @@ +# PR Drawer vs Workspace Contexts Separation Plan + +## Purpose + +Define a post-4C UX split so transactional GitHub actions stay focused while local-first workspace lifecycle actions move to a dedicated surface. + +## Problem Statement + +The current Open Pull Request drawer mixes two distinct concerns: + +1. Transactional sync actions: + - Open pull request + - Push commit to active pull request +2. Workspace lifecycle management: + - Select/search local contexts + - Remove local contexts + +This creates cognitive overhead and makes destructive local operations feel coupled to one-shot GitHub actions. + +## Goals + +1. Keep PR drawer focused on transactional actions and immediate status. +2. Move workspace lifecycle operations to a dedicated context management surface. +3. Preserve local-first behavior and IndexedDB as source of truth for workspace content. +4. Keep migration incremental and avoid breaking existing flows. + +## Product Decisions (Locked) + +The following decisions are accepted for implementation: + +1. Add a dedicated `Workspaces` control button in `app-grid-ai-controls`. +2. `Workspaces` opens its own drawer for lifecycle operations (search/select/remove and future context features). +3. Open PR / Push commit flows no longer expose `Component filename` and `Styles filename` fields. +4. Commit target filenames are derived from workspace tab metadata stored in IndexedDB. +5. The checkbox currently labeled `Include App wrapper in committed component source` will be relabeled to reflect entry-tab semantics. +6. Open PR / Push commit confirmation summary shifts from two fixed file fields to a mapped tab/file list. + +## Proposed Information Architecture + +### 1. PR Drawer (Transactional) + +Keep only action-scoped fields and status: + +1. Repository and branch selection +2. File mapping summary derived from workspace tab metadata +3. PR title/body and commit message +4. Submit actions (Open PR, Push commit) +5. Transaction status/errors + +Optional shortcut: + +1. Current context selector (read-focused quick switch) +2. Link/button to open full context manager + +### 2. Workspace Context Manager (Lifecycle) + +Dedicated UI surface (modal or side panel) for: + +1. Search/filter local contexts +2. Select/activate local context +3. Remove one or many local contexts +4. Future: open PR binding and context metadata management + +Location and trigger: + +1. Triggered from a new `Workspaces` button in `app-grid-ai-controls`. +2. Implemented as a dedicated drawer separate from the PR drawer. +3. Uses a colocated module structure under a parent directory dedicated to workspace lifecycle UI. + +## Storage Boundaries (Unchanged) + +1. IndexedDB: + - Workspace tabs and content + - Tab sync metadata (dirty/synced markers) + - Workspace context records +2. localStorage: + - User preferences + - Lightweight per-repo PR drawer config + +## Migration Plan + +### Phase A: Transitional (Low Risk) + +1. Add `Workspaces` button in `app-grid-ai-controls` with dedicated icon. +2. Add standalone `Workspaces` drawer skeleton and lifecycle list rendering. +3. Move search/remove controls from PR drawer into `Workspaces` drawer. +4. Keep quick context selection in PR drawer only as an optional shortcut. + +### Phase B: Consolidation + +1. Reduce PR drawer context UI to active context summary + switch shortcut. +2. Remove component/styles filename fields from PR drawer. +3. Derive commit file targets exclusively from workspace tab metadata in IndexedDB. +4. Add multi-select removal and richer filters in manager. + +### Phase C: Follow-up Enhancements + +1. Open PR list binding tools live in manager. +2. Context health indicators (dirty, synced, drift) appear in manager list. +3. Optional pin/favorite/recents support. +4. Optional tab-level include/exclude toggles for commit targets (if needed by workflow feedback). + +## Confirmation Summary Contract (Open PR / Push Commit) + +The confirmation dialog should show: + +1. Repository (`owner/repo`) +2. Branch information: + - Open PR: `Base` and `Head` + - Push commit: `Head` +3. Commit message +4. A commit target list derived from tab metadata: + - Tab display name + - Repository-relative filepath + - Optional tag for entry tab + +Recommended rendering: + +1. Keep the top metadata lines concise. +2. Show a bulleted list for `Files to commit` so users can quickly scan exact targets. +3. For long lists, cap visible rows and show `+N more` summary. + +## Accessibility and UX Requirements + +1. Dedicated manager must support keyboard navigation for list/select/remove. +2. Destructive actions must require explicit confirmation. +3. PR drawer status remains transactional only. +4. Context manager explains local-only deletion scope clearly. + +## Testing Plan + +1. PR drawer tests verify transactional workflows independent of context cleanup. +2. Context manager tests verify search/select/delete workflows. +3. Migration test verifies existing users retain contexts after UI split. + +## Open Decisions + +1. Modal vs side panel for context manager. +2. Whether quick-select remains in PR drawer after Phase B. +3. Whether context removal supports undo window. +4. Whether the PR drawer should show all mapped tabs or only tabs marked as commit-included. + +## Implementation Structure Guidance + +To keep implementation modular and colocated: + +1. Create a parent module directory for lifecycle UI, for example: + - `src/modules/workspaces-drawer/` +2. Keep small focused modules inside it, for example: + - `drawer.js` (controller) + - `state.js` (view state) + - `list-render.js` (UI rendering) + - `actions.js` (select/remove commands) +3. Keep PR transactional logic in existing PR modules and consume workspace metadata via adapter functions only. + +## UI Copy Updates + +1. Rename checkbox label: + - From: `Include App wrapper in committed component source` + - To: `Include entry tab source in committed output` +2. In summaries and status, refer to `entry tab` and `workspace files` rather than `component/styles files`. + +## Suggested Rollout + +1. Land after 4C stabilization tests are green. +2. Ship Phase A first, then Phase B in follow-up PR. diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index c68c9d6..f193a08 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -92,6 +92,68 @@ const removeSavedGitHubToken = async (page: Page) => { await expect(dialog).not.toHaveAttribute('open', '') } +const seedLocalWorkspaceContexts = async ( + page: Page, + contexts: Array<{ + id: string + repo: string + head: string + prTitle: string + }>, +) => { + await page.evaluate(async inputContexts => { + 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', 'readwrite') + const store = tx.objectStore('prWorkspaces') + const now = Date.now() + + for (const context of inputContexts) { + const putRequest = store.put({ + id: context.id, + repo: context.repo, + base: 'main', + head: context.head, + prTitle: context.prTitle, + renderMode: 'dom', + tabs: [], + activeTabId: 'component', + schemaVersion: 1, + createdAt: now, + lastModified: now, + }) + + await new Promise((resolve, reject) => { + putRequest.onsuccess = () => resolve() + putRequest.onerror = () => reject(putRequest.error) + }) + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }, contexts) +} + +const getLocalContextOptionLabels = async (page: Page) => { + return page + .getByLabel('Stored local editor contexts') + .locator('option') + .evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || '')) +} + test('Open PR drawer confirms and submits component/styles filepaths', async ({ page, }) => { @@ -251,6 +313,335 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ ).toBeVisible() }) +test('Open PR drawer can filter stored local contexts by search', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_knightedcodemonkey_develop_feat-alpha', + repo: 'knightedcodemonkey/develop', + head: 'feat/alpha', + prTitle: 'Alpha local context', + }, + { + id: 'repo_knightedcodemonkey_develop_feat-beta', + repo: 'knightedcodemonkey/develop', + head: 'feat/beta', + prTitle: 'Beta local context', + }, + ]) + + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + const search = page.getByLabel('Search stored local contexts') + await expect(search).toBeEnabled() + await search.fill('beta') + + const labels = await getLocalContextOptionLabels(page) + expect(labels).toEqual(['Select a stored local context', 'Local: Beta local context']) +}) + +test('Open PR drawer uses Git Database API atomic commit path by default', async ({ + page, +}) => { + 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'], + }) + + 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({ + object: { sha: 'branch-head-sha' }, + }), + }) + }, + ) + + 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/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/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 => { + commitRequests.push(route.request().postDataJSON() as Record) + 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 => { + 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 page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 52, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/52', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('Component filename').fill('examples/component/App.tsx') + await page.getByLabel('Styles filename').fill('examples/styles/app.css') + await page.getByLabel('PR title').fill('Apply editor updates from develop') + + 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/52', + ) + + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('new-commit-sha') + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Open PR drawer falls back to Contents API when Git Database commit fails', async ({ + page, +}) => { + const treeRequests: Array> = [] + const contentsPutRequests: Array<{ path: string; body: Record }> = [] + + 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({ object: { sha: 'branch-head-sha' } }), + }) + }, + ) + + 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/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/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Tree API unavailable' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const path = decodeURIComponent( + new URL(request.url()).pathname.split('/contents/')[1] ?? '', + ) + + if (method === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + const body = request.postDataJSON() as Record + contentsPutRequests.push({ path, body }) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ commit: { sha: 'fallback-commit-sha' } }), + }) + }, + ) + + 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: 53, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/53', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('Component filename').fill('examples/component/App.tsx') + await page.getByLabel('Styles filename').fill('examples/styles/app.css') + await page.getByLabel('PR title').fill('Apply editor updates from develop') + + 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/53', + ) + + expect(treeRequests).toHaveLength(1) + expect(contentsPutRequests).toHaveLength(2) + expect(contentsPutRequests[0]?.path).toBe('examples/component/App.tsx') + expect(contentsPutRequests[1]?.path).toBe('examples/styles/app.css') +}) + test('Open PR drawer starts with empty title/description and short default head', async ({ page, }) => { @@ -1387,6 +1778,213 @@ test('Active PR context uses Push commit flow without creating a new pull reques expect(upsertRequests[1]?.body.message).toBe(pushCommitMessage) }) +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({ + 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 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, }) => { diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index 90f6eec..7d7035b 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -35,6 +35,79 @@ const renameWorkspaceTab = async ( await renameInput.press('Enter') } +const seedSyncedComponentTab = async (page: import('@playwright/test').Page) => { + await 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', 'readwrite') + 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 now = Date.now() + for (const record of records) { + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const nextTabs = tabs.map(tab => { + if (!tab || typeof tab !== 'object') { + return tab + } + + if ((tab as { id?: unknown }).id !== 'component') { + return tab + } + + const pathValue = + typeof (tab as { path?: unknown }).path === 'string' + ? ((tab as { path: string }).path ?? '') + : '' + + return { + ...(tab as Record), + targetPrFilePath: pathValue, + syncedAt: now, + isDirty: false, + } + }) + + const putRequest = store.put({ + ...record, + tabs: nextTabs, + lastModified: now, + }) + + await new Promise((resolve, reject) => { + putRequest.onsuccess = () => resolve() + putRequest.onerror = () => reject(putRequest.error) + }) + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }) +} + test('removing active tab selects deterministic adjacent tab', async ({ page }) => { await waitForInitialRender(page) @@ -193,6 +266,25 @@ 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 }) => { + await waitForInitialRender(page) + + await seedSyncedComponentTab(page) + await page.reload() + await waitForInitialRender(page) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: 'export default function App() { return
Dirty
}', + kind: 'component', + }) + + const componentTab = page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .first() + await expect(componentTab).toContainText('Dirty') +}) + test('removed default styles tab stays removed after reload', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index d64ed33..7089eb9 100644 --- a/src/app.js +++ b/src/app.js @@ -70,6 +70,9 @@ const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app const githubPrLocalContextSelect = document.getElementById( 'github-pr-local-context-select', ) +const githubPrLocalContextSearch = document.getElementById( + 'github-pr-local-context-search', +) const githubPrLocalContextRemove = document.getElementById( 'github-pr-local-context-remove', ) @@ -159,6 +162,8 @@ const workspaceStorage = createWorkspaceStorageAdapter() let workspaceSaver = null let activeWorkspaceRecordId = '' let activeWorkspaceCreatedAt = null +let localContextSearchQuery = '' +let cachedLocalContexts = [] let isApplyingWorkspaceSnapshot = false let hasCompletedInitialWorkspaceBootstrap = false const workspaceTabsState = createWorkspaceTabsState({ @@ -819,6 +824,27 @@ let loadedStylesTabId = 'styles' const toNonEmptyWorkspaceText = value => typeof value === 'string' && value.trim().length > 0 ? value.trim() : '' +const toWorkspaceSyncTimestamp = value => + Number.isFinite(value) && value > 0 ? Math.max(0, Number(value)) : null + +const toWorkspaceSyncSha = value => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null + +const normalizeWorkspacePathValue = value => + toNonEmptyWorkspaceText(value).replace(/\\/g, '/').replace(/\/+/g, '/') + +const getTabTargetPrFilePath = tab => normalizeWorkspacePathValue(tab?.targetPrFilePath) + +const hasTabSyncBaseline = tab => + Boolean( + getTabTargetPrFilePath(tab) || + toWorkspaceSyncTimestamp(tab?.syncedAt) || + toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + ) + +const getDirtyStateForTabChange = tab => + hasTabSyncBaseline(tab) ? true : Boolean(tab?.isDirty) + const isStyleTabLanguage = language => styleTabLanguages.has(toNonEmptyWorkspaceText(language)) @@ -900,6 +926,7 @@ const persistActiveTabEditorContent = () => { { ...activeTab, content: nextContent, + isDirty: getDirtyStateForTabChange(activeTab), lastModified: Date.now(), isActive: true, }, @@ -1087,6 +1114,11 @@ const ensureWorkspaceTabsShape = tabs => { language: 'javascript-jsx', path: normalizedEntryPath, name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, + targetPrFilePath: + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(normalizedEntryPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), } } @@ -1104,6 +1136,12 @@ const ensureWorkspaceTabsShape = tabs => { normalizedStylesNameInput.toLowerCase() === 'styles' ? getPathFileName(normalizedStylesPath) || defaultStylesTabName : normalizedStylesNameInput, + targetPrFilePath: + getTabTargetPrFilePath(tab) || + normalizeWorkspacePathValue(normalizedStylesPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), } } @@ -1114,6 +1152,10 @@ const ensureWorkspaceTabsShape = tabs => { language: isStyleTabLanguage(tab?.language) ? tab.language : 'javascript-jsx', path: nextPath, name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, + targetPrFilePath: getTabTargetPrFilePath(tab) || null, + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), } }) } @@ -1158,16 +1200,80 @@ const buildWorkspaceTabsSnapshot = () => { ? tab.content : '' + const targetPrFilePath = + isComponentTab || isStylesTab + ? normalizeWorkspacePathValue(currentPath) || null + : getTabTargetPrFilePath(tab) || null + return { ...tab, path: currentPath, content: currentContent, + targetPrFilePath, isActive: activeTabId === tab.id, lastModified: Date.now(), } }) } +const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { + const updates = Array.isArray(fileUpdates) ? fileUpdates : [] + if (updates.length === 0) { + return 0 + } + + const updatesByPath = new Map() + for (const update of updates) { + const normalizedPath = normalizeWorkspacePathValue(update?.path) + if (!normalizedPath) { + continue + } + + updatesByPath.set(normalizedPath, toWorkspaceSyncSha(update?.commitSha)) + } + + if (updatesByPath.size === 0) { + return 0 + } + + const now = Date.now() + let updatedTabCount = 0 + const activeTabId = workspaceTabsState.getActiveTabId() + const nextTabs = workspaceTabsState.getTabs().map(tab => { + const candidatePaths = [ + getTabTargetPrFilePath(tab), + normalizeWorkspacePathValue(tab.path), + ].filter(Boolean) + + const matchedPath = candidatePaths.find(path => updatesByPath.has(path)) + if (!matchedPath) { + return tab + } + + updatedTabCount += 1 + const commitSha = updatesByPath.get(matchedPath) + + return { + ...tab, + targetPrFilePath: matchedPath, + isDirty: false, + syncedAt: now, + lastSyncedRemoteSha: commitSha || toWorkspaceSyncSha(tab.lastSyncedRemoteSha), + lastModified: now, + } + }) + + if (updatedTabCount > 0) { + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId, + }) + queueWorkspaceSave() + } + + return updatedTabCount +} + const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { const context = getWorkspaceContextSnapshot() const id = @@ -1204,42 +1310,56 @@ const updateLocalContextActions = () => { githubPrLocalContextRemove.disabled = !hasSelection } -const formatWorkspaceOptionLabel = workspace => { - const contextLabel = 'Local' - const hasTitle = typeof workspace.prTitle === 'string' && workspace.prTitle.trim() - const hasHead = typeof workspace.head === 'string' && workspace.head.trim() +const normalizeLocalContextSearchQuery = value => + typeof value === 'string' ? value.trim().toLowerCase() : '' - if (hasTitle) { - return `${contextLabel}: ${workspace.prTitle}` +const localContextMatchesQuery = (workspace, query) => { + if (!query) { + return true } - if (hasHead) { - return `${contextLabel}: ${workspace.head}` - } + const haystack = [ + workspace?.id, + workspace?.repo, + workspace?.head, + workspace?.base, + workspace?.prTitle, + formatWorkspaceOptionLabel(workspace), + ] + .filter(value => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase() - return `${contextLabel}: ${workspace.id}` + return haystack.includes(query) } -const refreshLocalContextOptions = async () => { +const renderLocalContextOptions = ({ options, query }) => { if (!(githubPrLocalContextSelect instanceof HTMLSelectElement)) { return [] } - const selectedRepository = getCurrentSelectedRepository() - const options = await workspaceStorage.listWorkspaces({ - repo: selectedRepository || '', - }) + const normalizedQuery = normalizeLocalContextSearchQuery(query) + const filtered = options.filter(workspace => + localContextMatchesQuery(workspace, normalizedQuery), + ) githubPrLocalContextSelect.replaceChildren() const placeholder = document.createElement('option') placeholder.value = '' placeholder.textContent = - options.length > 0 ? 'Select a stored local context' : 'No saved local contexts' - placeholder.selected = activeWorkspaceRecordId.length === 0 + options.length === 0 + ? 'No saved local contexts' + : filtered.length > 0 + ? 'Select a stored local context' + : 'No matching local contexts' + placeholder.disabled = filtered.length > 0 + placeholder.selected = !filtered.some( + workspace => workspace.id === activeWorkspaceRecordId, + ) githubPrLocalContextSelect.append(placeholder) - for (const workspace of options) { + for (const workspace of filtered) { const option = document.createElement('option') option.value = workspace.id option.textContent = formatWorkspaceOptionLabel(workspace) @@ -1256,7 +1376,48 @@ const refreshLocalContextOptions = async () => { githubPrLocalContextSelect.value = '' } + if ( + githubPrLocalContextSearch instanceof HTMLInputElement || + githubPrLocalContextSearch instanceof HTMLTextAreaElement + ) { + githubPrLocalContextSearch.disabled = options.length === 0 + } + updateLocalContextActions() + return filtered +} + +const formatWorkspaceOptionLabel = workspace => { + const contextLabel = 'Local' + const hasTitle = typeof workspace.prTitle === 'string' && workspace.prTitle.trim() + const hasHead = typeof workspace.head === 'string' && workspace.head.trim() + + if (hasTitle) { + return `${contextLabel}: ${workspace.prTitle}` + } + + if (hasHead) { + return `${contextLabel}: ${workspace.head}` + } + + return `${contextLabel}: ${workspace.id}` +} + +const refreshLocalContextOptions = async () => { + if (!(githubPrLocalContextSelect instanceof HTMLSelectElement)) { + return [] + } + + const selectedRepository = getCurrentSelectedRepository() + const options = await workspaceStorage.listWorkspaces({ + repo: selectedRepository || '', + }) + + cachedLocalContexts = options + renderLocalContextOptions({ + options, + query: localContextSearchQuery, + }) return options } @@ -1470,6 +1631,7 @@ const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => { ...tab, name: normalizedTabName, path: normalizedEntryPath, + isDirty: getDirtyStateForTabChange(tab), lastModified: Date.now(), }) @@ -1813,6 +1975,13 @@ const renderWorkspaceTabs = () => { tabContainer.append(metaBadge) } + if (tab.isDirty) { + const dirtyBadge = document.createElement('span') + dirtyBadge.className = 'workspace-tab__meta workspace-tab__meta--dirty' + dirtyBadge.textContent = 'Dirty' + tabContainer.append(dirtyBadge) + } + const renameButton = document.createElement('button') renameButton.className = 'workspace-tab__rename' renameButton.type = 'button' @@ -1930,18 +2099,26 @@ const syncTabPathsFromInputs = () => { githubPrComponentPath.value = componentPath } + const existingComponentTab = workspaceTabsState.getTab('component') + workspaceTabsState.upsertTab({ + ...(existingComponentTab ?? {}), id: 'component', path: componentPath, name: getPathFileName(componentPath) || defaultComponentTabName, language: 'javascript-jsx', role: 'entry', isActive: workspaceTabsState.getActiveTabId() === 'component', + targetPrFilePath: normalizeWorkspacePathValue(componentPath) || null, + isDirty: getDirtyStateForTabChange(existingComponentTab), + syncedAt: toWorkspaceSyncTimestamp(existingComponentTab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(existingComponentTab?.lastSyncedRemoteSha), }) const defaultStylesTab = workspaceTabsState.getTab('styles') if (defaultStylesTab) { workspaceTabsState.upsertTab({ + ...defaultStylesTab, id: 'styles', path: stylesPath, name: getPathFileName(stylesPath) || defaultStylesTabName, @@ -1950,6 +2127,10 @@ const syncTabPathsFromInputs = () => { : 'css', role: 'module', isActive: workspaceTabsState.getActiveTabId() === 'styles', + targetPrFilePath: normalizeWorkspacePathValue(stylesPath) || null, + isDirty: getDirtyStateForTabChange(defaultStylesTab), + syncedAt: toWorkspaceSyncTimestamp(defaultStylesTab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(defaultStylesTab?.lastSyncedRemoteSha), }) } @@ -2054,7 +2235,7 @@ prDrawerController = createGitHubPrDrawer({ confirmBeforeSubmit: options => { confirmAction(options) }, - onPullRequestOpened: ({ url }) => { + onPullRequestOpened: ({ url, fileUpdates }) => { const activeContextSyncKey = getActivePrContextSyncKey( githubAiContextState.activePrContext, ) @@ -2069,9 +2250,11 @@ prDrawerController = createGitHubPrDrawer({ const message = url ? `Pull request opened: ${url}` : 'Pull request opened successfully.' + reconcileWorkspaceTabsWithPushUpdates(fileUpdates) showAppToast(message) }, onPullRequestCommitPushed: ({ branch, fileUpdates }) => { + reconcileWorkspaceTabsWithPushUpdates(fileUpdates) const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0 const message = fileCount > 0 @@ -2232,15 +2415,21 @@ const initializeCodeEditors = async () => { } const activeTab = getActiveWorkspaceTab() if (activeTab && getTabKind(activeTab) === 'component') { + const nextDirtyState = getDirtyStateForTabChange(activeTab) workspaceTabsState.upsertTab( { ...activeTab, content: getJsxSource(), + isDirty: nextDirtyState, lastModified: Date.now(), isActive: true, }, { emitReason: 'componentEditorChange' }, ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } } queueWorkspaceSave() maybeRenderFromComponentEditorChange() @@ -2262,15 +2451,21 @@ const initializeCodeEditors = async () => { } const activeTab = getActiveWorkspaceTab() if (activeTab && getTabKind(activeTab) === 'styles') { + const nextDirtyState = getDirtyStateForTabChange(activeTab) workspaceTabsState.upsertTab( { ...activeTab, content: getCssSource(), + isDirty: nextDirtyState, lastModified: Date.now(), isActive: true, }, { emitReason: 'stylesEditorChange' }, ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } } queueWorkspaceSave() maybeRender() @@ -3001,6 +3196,16 @@ if (githubPrLocalContextSelect instanceof HTMLSelectElement) { }) } +if (githubPrLocalContextSearch instanceof HTMLInputElement) { + githubPrLocalContextSearch.addEventListener('input', () => { + localContextSearchQuery = githubPrLocalContextSearch.value + renderLocalContextOptions({ + options: cachedLocalContexts, + query: localContextSearchQuery, + }) + }) +} + for (const element of [ githubPrBaseBranch, githubPrHeadBranch, diff --git a/src/index.html b/src/index.html index 3c21f51..c2470ff 100644 --- a/src/index.html +++ b/src/index.html @@ -783,6 +783,14 @@

Open Pull Request

for="github-pr-local-context-select" > Local contexts +
+ + + + +
+ + +
+
+ +