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