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
+
+
+