diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9f4247e..b3a1a72 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -39,20 +39,25 @@ jobs: shardIndex: 1 shardTotal: 1 - browser: webkit - jobName: E2E (Playwright, webkit, shard 1/3) + jobName: E2E (Playwright, webkit, shard 1/4) workers: 1 shardIndex: 1 - shardTotal: 3 + shardTotal: 4 - browser: webkit - jobName: E2E (Playwright, webkit, shard 2/3) + jobName: E2E (Playwright, webkit, shard 2/4) workers: 1 shardIndex: 2 - shardTotal: 3 + shardTotal: 4 - browser: webkit - jobName: E2E (Playwright, webkit, shard 3/3) + jobName: E2E (Playwright, webkit, shard 3/4) workers: 1 shardIndex: 3 - shardTotal: 3 + shardTotal: 4 + - browser: webkit + jobName: E2E (Playwright, webkit, shard 4/4) + workers: 1 + shardIndex: 4 + shardTotal: 4 steps: - name: Checkout diff --git a/docs/next-steps.md b/docs/next-steps.md index a9d10e1..dea46ea 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -25,3 +25,19 @@ Focused follow-up work for `@knighted/develop`. - If beneficial, introduce a configurable/hybrid strategy (for example, optimistic default with metadata fallback) without regressing current reliability. - Suggested implementation prompt: - "Evaluate and optionally optimize @knighted/develop GitHub file upsert behavior. Compare metadata-first preflight GET+PUT against optimistic PUT with retry-on-missing-sha for existing files. Keep current reliability guarantees and avoid reintroducing noisy false-positive failures. If implementing a hybrid/configurable strategy, keep defaults conservative, update docs, and validate with npm run lint plus targeted Playwright PR drawer flows." + +5. **Remove pre-multitab component/styles compatibility paths** + - Delete code paths that preserve or translate legacy single-component/single-styles storage and sync behavior from before the multitab update. + - Remove backward-compatibility shims, fallback field reads, and migration glue tied to old `componentFilePath`/`stylesFilePath`-style assumptions when equivalent tab-derived data exists. + - Favor one canonical tab-first data contract across local storage, IndexedDB workspace records, PR sync metadata, and commit target derivation. + - Accept breaking changes for old locally stored app state to simplify maintenance and reduce branching logic. + - Suggested implementation prompt: + - "Remove backwards-compatibility code in @knighted/develop that supports pre-multitab component/styles storage/sync behavior. Standardize on the current tab-derived schema only, delete legacy field fallbacks and migration helpers, and update tests/docs to match the simplified contract. Validate with npm run lint and targeted Playwright suites for workspace tabs + PR drawer flows." + +6. **Promise handling conventions (consistency of intent)** + - Define a project default: use `async`/`await` with `try`/`catch` for most async control flow. + - Keep Promise chains where they better express intent (for example, fire-and-forget paths with explicit `.catch()` to avoid unhandled rejections, or concise pass-through composition). + - Document this as an intent-first rule so mixed syntax is acceptable only when deliberate and easy to reason about. + - Add a lightweight lint/review rule to flag mixed async styles in the same flow unless there is a clear justification. + - Suggested implementation prompt: + - "Define and apply async handling conventions in @knighted/develop with consistency of intent: default to async/await + try/catch, allow Promise chains for explicit fire-and-forget and concise composition, and require explicit .catch on unawaited promises. Update docs and enforce via lint/review guidance without broad no-op refactors. Validate with npm run lint and targeted Playwright runs." 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..c955287 --- /dev/null +++ b/docs/pr-drawer-workspace-context-separation-plan.md @@ -0,0 +1,46 @@ +# PR Drawer vs Workspace Contexts Remaining Backlog + +This document now tracks only work not yet implemented from the original separation plan. + +## Completed (for context) + +1. Workspaces button and dedicated Workspaces drawer exist. +2. PR drawer no longer exposes component/styles filename inputs. +3. Commit targets are derived from workspace tab metadata. +4. Checkbox copy uses entry-tab language. +5. Confirmation summary includes a Files to commit list. + +## Remaining Work + +### Phase B follow-up + +1. Add multi-select removal in Workspaces drawer. +2. Add richer filtering in Workspaces drawer. +3. Decide whether to keep a quick context-switch affordance in PR drawer. + +### Phase C enhancements + +1. Add open PR binding tools to Workspaces drawer. +2. Add context health indicators in the Workspaces list (dirty, synced, drift). +3. Add optional pin/favorite/recents support. +4. Evaluate optional tab include/exclude toggles for commit targets. + +### Confirmation summary UX polish + +1. For long file lists, cap visible rows and show a +N more summary. + +### Modularization follow-up + +Current implementation is primarily `src/modules/workspaces-drawer/drawer.js`. + +1. Split module if needed into smaller units: + - `state.js` + - `list-render.js` + - `actions.js` +2. Keep PR transactional logic isolated in PR modules. + +## Validation Coverage to Keep + +1. PR drawer tests remain focused on transactional workflows. +2. Workspaces drawer tests cover search/select/delete and future multi-select behavior. +3. Migration tests ensure existing stored contexts are retained. diff --git a/docs/render-pipeline-multitab-spec-plan.md b/docs/render-pipeline-multitab-spec-plan.md index f209cdc..b03d319 100644 --- a/docs/render-pipeline-multitab-spec-plan.md +++ b/docs/render-pipeline-multitab-spec-plan.md @@ -1,136 +1,51 @@ -# Render Pipeline + Multi-Tab Spec Plan +# Render Pipeline + Multi-Tab Vital Remaining Spec TODOs -This document outlines test coverage to add after the render pipeline rewrite is fully integrated with the multi-tab UX. +This file tracks only high-value gaps that still need coverage. -## Why this plan exists +## Already Covered (summary) -Current test coverage intentionally removed a subset of specs that were tightly coupled to pre-rewrite assumptions: +1. Entry-tab role behavior, rename stability, and restore behavior. +2. Cross-tab import graph basics, missing-module and circular-import determinism. +3. Core diagnostics/status flows, including pending/error/neutral transitions. +4. Workspace persistence and per-repo context/config isolation baseline. -1. Legacy assumptions around default-export hydration behavior in preview module assembly. -2. Styles diagnostics behavior that depended on old compile/lint sequencing. -3. PR drawer path validation timing assumptions tied to previous field sync flow. +## Vital Remaining TODOs -These should return as updated tests once the new pipeline contract is finalized. +## 1. Default Export Support Matrix (High Risk) -## Proposed Test Areas +Add explicit render/typecheck specs for: -## 1. Entry Resolution and Execution Semantics +1. `export default class ...` in React mode. +2. `function App() { ... } export default App` behavior. +3. `const X = ...; export default X` behavior with entry-wrapper rules. +4. Unsupported default-export combinations producing deterministic diagnostics. -Goal: Validate how preview entry is resolved from workspace tabs under the `role: entry` model. +## 2. Style Compile vs Lint Contract (High Risk) -Add specs for: +Lock down precedence and parity: -1. Entry selection prefers explicit `role: entry`, with documented fallback behavior only when no explicit entry is present. -2. Entry rename between `App.tsx` and `App.js` keeps execution stable. -3. Entry path updates preserve directory while enforcing filename convention. -4. Reload restores same entry tab and executes same source. +1. Less error-path parity with Sass error behavior. +2. Compile diagnostics + lint diagnostics precedence/coexistence contract. +3. Clearing styles diagnostics does not affect component diagnostics/status. -## 2. Default Export Handling in New Hydration Pipeline +## 3. Status Aggregation Contract (High Risk) -Goal: Reintroduce export-default tests against the final module assembly support matrix. +Add state-machine coverage for: -Add specs for: +1. Multiple simultaneous error sources aggregating counts correctly. +2. Clearing one scope updates only that scope and leaves other error states intact. -1. `export default () => ...` in entry tab with manual render. -2. `export default class ...` in React mode. -3. `function App() { ... } export default App` compatibility. -4. `const Button = ...; export default Button` behavior when App wrapper is implicit or explicit. -5. Negative cases: unsupported default-export combinations produce deterministic diagnostics. +## 4. Inactive Panel Mutation Guard (Medium Risk) -## 3. Cross-Tab Import Graph Hydration +Add keyboard/actionability spec that proves inactive editor panel input cannot mutate source. -Goal: Ensure workspace graph resolution works across multiple component and style tabs. +## 5. Update Obsolete PR Path-Validation Section (Doc/Test Hygiene) -Add specs for: +Old PR drawer filename-field validation cases are obsolete after tab-derived commit targets. -1. Entry imports sibling component tab by relative specifier. -2. Nested dependency chain (A imports B imports C) hydrates in stable order. -3. Missing module path reports actionable preview error including unresolved specifier. -4. Circular import emits stable error (or supported behavior) without hanging. -5. Windows-style and POSIX-style separators normalize consistently in lookup keys. +1. Replace with tab-derived commit target validation/normalization tests. +2. Remove any remaining assumptions about component/styles filename fields in PR drawer flows. -## 4. Styles Pipeline and Diagnostics Contract +## Minimal Done Criteria -Goal: Lock down expected diagnostics and status transitions for style dialects. - -Add specs for: - -1. Sass compilation error sets diagnostics state to error with styles-scope detail. -2. Less error path behavior parity with Sass. -3. Switching style mode clears stale diagnostics according to final pipeline contract. -4. Styles lint diagnostics and compile diagnostics coexist or prioritize per contract. -5. Clearing style diagnostics does not clear unrelated component diagnostics. - -## 5. Status and Diagnostics State Machine - -Goal: Ensure app status text/class and diagnostics toggle class remain consistent. - -Add specs for: - -1. Pending to error to neutral transitions for typecheck + lint + render. -2. Multiple error sources aggregate counts correctly. -3. Clearing one scope updates only corresponding status/diagnostics indicators. -4. Auto-render off path keeps status stable until explicit render. - -## 6. Multi-Tab Tool Visibility and Actionability - -Goal: Guarantee controls are actionable only for active editor tab and panel. - -Add specs for: - -1. Component controls hidden/inert when styles tab is active. -2. Styles controls hidden/inert when component tab is active. -3. Keyboard interactions in inactive panel do not mutate source. -4. Tab switches maintain tool visibility state and collapse state correctly. - -## 7. Persistence and Isolation Guarantees - -Goal: Verify deterministic startup and no stale state bleed between sessions. - -Add specs for: - -1. IndexedDB workspace restore across reload preserves tabs, active tab, entry role, and paths. -2. PR drawer saved config does not unexpectedly overwrite active workspace tab paths. -3. New session starts clean when storage is reset in tests. -4. Repository switch behavior isolates per-repo local context and config. - -## 8. PR Drawer Path Validation and Sync - -Goal: Revisit path validation behavior after final field sync implementation. - -Add specs for: - -1. Reject traversal (`../`) for component and styles paths. -2. Reject trailing slash paths for component and styles fields. -3. Allow dotted segments that are not traversal. -4. Entry-specific filename rule enforcement (`App.tsx` or `App.js`) reflected in drawer path values. - -## 9. Test Infrastructure Improvements - -Goal: Keep suites stable as UX evolves. - -Actions: - -1. Add helper APIs for tab activation before control interactions. -2. Add one reset helper per suite to clear localStorage, sessionStorage, and IndexedDB. -3. Prefer role/name selectors that match active-tab semantics. -4. Avoid assertions that require hidden panel controls to be clickable. - -## Suggested Rollout Order - -1. Entry resolution + default-export support matrix. -2. Cross-tab import graph hydration. -3. Styles diagnostics contract. -4. Status state machine. -5. PR drawer path validation synchronization. -6. Persistence/isolation hardening. - -## Definition of Done for this plan - -Before reintroducing removed specs, the render pipeline implementation should provide a written behavior contract for: - -1. Entry tab selection. -2. Default export support matrix. -3. Style compile + lint diagnostics precedence. -4. Status/diagnostics state transitions. -5. Path normalization and validation across workspace tabs and PR drawer fields. +This plan is complete when the five sections above are covered by Playwright tests and linked from the affected suites. diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index c68c9d6..5cf9fbd 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -14,13 +14,6 @@ import { waitForAppReady, } from './helpers/app-test-helpers.js' -const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop' - -const decodeGitHubFileBodyContent = (body: Record) => { - const encoded = typeof body.content === 'string' ? body.content : '' - return Buffer.from(encoded, 'base64').toString('utf8') -} - const getOpenPrDrawer = (page: Page) => page.getByRole('complementary', { name: /Open Pull Request|Push Commit/ }) @@ -92,12 +85,77 @@ 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, }) => { const customCommitMessage = 'chore: sync develop editor outputs' let createdRefBody: CreateRefRequestBody | null = null - const upsertRequests: Array<{ path: string; body: Record }> = [] + const treeRequests: Array> = [] + const commitRequests: Array> = [] + const updateRefRequests: Array> = [] + const contentsPutRequests: string[] = [] let pullRequestBody: PullRequestCreateBody | null = null await page.route('https://api.github.com/user/repos**', async route => { @@ -135,6 +193,59 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ }, ) + 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 => { + 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/git/refs', async route => { @@ -150,26 +261,14 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', async route => { - const request = route.request() - const method = request.method() - const url = request.url() - const path = new URL(url).pathname.split('/contents/')[1] ?? '' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) } - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) await route.fulfill({ - status: 201, + status: 404, contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), + body: JSON.stringify({ message: 'Not Found' }), }) }, ) @@ -194,21 +293,13 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ 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 page .getByLabel('PR description') .fill('Generated from editor content in @knighted/develop.') await page.getByLabel('Commit message').fill(customCommitMessage) - await submitOpenPrAndConfirm(page, { - expectedSummaryLines: [ - 'Open pull request with editor content?', - 'Component file path: examples/component/App.tsx', - 'Styles file path: examples/styles/app.css', - ], - }) + await submitOpenPrAndConfirm(page) await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), @@ -221,20 +312,17 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({ expect(createdRefPayload?.ref).toBe('refs/heads/Develop/Open-Pr-Test') expect(createdRefPayload?.sha).toBe('abc123mainsha') - - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(customCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(customCommitMessage) + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(customCommitMessage) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('new-commit-sha') + expect(contentsPutRequests).toHaveLength(0) expect(pullRequestPayload?.head).toBe('Develop/Open-Pr-Test') expect(pullRequestPayload?.base).toBe('main') await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Component filename')).toHaveValue( - 'examples/component/App.tsx', - ) - await expect(page.getByLabel('Styles filename')).toHaveValue('examples/styles/app.css') await expect(page.getByLabel('Pull request base branch')).toHaveValue('main') await expect(page.getByLabel('Head')).toHaveValue('Develop/Open-Pr-Test') await expect(page.getByLabel('PR title')).toHaveValue( @@ -251,6 +339,311 @@ 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 page.getByRole('button', { name: 'Workspaces' }).click() + + 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('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 surfaces an error when Git Database commit fails', async ({ + page, +}) => { + const treeRequests: Array> = [] + 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'], + }) + + 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 => { + 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 => { + pullRequestRequestCount += 1 + 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('PR title').fill('Apply editor updates from develop') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Open PR failed:') + + expect(treeRequests).toHaveLength(1) + expect(pullRequestRequestCount).toBe(0) +}) + test('Open PR drawer starts with empty title/description and short default head', async ({ page, }) => { @@ -386,15 +779,14 @@ test('Open PR drawer keeps a single active PR context in localStorage', async ({ await ensureOpenPrDrawerOpen(page) const repoSelect = page.getByLabel('Pull request repository') - const componentPath = page.getByLabel('Component filename') await repoSelect.selectOption('knightedcodemonkey/develop') - await componentPath.fill('examples/develop/App.tsx') - await componentPath.blur() + await page.getByLabel('Head').fill('examples/develop/head') + await page.getByLabel('Head').blur() await repoSelect.selectOption('knightedcodemonkey/css') - await componentPath.fill('examples/css/App.tsx') - await componentPath.blur() + await page.getByLabel('Head').fill('examples/css/head') + await page.getByLabel('Head').blur() const activeContext = await page.evaluate(() => { const storagePrefix = 'knighted:develop:github-pr-config:' @@ -416,7 +808,7 @@ test('Open PR drawer keeps a single active PR context in localStorage', async ({ expect(activeContext.key).toBe( 'knighted:develop:github-pr-config:knightedcodemonkey/css', ) - expect(activeContext.parsed?.componentFilePath).toBe('examples/css/App.tsx') + expect(activeContext.parsed?.headBranch).toBe('examples/css/head') }) test('Open PR drawer does not prune saved PR context on repo switch before save', async ({ @@ -461,11 +853,10 @@ test('Open PR drawer does not prune saved PR context on repo switch before save' await ensureOpenPrDrawerOpen(page) const repoSelect = page.getByLabel('Pull request repository') - const componentPath = page.getByLabel('Component filename') await repoSelect.selectOption('knightedcodemonkey/develop') - await componentPath.fill('examples/develop/App.tsx') - await componentPath.blur() + await page.getByLabel('Head').fill('examples/develop/head') + await page.getByLabel('Head').blur() await repoSelect.selectOption('knightedcodemonkey/css') @@ -493,7 +884,7 @@ test('Open PR drawer does not prune saved PR context on repo switch before save' expect(contexts[0]?.key).toBe( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', ) - expect(contexts[0]?.parsed?.componentFilePath).toBe('examples/develop/App.tsx') + expect(contexts[0]?.parsed?.headBranch).toBe('examples/develop/head') }) test('Active PR context disconnect uses local-only confirmation flow', async ({ @@ -577,8 +968,8 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -750,8 +1141,8 @@ test('Active PR context updates controls and can be closed from AI controls', as localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -840,8 +1231,8 @@ test('Active PR context is disabled on load when pull request is closed', async localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -939,8 +1330,8 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/css', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'css/rehydrate-test', @@ -1050,8 +1441,8 @@ test('Active PR context deactivates after token remove and re-add when PR is clo localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/css', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'css/rehydrate-test', @@ -1164,8 +1555,8 @@ test('Active PR context recovers when saved head branch is missing but PR metada localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: '', @@ -1178,23 +1569,218 @@ test('Active PR context recovers when saved head branch is missing but PR metada ) }) - await connectByotWithSingleRepo(page) + await connectByotWithSingleRepo(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() + await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') +}) + +test('Active PR context uses Push commit flow without creating a new pull request', 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' }), + }) + }, + ) + + 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/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/git/trees', + async route => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Tree API unavailable' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem( + 'knighted:develop:github-pr-config:knightedcodemonkey/develop', + JSON.stringify({ + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: '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 expect(page.getByLabel('Pull request repository')).toBeDisabled() + 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('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', + ) + await expect(includeWrapperToggle).toBeEnabled() + await includeWrapperToggle.check() + await expect(includeWrapperToggle).toBeChecked() + await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() + await expect(page.getByLabel('PR description')).toBeHidden() + await expect(page.getByLabel('Commit message')).toBeVisible() + + await setComponentEditorSource(page, 'const commitMarker = 1') + await setStylesEditorSource(page, '.commit-marker { color: red; }') + const pushCommitMessage = 'chore: push active context sync' + 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 expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), + page.getByText('Push commit to active pull request branch?', { exact: true }), + ).toHaveText('Push commit to active pull request branch?') + await expect( + page.getByText('Head branch: develop/open-pr-test', { exact: true }), + ).toBeVisible() + await expect(page.getByText('Files to commit:', { exact: true })).toBeVisible() + await expect( + page.getByText('App.tsx -> src/components/App.tsx', { exact: true }), + ).toBeVisible() + await expect( + page.getByText('app.css -> src/styles/app.css', { exact: true }), ).toBeVisible() - await ensureOpenPrDrawerOpen(page) - await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() - await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Push commit failed:') + + expect(createRefRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) + expect(contentsPutRequests).toHaveLength(0) }) -test('Active PR context uses Push commit flow without creating a new pull request', async ({ +test('Active PR context push commit uses Git Database API atomic path by default', async ({ page, }) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] 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({ @@ -1277,28 +1863,69 @@ test('Active PR context uses Push commit flow without creating a new pull reques ) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', async route => { - const request = route.request() - const method = request.method() - const url = request.url() - const path = new URL(url).pathname.split('/contents/')[1] ?? '' + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } + 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' }), + }) + }, + ) - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) + 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({ commit: { sha: 'commit-sha' } }), + 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' }), }) }, ) @@ -1309,8 +1936,8 @@ test('Active PR context uses Push commit flow without creating a new pull reques localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1326,52 +1953,15 @@ test('Active PR context uses Push commit flow without creating a new pull reques await connectByotWithSingleRepo(page) await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toBeDisabled() - await expect(page.getByLabel('Pull request base branch')).toBeDisabled() - await expect(page.getByLabel('Head')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('Component filename')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('Styles filename')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('PR title')).toHaveJSProperty('readOnly', true) - await expect( - page.getByLabel('Include App wrapper in committed component source'), - ).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 App wrapper in committed component source', - ) - await expect(includeWrapperToggle).toBeEnabled() - await includeWrapperToggle.check() - await expect(includeWrapperToggle).toBeChecked() - await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() - await expect(page.getByLabel('PR description')).toBeHidden() - await expect(page.getByLabel('Commit message')).toBeVisible() - - await setComponentEditorSource(page, 'const commitMarker = 1') - await setStylesEditorSource(page, '.commit-marker { color: red; }') - const pushCommitMessage = 'chore: push active context sync' + 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 expect( - page.getByText('Push commit to active pull request branch?', { exact: true }), - ).toHaveText('Push commit to active pull request branch?') - await expect( - page.getByText('Head branch: develop/open-pr-test', { exact: true }), - ).toBeVisible() - await expect( - page.getByText('Component file path: examples/component/App.tsx', { exact: true }), - ).toBeVisible() - await expect( - page.getByText('Styles file path: examples/styles/app.css', { exact: true }), - ).toBeVisible() - await dialog.getByRole('button', { name: 'Push commit' }).click() await expect( @@ -1380,17 +1970,19 @@ test('Active PR context uses Push commit flow without creating a new pull reques expect(createRefRequestCount).toBe(0) expect(pullRequestRequestCount).toBe(0) - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(pushCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(pushCommitMessage) + 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 upsertRequests: Array<{ path: string; body: Record }> = [] + const contentsPutRequests: string[] = [] let createRefRequestCount = 0 let pullRequestRequestCount = 0 @@ -1477,25 +2069,25 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re await page.route( 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', async route => { - const request = route.request() - const method = request.method() - const path = 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 + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) } - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) await route.fulfill({ - status: 201, + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + await route.fulfill({ + status: 500, contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), + body: JSON.stringify({ message: 'Tree API unavailable' }), }) }, ) @@ -1506,8 +2098,8 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1541,15 +2133,11 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re await expect( page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') + ).toContainText('Push commit failed:') expect(createRefRequestCount).toBe(0) expect(pullRequestRequestCount).toBe(0) - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(defaultCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(defaultCommitMessage) + expect(contentsPutRequests).toHaveLength(0) }) test('Reloaded active PR context syncs editor content from GitHub branch and restores style mode', async ({ @@ -1615,7 +2203,7 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res return } - if (path === 'examples/component/App.tsx') { + if (path === 'src/components/App.tsx') { await route.fulfill({ status: 200, contentType: 'application/json', @@ -1627,7 +2215,7 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res return } - if (path === 'examples/styles/app.css') { + if (path === 'src/styles/app.css') { await route.fulfill({ status: 200, contentType: 'application/json', @@ -1653,8 +2241,8 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', styleMode: 'sass', baseBranch: 'main', @@ -1738,8 +2326,8 @@ test('Reloaded active PR context falls back to css style mode for unsupported va localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', + syncComponentFilePath: 'src/components/App.tsx', + syncStylesFilePath: 'src/styles/app.css', renderMode: 'react', styleMode: 'scss', baseBranch: 'main', @@ -1757,40 +2345,25 @@ test('Reloaded active PR context falls back to css style mode for unsupported va await expect(page.getByLabel('Style mode')).toHaveValue('css') }) -test('Open PR drawer validates unsafe filepaths', async ({ page }) => { +test('Open PR drawer shows confirmation with tab-derived files', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await ensureOpenPrDrawerOpen(page) - const componentPath = page.getByLabel('Component filename') - await page.getByLabel('PR title').fill('Validate unsafe paths') - await componentPath.fill('../outside/App.tsx') - await expect(componentPath).toHaveValue('../outside/App.tsx') - await componentPath.blur() - await clickOpenPrDrawerSubmit(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Component path: File path cannot include parent directory traversal.') - await expect(page.getByRole('dialog')).toBeHidden() + await page.getByLabel('PR title').fill('Tab-derived summary prompt') + const dialog = await triggerOpenPrConfirmation(page) + await expect(dialog.getByText('Files to commit:', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: 'Cancel' }).click() }) -test('Open PR drawer allows dotted file segments that are not traversal', async ({ +test('Open PR drawer confirmation does not report path traversal errors', async ({ page, }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await ensureOpenPrDrawerOpen(page) - const componentPath = page.getByLabel('Component filename') - const stylesPath = page.getByLabel('Styles filename') - - await componentPath.fill('docs/v1.0..v1.1/App.tsx') - await stylesPath.fill('styles/foo..bar.css') - await expect(componentPath).toHaveValue('docs/v1.0..v1.1/App.tsx') - await expect(stylesPath).toHaveValue('styles/foo..bar.css') - await page.getByLabel('PR title').fill('Allow dotted file segments') - await stylesPath.blur() + await page.getByLabel('PR title').fill('No traversal error in default flow') await expectOpenPrConfirmationPrompt(page) await expect( @@ -1806,7 +2379,7 @@ test('Open PR drawer include App wrapper checkbox defaults off and resets on reo await ensureOpenPrDrawerOpen(page) const includeWrapperToggle = page.getByLabel( - 'Include App wrapper in committed component source', + 'Include entry tab source in committed output', ) await expect(includeWrapperToggle).not.toBeChecked() @@ -1822,7 +2395,7 @@ test('Open PR drawer include App wrapper checkbox defaults off and resets on reo test('Open PR drawer strips App wrapper from committed component source by default', async ({ page, }) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] + const treeRequests: Array> = [] await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -1860,39 +2433,71 @@ test('Open PR drawer strips App wrapper from committed component source by defau ) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + '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({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + body: JSON.stringify({ sha: 'new-tree-sha' }), }) }, ) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', async route => { - const request = route.request() - const method = request.method() - const path = - new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } + 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-app-wrapper' }), + }) + }, + ) - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) + 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({ commit: { sha: 'commit-sha' } }), + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + 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' }), }) }, ) @@ -1932,13 +2537,10 @@ test('Open PR drawer strips App wrapper from committed component source by defau 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', ) - const componentUpserts = upsertRequests.filter(request => - request.path.endsWith('/App.jsx'), - ) - - expect(componentUpserts).toHaveLength(1) - - const strippedComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + 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) expect(strippedComponentSource).toContain('const CounterButton = () =>') expect(strippedComponentSource).not.toContain('const App = () =>') @@ -1947,7 +2549,7 @@ test('Open PR drawer strips App wrapper from committed component source by defau test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ page, }) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] + const treeRequests: Array> = [] await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ @@ -1985,39 +2587,71 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', ) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + '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({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + body: JSON.stringify({ sha: 'new-tree-sha' }), }) }, ) await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', async route => { - const request = route.request() - const method = request.method() - const path = - new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } + 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-app-wrapper' }), + }) + }, + ) - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) + 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({ commit: { sha: 'commit-sha' } }), + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + 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' }), }) }, ) @@ -2049,7 +2683,7 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', await ensureOpenPrDrawerOpen(page) const includeWrapperToggle = page.getByLabel( - 'Include App wrapper in committed component source', + 'Include entry tab source in committed output', ) await includeWrapperToggle.check() @@ -2063,13 +2697,10 @@ test('Open PR drawer includes App wrapper in committed source when toggled on', 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', ) - const componentUpserts = upsertRequests.filter(request => - request.path.endsWith('/App.jsx'), - ) - - expect(componentUpserts).toHaveLength(1) - - const fullComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + 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 = () =>') }) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index e829ded..cfa8646 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -39,8 +39,40 @@ export type PullRequestCreateBody = { export type BranchesByRepo = Record +const isRetryableGotoError = (error: unknown) => { + if (!(error instanceof Error)) { + return false + } + + return /WebKit encountered an internal error|Test timeout/i.test(error.message) +} + +const navigateToApp = async (page: Page, path: string) => { + const wait = (durationMs: number) => + new Promise(resolve => { + setTimeout(resolve, durationMs) + }) + + let attempt = 0 + + while (attempt < 3) { + attempt += 1 + + try { + await page.goto(path, { waitUntil: 'domcontentloaded' }) + return + } catch (error) { + if (attempt >= 3 || !isRetryableGotoError(error)) { + throw error + } + + await wait(attempt * 200) + } + } +} + export const waitForAppReady = async (page: Page, path = appEntryPath) => { - await page.goto(path) + await navigateToApp(page, path) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() await expect .poll(async () => { 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..55f5ff8 100644 --- a/src/app.js +++ b/src/app.js @@ -24,6 +24,7 @@ import { collectTopLevelDeclarations } from './modules/jsx-top-level-declaration import { ensureJsxTransformSource } from './modules/jsx-transform-runtime.js' import { createEditorPoolManager } from './modules/editor-pool-manager.js' import { createWorkspaceTabsState } from './modules/workspace-tabs-state.js' +import { createWorkspacesDrawer } from './modules/workspaces-drawer/drawer.js' import { createDebouncedWorkspaceSaver, createWorkspaceStorageAdapter, @@ -61,19 +62,19 @@ const githubPrStatus = document.getElementById('github-pr-status') const githubPrRepoSelect = document.getElementById('github-pr-repo-select') const githubPrBaseBranch = document.getElementById('github-pr-base-branch') const githubPrHeadBranch = document.getElementById('github-pr-head-branch') -const githubPrComponentPath = document.getElementById('github-pr-component-path') -const githubPrStylesPath = document.getElementById('github-pr-styles-path') const githubPrTitle = document.getElementById('github-pr-title') const githubPrBody = document.getElementById('github-pr-body') const githubPrCommitMessage = document.getElementById('github-pr-commit-message') const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app-wrapper') -const githubPrLocalContextSelect = document.getElementById( - 'github-pr-local-context-select', -) -const githubPrLocalContextRemove = document.getElementById( - 'github-pr-local-context-remove', -) const githubPrSubmit = document.getElementById('github-pr-submit') +const workspacesToggle = document.getElementById('workspaces-toggle') +const workspacesDrawer = document.getElementById('workspaces-drawer') +const workspacesClose = document.getElementById('workspaces-close') +const workspacesStatus = document.getElementById('workspaces-status') +const workspacesSearch = document.getElementById('workspaces-search') +const workspacesSelect = document.getElementById('workspaces-select') +const workspacesOpen = document.getElementById('workspaces-open') +const workspacesRemove = document.getElementById('workspaces-remove') 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') @@ -129,6 +130,7 @@ const defaultComponentTabName = 'App.tsx' const defaultStylesTabName = 'app.css' const defaultEntryTabDirectory = 'src/components' const allowedEntryTabFileNames = new Set(['app.tsx', 'app.js']) +const renderModeStorageKey = 'knighted-develop:render-mode' const editorKinds = ['component', 'styles'] const editorPanelsByKind = { component: componentEditorPanel, @@ -159,6 +161,7 @@ const workspaceStorage = createWorkspaceStorageAdapter() let workspaceSaver = null let activeWorkspaceRecordId = '' let activeWorkspaceCreatedAt = null +let workspacesDrawerController = null let isApplyingWorkspaceSnapshot = false let hasCompletedInitialWorkspaceBootstrap = false const workspaceTabsState = createWorkspaceTabsState({ @@ -698,13 +701,21 @@ const syncAiChatTokenVisibility = token => { const hasToken = typeof token === 'string' && token.trim().length > 0 if (hasToken) { + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.disabled = false + } + aiChatToggle?.removeAttribute('hidden') githubPrToggle?.removeAttribute('hidden') + if (!githubAiContextState.activePrContext) { + workspacesToggle?.removeAttribute('hidden') + } if (githubAiContextState.activePrContext) { githubPrContextClose?.removeAttribute('hidden') githubPrContextDisconnect?.removeAttribute('hidden') + workspacesToggle?.setAttribute('hidden', '') } else { githubPrContextClose?.setAttribute('hidden', '') githubPrContextDisconnect?.setAttribute('hidden', '') @@ -714,6 +725,9 @@ const syncAiChatTokenVisibility = token => { aiChatToggle?.setAttribute('hidden', '') aiChatToggle?.setAttribute('aria-expanded', 'false') + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.disabled = true + } githubAiContextState.activePrContext = null githubAiContextState.activePrEditorSyncKey = '' githubAiContextState.hasSyncedActivePrEditorContent = false @@ -721,10 +735,13 @@ const syncAiChatTokenVisibility = token => { setGitHubPrToggleVisual('open-pr') githubPrToggle?.setAttribute('hidden', '') githubPrToggle?.setAttribute('aria-expanded', 'false') + workspacesToggle?.setAttribute('hidden', '') + workspacesToggle?.setAttribute('aria-expanded', 'false') githubPrContextClose?.setAttribute('hidden', '') githubPrContextDisconnect?.setAttribute('hidden', '') chatDrawerController.setOpen(false) prDrawerController.setOpen(false) + void workspacesDrawerController?.setOpen(false) } const byotControls = createGitHubByotControls({ @@ -819,6 +836,65 @@ 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 toWorkspaceSyncedContent = value => (typeof value === 'string' ? value : 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 hasTabCommittedSyncState = tab => + Boolean( + toWorkspaceSyncTimestamp(tab?.syncedAt) || + toWorkspaceSyncSha(tab?.lastSyncedRemoteSha) || + toWorkspaceSyncedContent(tab?.syncedContent), + ) + +const getDirtyStateForTabChange = (tab, nextContent) => { + if (!hasTabSyncBaseline(tab)) { + return Boolean(tab?.isDirty) + } + + const normalizedNextContent = typeof nextContent === 'string' ? nextContent : '' + const syncedContent = toWorkspaceSyncedContent(tab?.syncedContent) + + if (syncedContent === null) { + if (normalizedNextContent === (typeof tab?.content === 'string' ? tab.content : '')) { + return Boolean(tab?.isDirty) + } + + return true + } + + return normalizedNextContent !== syncedContent +} + +const resolveSyncedBaselineContent = ({ tab, content }) => { + const explicitSyncedContent = toWorkspaceSyncedContent(tab?.syncedContent) + if (explicitSyncedContent !== null) { + return explicitSyncedContent + } + + if (hasTabSyncBaseline(tab) && !tab?.isDirty) { + return content + } + + return null +} + const isStyleTabLanguage = language => styleTabLanguages.has(toNonEmptyWorkspaceText(language)) @@ -900,6 +976,7 @@ const persistActiveTabEditorContent = () => { { ...activeTab, content: nextContent, + isDirty: getDirtyStateForTabChange(activeTab, nextContent), lastModified: Date.now(), isActive: true, }, @@ -1085,8 +1162,18 @@ const ensureWorkspaceTabsShape = tabs => { ...tab, role: 'entry', language: 'javascript-jsx', + content: typeof tab?.content === 'string' ? tab.content : '', path: normalizedEntryPath, name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, + targetPrFilePath: + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(normalizedEntryPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: typeof tab?.content === 'string' ? tab.content : '', + }), } } @@ -1098,22 +1185,43 @@ const ensureWorkspaceTabsShape = tabs => { ...tab, language: isStyleTabLanguage(tab.language) ? tab.language : 'css', role: 'module', + content: typeof tab?.content === 'string' ? tab.content : '', path: normalizedStylesPath, name: !normalizedStylesNameInput || normalizedStylesNameInput.toLowerCase() === 'styles' ? getPathFileName(normalizedStylesPath) || defaultStylesTabName : normalizedStylesNameInput, + targetPrFilePath: + getTabTargetPrFilePath(tab) || + normalizeWorkspacePathValue(normalizedStylesPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: typeof tab?.content === 'string' ? tab.content : '', + }), } } const nextPath = toNonEmptyWorkspaceText(tab?.path) + const nextContent = typeof tab?.content === 'string' ? tab.content : '' return { ...tab, role: 'module', language: isStyleTabLanguage(tab?.language) ? tab.language : 'javascript-jsx', path: nextPath, + content: nextContent, name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, + targetPrFilePath: getTabTargetPrFilePath(tab) || null, + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: nextContent, + }), } }) } @@ -1136,18 +1244,7 @@ const resolveWorkspaceActiveTabId = ({ tabs, requestedActiveTabId }) => { const buildWorkspaceTabsSnapshot = () => { const activeTabId = workspaceTabsState.getActiveTabId() return workspaceTabsState.getTabs().map(tab => { - const isComponentTab = tab.id === 'component' - const isStylesTab = tab.id === 'styles' - const currentPath = isComponentTab - ? typeof githubPrComponentPath?.value === 'string' && - githubPrComponentPath.value.trim() - ? githubPrComponentPath.value.trim() - : tab.path - : isStylesTab - ? typeof githubPrStylesPath?.value === 'string' && githubPrStylesPath.value.trim() - ? githubPrStylesPath.value.trim() - : tab.path - : tab.path + const currentPath = toNonEmptyWorkspaceText(tab.path) const currentContent = tab.id === activeTabId @@ -1158,16 +1255,177 @@ const buildWorkspaceTabsSnapshot = () => { ? tab.content : '' + const targetPrFilePath = + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(currentPath) || null + return { ...tab, path: currentPath, content: currentContent, + syncedContent: toWorkspaceSyncedContent(tab?.syncedContent), + 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, + syncedContent: typeof tab?.content === 'string' ? tab.content : '', + isDirty: false, + syncedAt: now, + lastSyncedRemoteSha: commitSha || toWorkspaceSyncSha(tab.lastSyncedRemoteSha), + lastModified: now, + } + }) + + if (updatedTabCount > 0) { + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId, + }) + queueWorkspaceSave() + } + + return updatedTabCount +} + +const getWorkspacePrFileCommits = () => { + const snapshotTabs = buildWorkspaceTabsSnapshot() + const dedupedByPath = new Map() + + for (const tab of snapshotTabs) { + const shouldCommitTab = Boolean(tab?.isDirty) || !hasTabCommittedSyncState(tab) + if (!shouldCommitTab) { + continue + } + + const path = + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' + if (!path) { + continue + } + + dedupedByPath.set(path, { + path, + content: typeof tab?.content === 'string' ? tab.content : '', + tabLabel: toNonEmptyWorkspaceText(tab?.name) || toNonEmptyWorkspaceText(tab?.id), + isEntry: tab?.role === 'entry', + }) + } + + return [...dedupedByPath.values()] +} + +const getEditorSyncTargets = () => { + const componentTab = getWorkspaceTabByKind('component') + const stylesTab = getWorkspaceTabByKind('styles') + + return { + componentFilePath: + getTabTargetPrFilePath(componentTab) || + normalizeWorkspacePathValue(componentTab?.path) || + '', + stylesFilePath: + getTabTargetPrFilePath(stylesTab) || + normalizeWorkspacePathValue(stylesTab?.path) || + '', + } +} + +const reconcileWorkspaceTabsWithEditorSync = ({ componentPath, stylesPath } = {}) => { + const normalizedComponentPath = normalizeWorkspacePathValue(componentPath) + const normalizedStylesPath = normalizeWorkspacePathValue(stylesPath) + + if (!normalizedComponentPath && !normalizedStylesPath) { + return 0 + } + + const now = Date.now() + let updatedTabCount = 0 + const activeTabId = workspaceTabsState.getActiveTabId() + const componentSource = getJsxSource() + const stylesSource = getCssSource() + + const nextTabs = workspaceTabsState.getTabs().map(tab => { + const tabKind = getTabKind(tab) + const expectedPath = + tabKind === 'styles' ? normalizedStylesPath : normalizedComponentPath + if (!expectedPath) { + return tab + } + + const candidatePaths = [ + getTabTargetPrFilePath(tab), + normalizeWorkspacePathValue(tab.path), + ].filter(Boolean) + const matchedPath = candidatePaths.find(path => path === expectedPath) + if (!matchedPath) { + return tab + } + + const syncedContent = tabKind === 'styles' ? stylesSource : componentSource + updatedTabCount += 1 + return { + ...tab, + content: syncedContent, + syncedContent, + isDirty: false, + syncedAt: now, + lastModified: now, + } + }) + + if (updatedTabCount > 0) { + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId, + }) + queueWorkspaceSave() + } + + return updatedTabCount +} + const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { const context = getWorkspaceContextSnapshot() const id = @@ -1193,70 +1451,21 @@ const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { } } -const updateLocalContextActions = () => { - if (!(githubPrLocalContextRemove instanceof HTMLButtonElement)) { - return - } - - const hasSelection = - typeof githubPrLocalContextSelect?.value === 'string' && - githubPrLocalContextSelect.value.length > 0 - 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() - - 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 listLocalContextRecords = async () => { const selectedRepository = getCurrentSelectedRepository() - const options = await workspaceStorage.listWorkspaces({ + return workspaceStorage.listWorkspaces({ repo: selectedRepository || '', }) +} - 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 - githubPrLocalContextSelect.append(placeholder) - - for (const workspace of options) { - const option = document.createElement('option') - option.value = workspace.id - option.textContent = formatWorkspaceOptionLabel(workspace) - option.selected = workspace.id === activeWorkspaceRecordId - githubPrLocalContextSelect.append(option) - } +const refreshLocalContextOptions = async () => { + const options = await listLocalContextRecords() - if ( - activeWorkspaceRecordId && - !options.some(workspace => workspace.id === activeWorkspaceRecordId) - ) { - activeWorkspaceRecordId = '' - activeWorkspaceCreatedAt = null - githubPrLocalContextSelect.value = '' + if (workspacesDrawerController) { + workspacesDrawerController.setSelectedId(activeWorkspaceRecordId) + await workspacesDrawerController.refresh() } - updateLocalContextActions() return options } @@ -1272,9 +1481,6 @@ const applyWorkspaceRecord = async (workspace, { silent = false } = {}) => { activeWorkspaceCreatedAt = workspace.createdAt ?? null const nextTabs = ensureWorkspaceTabsShape(workspace.tabs) - const componentTab = nextTabs.find(tab => tab.id === 'component') - const stylesTab = nextTabs.find(tab => tab.id === 'styles') - if (typeof workspace.base === 'string' && githubPrBaseBranch) { githubPrBaseBranch.value = workspace.base } @@ -1299,26 +1505,13 @@ const applyWorkspaceRecord = async (workspace, { silent = false } = {}) => { if (renderMode.value !== nextRenderMode) { renderMode.value = nextRenderMode } - - if (typeof componentTab?.path === 'string' && githubPrComponentPath) { - githubPrComponentPath.value = componentTab.path - } - - if (typeof stylesTab?.path === 'string' && githubPrStylesPath) { - githubPrStylesPath.value = stylesTab.path - } else if (githubPrStylesPath instanceof HTMLInputElement) { - githubPrStylesPath.value = '' - } + persistRenderMode(nextRenderMode) const activeTab = getActiveWorkspaceTab() if (activeTab) { loadWorkspaceTabIntoEditor(activeTab) } - if (stylesTab && typeof stylesTab.content === 'string') { - setCssSource(stylesTab.content) - } - renderWorkspaceTabs() updateRenderModeEditability() @@ -1470,17 +1663,13 @@ const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => { ...tab, name: normalizedTabName, path: normalizedEntryPath, + isDirty: getDirtyStateForTabChange( + tab, + typeof tab?.content === 'string' ? tab.content : '', + ), lastModified: Date.now(), }) - if (tab.role === 'entry' && githubPrComponentPath instanceof HTMLInputElement) { - githubPrComponentPath.value = normalizedEntryPath - } - - if (tab.id === 'styles' && githubPrStylesPath instanceof HTMLInputElement) { - githubPrStylesPath.value = normalizedEntryPath - } - syncHeaderLabels() renderWorkspaceTabs() queueWorkspaceSave() @@ -1813,6 +2002,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' @@ -1915,48 +2111,6 @@ const bindWorkspaceMetadataPersistence = element => { element.addEventListener('blur', flush) } -const syncTabPathsFromInputs = () => { - const requestedComponentPath = - typeof githubPrComponentPath?.value === 'string' && githubPrComponentPath.value.trim() - ? githubPrComponentPath.value.trim() - : defaultComponentTabPath - const componentPath = normalizeEntryTabPath(requestedComponentPath) - const stylesPath = - typeof githubPrStylesPath?.value === 'string' && githubPrStylesPath.value.trim() - ? githubPrStylesPath.value.trim() - : defaultStylesTabPath - - if (githubPrComponentPath instanceof HTMLInputElement) { - githubPrComponentPath.value = componentPath - } - - workspaceTabsState.upsertTab({ - id: 'component', - path: componentPath, - name: getPathFileName(componentPath) || defaultComponentTabName, - language: 'javascript-jsx', - role: 'entry', - isActive: workspaceTabsState.getActiveTabId() === 'component', - }) - - const defaultStylesTab = workspaceTabsState.getTab('styles') - if (defaultStylesTab) { - workspaceTabsState.upsertTab({ - id: 'styles', - path: stylesPath, - name: getPathFileName(stylesPath) || defaultStylesTabName, - language: isStyleTabLanguage(defaultStylesTab.language) - ? defaultStylesTab.language - : 'css', - role: 'module', - isActive: workspaceTabsState.getActiveTabId() === 'styles', - }) - } - - syncHeaderLabels() - renderWorkspaceTabs() -} - const getCurrentWritableRepositories = () => githubAiContextState.writableRepositories.length > 0 ? [...githubAiContextState.writableRepositories] @@ -1978,8 +2132,22 @@ const getTopLevelDeclarations = async source => { } const prEditorSyncController = createGitHubPrEditorSyncController({ - setComponentSource: setJsxSource, - setStylesSource: setCssSource, + setComponentSource: value => { + suppressEditorChangeSideEffects = true + try { + setJsxSource(value) + } finally { + suppressEditorChangeSideEffects = false + } + }, + setStylesSource: value => { + suppressEditorChangeSideEffects = true + try { + setCssSource(value) + } finally { + suppressEditorChangeSideEffects = false + } + }, scheduleRender: () => { if ( autoRenderToggle?.checked && @@ -2030,8 +2198,6 @@ prDrawerController = createGitHubPrDrawer({ repositorySelect: githubPrRepoSelect, baseBranchInput: githubPrBaseBranch, headBranchInput: githubPrHeadBranch, - componentPathInput: githubPrComponentPath, - stylesPathInput: githubPrStylesPath, prTitleInput: githubPrTitle, prBodyInput: githubPrBody, commitMessageInput: githubPrCommitMessage, @@ -2043,8 +2209,8 @@ prDrawerController = createGitHubPrDrawer({ getSelectedRepository: getCurrentSelectedRepository, getWritableRepositories: getCurrentWritableRepositories, setSelectedRepository: setCurrentSelectedRepository, - getComponentSource: () => getJsxSource(), - getStylesSource: () => getCssSource(), + getFileCommits: getWorkspacePrFileCommits, + getEditorSyncTargets, getTopLevelDeclarations, getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, @@ -2054,7 +2220,7 @@ prDrawerController = createGitHubPrDrawer({ confirmBeforeSubmit: options => { confirmAction(options) }, - onPullRequestOpened: ({ url }) => { + onPullRequestOpened: ({ url, fileUpdates }) => { const activeContextSyncKey = getActivePrContextSyncKey( githubAiContextState.activePrContext, ) @@ -2069,9 +2235,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 @@ -2082,6 +2250,13 @@ prDrawerController = createGitHubPrDrawer({ onActivePrContextChange: activeContext => { syncActivePrContextUi(activeContext) syncAiChatTokenVisibility(githubAiContextState.token) + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.hidden = Boolean(activeContext) + } + + if (activeContext) { + void workspacesDrawerController?.setOpen(false) + } }, onSyncActivePrEditorContent: async args => { const result = await prEditorSyncController.syncFromActiveContext(args) @@ -2097,6 +2272,11 @@ prDrawerController = createGitHubPrDrawer({ if (result?.synced === true) { githubAiContextState.hasSyncedActivePrEditorContent = true syncEditorPrContextIndicators(true) + + reconcileWorkspaceTabsWithEditorSync({ + componentPath: args?.activeContext?.componentFilePath, + stylesPath: args?.activeContext?.stylesFilePath, + }) } return result @@ -2109,6 +2289,73 @@ prDrawerController = createGitHubPrDrawer({ }, }) +workspacesDrawerController = createWorkspacesDrawer({ + toggleButton: workspacesToggle, + drawer: workspacesDrawer, + closeButton: workspacesClose, + statusNode: workspacesStatus, + searchInput: workspacesSearch, + selectInput: workspacesSelect, + openButton: workspacesOpen, + removeButton: workspacesRemove, + getDrawerSide: () => { + return 'right' + }, + onRefreshRequested: listLocalContextRecords, + onOpenSelected: async workspaceId => { + try { + const record = await workspaceStorage.getWorkspaceById(workspaceId) + if (!record) { + await refreshLocalContextOptions() + workspacesDrawerController?.setStatus( + 'Stored local context no longer exists.', + 'error', + ) + return false + } + + return applyWorkspaceRecord(record, { silent: false }) + } catch { + workspacesDrawerController?.setStatus( + 'Could not load selected local context.', + 'error', + ) + return false + } + }, + onRemoveSelected: async workspaceId => { + confirmAction({ + title: 'Remove stored local context?', + copy: 'This removes only local workspace metadata and editor content from this browser.', + confirmButtonText: 'Remove', + onConfirm: () => { + void workspaceStorage + .removeWorkspace(workspaceId) + .then(async () => { + if (activeWorkspaceRecordId === workspaceId) { + activeWorkspaceRecordId = '' + activeWorkspaceCreatedAt = null + } + + await refreshLocalContextOptions() + workspacesDrawerController?.setStatus( + 'Removed stored local context.', + 'neutral', + ) + }) + .catch(() => { + workspacesDrawerController?.setStatus( + 'Could not remove stored local context.', + 'error', + ) + }) + }, + }) + + return false + }, +}) + prDrawerController.setToken(githubAiContextState.token) prDrawerController.setSelectedRepository(githubAiContextState.selectedRepository) prDrawerController.syncRepositories() @@ -2188,6 +2435,27 @@ const getStyleEditorLanguage = mode => { const normalizeRenderMode = mode => (mode === 'react' ? 'react' : 'dom') +const persistRenderMode = mode => { + const normalizedMode = normalizeRenderMode(mode) + + try { + localStorage.setItem(renderModeStorageKey, normalizedMode) + } catch { + /* Ignore storage write errors in restricted browsing modes. */ + } +} + +const getInitialRenderMode = () => { + try { + const value = localStorage.getItem(renderModeStorageKey) + return normalizeRenderMode(value) + } catch { + /* Ignore storage read errors in restricted browsing modes. */ + } + + return 'dom' +} + const updateRenderModeEditability = () => { if (!(renderMode instanceof HTMLSelectElement)) { return @@ -2232,15 +2500,23 @@ const initializeCodeEditors = async () => { } const activeTab = getActiveWorkspaceTab() if (activeTab && getTabKind(activeTab) === 'component') { + const nextContent = getJsxSource() + const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) workspaceTabsState.upsertTab( { ...activeTab, - content: getJsxSource(), + content: nextContent, + syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), + isDirty: nextDirtyState, lastModified: Date.now(), isActive: true, }, { emitReason: 'componentEditorChange' }, ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } } queueWorkspaceSave() maybeRenderFromComponentEditorChange() @@ -2262,15 +2538,23 @@ const initializeCodeEditors = async () => { } const activeTab = getActiveWorkspaceTab() if (activeTab && getTabKind(activeTab) === 'styles') { + const nextContent = getCssSource() + const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) workspaceTabsState.upsertTab( { ...activeTab, - content: getCssSource(), + content: nextContent, + syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), + isDirty: nextDirtyState, lastModified: Date.now(), isActive: true, }, { emitReason: 'stylesEditorChange' }, ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } } queueWorkspaceSave() maybeRender() @@ -2795,6 +3079,8 @@ function applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext = fal renderMode.value = nextMode } + persistRenderMode(nextMode) + resetDiagnosticsFlow() maybeRender() @@ -2847,6 +3133,9 @@ function applyStyleMode({ mode }) { } maybeRender() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) } renderMode.addEventListener('change', () => { @@ -2977,89 +3266,10 @@ cssEditor.addEventListener('blur', () => { }) }) -if (githubPrLocalContextSelect instanceof HTMLSelectElement) { - githubPrLocalContextSelect.addEventListener('change', () => { - const selectedId = githubPrLocalContextSelect.value - updateLocalContextActions() - - if (!selectedId) { - return - } - - void workspaceStorage - .getWorkspaceById(selectedId) - .then(record => { - if (!record) { - return refreshLocalContextOptions() - } - - return applyWorkspaceRecord(record, { silent: false }) - }) - .catch(() => { - setStatus('Could not load selected local context.', 'error') - }) - }) -} - -for (const element of [ - githubPrBaseBranch, - githubPrHeadBranch, - githubPrComponentPath, - githubPrStylesPath, - githubPrTitle, -]) { +for (const element of [githubPrBaseBranch, githubPrHeadBranch, githubPrTitle]) { bindWorkspaceMetadataPersistence(element) } -for (const element of [githubPrComponentPath, githubPrStylesPath]) { - if (!(element instanceof HTMLInputElement)) { - continue - } - - const handler = () => { - syncTabPathsFromInputs() - } - - element.addEventListener('input', handler) - element.addEventListener('change', handler) - element.addEventListener('blur', handler) -} - -if (githubPrLocalContextRemove instanceof HTMLButtonElement) { - githubPrLocalContextRemove.addEventListener('click', () => { - const selectedId = - githubPrLocalContextSelect instanceof HTMLSelectElement - ? githubPrLocalContextSelect.value - : '' - - if (!selectedId) { - return - } - - confirmAction({ - title: 'Remove stored local context?', - copy: 'This removes only local workspace metadata and editor content from this browser.', - confirmButtonText: 'Remove', - onConfirm: () => { - void workspaceStorage - .removeWorkspace(selectedId) - .then(async () => { - if (activeWorkspaceRecordId === selectedId) { - activeWorkspaceRecordId = '' - activeWorkspaceCreatedAt = null - } - - await refreshLocalContextOptions() - setStatus('Removed stored local context.', 'neutral') - }) - .catch(() => { - setStatus('Could not remove stored local context.', 'error') - }) - }, - }) - }) -} - for (const button of appThemeButtons) { button.addEventListener('click', () => { const nextTheme = button.dataset.appTheme @@ -3226,6 +3436,7 @@ if (workspaceTabAddStyles instanceof HTMLButtonElement) { } applyTheme(getInitialTheme(), { persist: false }) +renderMode.value = getInitialRenderMode() applyEditorToolsVisibility() applyPanelCollapseState() syncHeaderLabels() diff --git a/src/index.html b/src/index.html index 3c21f51..f9d8962 100644 --- a/src/index.html +++ b/src/index.html @@ -157,6 +157,23 @@

Open PR + + + + +

+ Manage local workspace contexts stored in this browser. +

+ +
+ + + + +
+ + +
+
+ +

aria-label="Open pull request status" data-level="neutral" > - Configure repository, file paths, and branch details. + Configure repository, branch details, and commit metadata.

- - - - - -