diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md
index 029a7ce..2124523 100644
--- a/docs/idb-workspace-state.md
+++ b/docs/idb-workspace-state.md
@@ -17,13 +17,14 @@ Each workspace record may include:
- `id`
- `createdAt`
- `lastModified`
+ - `workspaceScope` (`local` | `repository`)
- Repository and PR context:
- `repo`
- `base`
- `head`
- `prTitle`
- `prNumber`
- - `prContextState` (`inactive` | `active` | `disconnected` | `closed`)
+ - `prContextState` (`inactive` | `active` | `closed`)
- Runtime/editor state:
- `renderMode`
- `activeTabId`
@@ -37,10 +38,34 @@ IDB supports that by storing:
- Full workspace snapshots
- Repo-scoped context records
-- Historical transitions such as disconnected or closed PR context
+- Historical transitions such as closed PR context
## Design Rule
If a value is required to accurately restore PR/workspace behavior after reload, it must live in IDB records.
`localStorage` should only mirror user preferences and lightweight bootstrap values.
+
+## Post-Push Baseline Invariant
+
+After a successful Push Commit action for an active PR workspace:
+
+- The active workspace record must persist immediately in IDB.
+- Any committed tab path returned by push updates must persist with:
+ - `isDirty = false`
+ - `syncedContent = content`
+ - `syncedAt` updated to the push/reconcile time
+ - `lastSyncedRemoteSha` set when a commit SHA is available
+- The same clean baseline must survive a full page reload.
+
+Dirty-state note:
+
+- When `syncedContent` is present for a tab, canonical dirty state is derived from
+ `content !== syncedContent`.
+- This prevents stale UI-only dirty flags from overriding persisted sync baseline truth.
+
+## Behavioral Spec
+
+For action-level drawer semantics and state machine behavior, see:
+
+- `docs/workspaces-behavior-algorithm.md`
diff --git a/docs/issue-99-workspaces-drawer-plan.md b/docs/issue-99-workspaces-drawer-plan.md
new file mode 100644
index 0000000..4656b19
--- /dev/null
+++ b/docs/issue-99-workspaces-drawer-plan.md
@@ -0,0 +1,71 @@
+# Issue #99 + Workspaces Drawer UX Plan
+
+## Goal
+
+Simplify PR/workspace lifecycle and the Workspaces drawer UX by:
+
+- removing disconnected state and Disconnect action paths
+- using workspace terminology in the drawer (not context)
+- separating new workspace initialization from workspace selection
+- preserving strict explicit intent semantics (no implicit apply/mutation from select changes)
+
+## Decisions
+
+- New workspace is an explicit direct action via a `New workspace` button.
+- `New workspace` must work for both repository scopes and `Local`.
+- `Open` remains the explicit action for applying an existing stored workspace.
+- If the selected repository has no stored workspaces, hide the workspace select and show the new-workspace path.
+
+## Implementation Steps
+
+1. Remove disconnected model paths (Issue #99)
+
+- Remove Disconnect control from UI.
+- Remove disconnected event wiring and runtime callbacks.
+- Remove disconnected public action paths.
+- Normalize legacy `disconnected` records to `inactive` during restore/normalization.
+
+2. Redesign drawer flow
+
+- Replace starter option-in-select behavior with a dedicated `New workspace` button adjacent to repository select.
+- Keep workspace select for stored workspaces only.
+- Hide workspace select when no stored workspaces exist for the selected scope.
+- Keep strict explicit selection semantics (no auto-apply from select/filter changes).
+
+3. Update copy and accessibility
+
+- Replace "Stored contexts" and related "context" wording with "Workspace" wording.
+- Update status and aria labels consistently.
+
+4. Remove obsolete code paths
+
+- Remove starter prefix constants and parsing.
+- Remove disconnected-only logic and stale styling/branches.
+
+5. Update tests
+
+- Remove/replace disconnected-focused scenarios.
+- Update helpers/selectors to new workspace labels and `New workspace` action.
+- Add/adjust scenarios for empty repository scope (select hidden) and explicit Open behavior for existing workspaces.
+
+6. Update docs
+
+- Update storage/state docs to remove disconnected semantics.
+- Update drawer UX docs to reflect repository row + new workspace action flow.
+
+## Verification
+
+1. `npm run lint`
+2. Targeted Playwright (Chromium first):
+
+- `playwright/github-pr-drawer/active-context-switch.spec.ts`
+- `playwright/github-pr-drawer/open-pr-create.spec.ts`
+
+3. Broader Playwright run for workspace/PR drawer flows.
+4. Manual verification in dev server for:
+
+- repository + new workspace row
+- local new workspace creation
+- hidden workspace select when no stored entries
+- explicit Open required for existing entries
+- no Disconnect control
diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md
index a9af9ba..ae273a2 100644
--- a/docs/localstorage-state.md
+++ b/docs/localstorage-state.md
@@ -19,7 +19,7 @@ Do not store pull request context in `localStorage`.
Examples that must stay out of `localStorage`:
- Selected repository preference (`owner/repo`)
-- PR context state (`active`, `disconnected`, `closed`, `inactive`)
+- PR context state (`active`, `closed`, `inactive`)
- PR number and URL
- PR base/head/title/body
- PR drawer repository-scoped workflow state
@@ -32,3 +32,7 @@ Examples that must stay out of `localStorage`:
If data is needed to restore workspace or pull request workflow state, it belongs in IndexedDB workspace records.
Repository selection is derived from in-memory BYOT controls and IndexedDB-backed workspace records, not from a dedicated localStorage key.
+
+For the Workspaces drawer action/state algorithm, see:
+
+- `docs/workspaces-behavior-algorithm.md`
diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md
index 8a068fc..14d9979 100644
--- a/docs/pr-context-storage-matrix.md
+++ b/docs/pr-context-storage-matrix.md
@@ -20,7 +20,7 @@ See the full storage ownership docs for non-PR keys:
- Database: `knighted-develop-workspaces`
- Object store: `prWorkspaces`
- Relevant fields in each workspace record:
- - `prContextState`: `inactive` | `active` | `disconnected` | `closed`
+ - `prContextState`: `inactive` | `active` | `closed`
- `prNumber`: `number | null`
- `prTitle`, `base`, `head`
- `repo`
@@ -34,12 +34,12 @@ See the full storage ownership docs for non-PR keys:
Use this matrix as the source of truth when debugging UI/storage mismatch.
-| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
-| --------------------------------------------- | -------------------- | --------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
-| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
-| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
-| C. Workspace is for a disconnected PR context | `disconnected` | last known PR number if available | none | Opening this workspace from Workspaces restores PR runtime context and verifies open/closed state with GitHub. |
-| D. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
+| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage PR fields | Notes |
+| ------------------------------------------ | -------------------- | ---------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------- |
+| A. Local workspace only, no PR context | `inactive` | `null` | none | No connected PR context. |
+| B. Workspace is for an active, open PR | `active` | PR number | none | Push mode in PR controls. |
+| C. Workspace is for a PR closed on GitHub | `closed` | closed PR number | none | Historical context retained for debugging/reference. |
+| D. Active PR immediately after push commit | `active` | PR number | none | Committed tabs persist clean baseline (`isDirty=false`, `syncedContent=content`) and remain clean after reload. |
## Current Workspace Selection On Load
@@ -81,10 +81,12 @@ When the UI does not match expected PR state:
- `prContextState`
- `prNumber`
- `repo`, `head`, `prTitle`
+
+- committed tab fields: `isDirty`, `syncedContent`, `content`, `syncedAt`, `lastSyncedRemoteSha`
+
2. Compare against the matrix above.
-3. If scenario C is expected, open that workspace from Workspaces to restore runtime PR context.
-4. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`.
-5. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
+3. If the PR is still open on GitHub, expect PR controls to return to Push mode and the workspace record to transition back to `active`.
+4. If the PR is no longer open, expect Open PR mode to remain and status messaging to explain verification results.
## Console Snippets
@@ -99,19 +101,13 @@ indexedDB.open('knighted-develop-workspaces').onsuccess = event => {
}
```
-## Reconnect Behavior
-
-Reconnect behavior from the Workspaces drawer is implemented.
-
-Opening a `disconnected` workspace record restores active PR runtime context for that repository and reinitializes editor state from the selected workspace record.
-
## End-Of-Session Behavior
-`Disconnect` and `Close` are treated as end-of-session actions for PR-linked workspaces.
+`Close` is the end-of-session action for PR-linked workspaces.
-When either action is confirmed:
+When close is confirmed:
-1. The current workspace is archived as historical (`disconnected` or `closed`).
+1. The current workspace is archived as historical (`closed`).
2. The app immediately switches to a fresh local workspace (`inactive`) with a single empty entry tab.
3. Status messaging guides the user to continue locally or reopen a stored workspace from Workspaces.
diff --git a/docs/workspaces-behavior-algorithm.md b/docs/workspaces-behavior-algorithm.md
new file mode 100644
index 0000000..f34bd7a
--- /dev/null
+++ b/docs/workspaces-behavior-algorithm.md
@@ -0,0 +1,96 @@
+# Workspaces Drawer Behavior Algorithm
+
+This document locks in the intended behavior for Workspaces drawer actions.
+
+## Goals
+
+- Keep action semantics explicit and predictable.
+- Keep button visibility state-driven and mutually exclusive.
+- Preserve workspace restore behavior by persisting state in IndexedDB.
+
+## Core Terms
+
+- `Local` scope: Workspaces whose `workspaceScope` is `local`.
+- `Repository` scope: Workspaces whose `workspaceScope` is `repository` and whose `repo` matches the selected repository filter.
+- `workspaceKey`: Derived identity key from repository + head branch. Used for matching/preference logic, not for UI policy by itself.
+
+## Required Invariants
+
+1. `Initialize` and `New workspace` must never be visible at the same time.
+2. `Local` scope never shows `Initialize`.
+3. `Initialize` for non-Local empty scope updates the active workspace in place (no fork).
+4. `New workspace` always forks from current editor/runtime state into a new record id.
+5. Fork creation must generate a fresh head branch suffix so `workspaceKey` and visible labels are distinct.
+6. Any workspace created via `New workspace` must persist with `prContextState = "inactive"`.
+7. For `New workspace`, `workspaceScope` is target-dependent:
+ - `local` when repository filter is `__local__`
+ - `repository` when repository filter is a non-local `owner/repo`
+
+## UI State Machine
+
+State is derived from:
+
+- Selected repository filter (`__local__` vs non-local `owner/repo`)
+- Presence of stored workspaces in the selected scope
+
+States:
+
+1. `local-empty`
+ - Show: `New workspace`
+ - Hide: `Initialize`, workspace select, `Open`, `Remove`
+2. `local-with-workspaces`
+ - Show: `New workspace`, workspace select, `Open`, `Remove`
+ - Hide: `Initialize`
+3. `repository-empty`
+ - Show: `Initialize`
+ - Hide: `New workspace`, workspace select, `Open`, `Remove`
+4. `repository-with-workspaces`
+ - Show: `New workspace`, workspace select, `Open`, `Remove`
+ - Hide: `Initialize`
+
+## Action Semantics
+
+### A) Local + New workspace
+
+- Action: fork current workspace into a new record.
+- Persisted updates:
+ - `workspaceScope = "local"`
+ - `prContextState = "inactive"`
+ - `repo = ""`
+ - fresh `id`
+ - fresh local `head` (suffix-appended)
+ - `workspaceKey = local::`
+
+### B) Non-Local + Initialize (no stored workspaces in selected repository)
+
+- Action: update active workspace in place to selected repository scope.
+- Persisted updates on current record:
+ - `workspaceScope = "repository"`
+ - `repo = `
+ - `workspaceKey = ::`
+- Must preserve current record id.
+
+### C) Non-Local + New workspace (stored workspaces exist)
+
+- Action: fork current workspace into a new repository-scoped record.
+- Persisted updates:
+ - `workspaceScope = "repository"`
+ - `prContextState = "inactive"`
+ - `repo = `
+ - fresh `id`
+ - fresh repository `head` (suffix-appended from current head)
+ - `workspaceKey = ::`
+
+## Storage Notes
+
+- Canonical workflow state lives in IndexedDB (`prWorkspaces` records).
+- `localStorage` must not own repository/workspace workflow state.
+
+## Regression Coverage Expectations
+
+At minimum, tests should verify:
+
+1. Local `New workspace` creates a new record and distinct local label/key.
+2. Non-local empty scope shows only `Initialize` and updates active record in place.
+3. Non-local scope with records shows only `New workspace` and forks new record.
+4. `Initialize` and `New workspace` never coexist.
diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts
index e5f61f4..add40b3 100644
--- a/playwright/github-byot-ai.spec.ts
+++ b/playwright/github-byot-ai.spec.ts
@@ -768,22 +768,16 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.getByRole('button', { name: 'Workspaces' }).click()
const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter')
- const storedContextsSelect = page.getByLabel('Stored local editor contexts')
- const openStoredContextButton = page.getByRole('button', {
- name: 'Open',
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
exact: true,
})
await expect(workspaceRepositoryFilter).toBeVisible()
await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop')
await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
- await expect(storedContextsSelect).toBeVisible()
- await storedContextsSelect.selectOption({
- label: 'Start new context for knightedcodemonkey/develop',
- })
- await expect(openStoredContextButton).toBeEnabled()
- await openStoredContextButton.click()
- await page.getByRole('button', { name: 'Close workspaces drawer' }).click()
+ await expect(initializeButton).toBeVisible()
+ await initializeButton.click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
diff --git a/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts
new file mode 100644
index 0000000..34bd55d
--- /dev/null
+++ b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts
@@ -0,0 +1,750 @@
+import { expect, test } from '@playwright/test'
+import type { Route } from '@playwright/test'
+import {
+ appEntryPath,
+ buildWorkspaceRecordId,
+ connectByotWithSingleRepo,
+ ensureOpenPrDrawerOpen,
+ getWorkspaceTabsRecord,
+ openStoredWorkspaceContextByHead,
+ seedLocalWorkspaceContexts,
+ setComponentEditorSource,
+ toRecordIntegritySnapshot,
+ waitForAppReady,
+} from './github-pr-drawer.helpers.js'
+
+const repositoryFullName = 'knightedcodemonkey/develop'
+const sandboxRepositoryFullName = 'knightedcodemonkey/develop-sandbox'
+
+const setupSandboxRepositoryRoutes = async ({
+ page,
+ pHeadBranch,
+ ppHeadBranch,
+ onPullRequestRequest,
+}: {
+ page: Parameters[0]
+ pHeadBranch: string
+ ppHeadBranch: string
+ // eslint-disable-next-line no-unused-vars
+ onPullRequestRequest?: (_input: {
+ pullRequestNumber: number
+ route: Route
+ }) => Promise
+}) => {
+ const [repositoryOwner, repositoryName] = sandboxRepositoryFullName.split('/')
+
+ 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: repositoryOwner },
+ name: repositoryName,
+ full_name: sandboxRepositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await page.route('https://api.github.com/repos/**/branches**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { name: 'main' },
+ { name: pHeadBranch },
+ { name: ppHeadBranch },
+ ]),
+ })
+ })
+
+ await page.route('https://api.github.com/repos/**/pulls**', async route => {
+ const url = new URL(route.request().url())
+ const match = url.pathname.match(/\/pulls\/(\d+)$/)
+ const pullRequestNumber = match ? Number.parseInt(match[1], 10) : Number.NaN
+
+ if (!Number.isFinite(pullRequestNumber)) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ if (typeof onPullRequestRequest === 'function') {
+ await onPullRequestRequest({ pullRequestNumber, route })
+ return
+ }
+
+ const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch
+ const title = pullRequestNumber === 70 ? 'PP' : 'P'
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: pullRequestNumber,
+ state: 'open',
+ title,
+ html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`,
+ head: { ref: headRef },
+ base: { ref: 'main' },
+ }),
+ })
+ })
+}
+
+const seedSandboxActivePpContexts = async ({
+ page,
+ pHeadBranch,
+ ppHeadBranch,
+}: {
+ page: Parameters[0]
+ pHeadBranch: string
+ ppHeadBranch: string
+}) => {
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7',
+ repo: sandboxRepositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: pHeadBranch,
+ prTitle: 'P',
+ prNumber: 69,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { P } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'button { padding: 10px; }\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ {
+ id: 'ws_d6502674-64fd-46a6-9418-596f31067779',
+ repo: sandboxRepositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: ppHeadBranch,
+ prTitle: 'PP',
+ prNumber: 70,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { PP } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { color: red; }\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+}
+
+const seedRepositoryWorkspaces = async ({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+}: {
+ page: Parameters[0]
+ sourceHeadBranch: string
+ targetHeadBranch: string
+}) => {
+ const now = Date.now()
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: sourceHeadBranch,
+ }),
+ repo: repositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: sourceHeadBranch,
+ prTitle: 'Source workspace',
+ prContextState: 'inactive',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Source baseline',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: targetHeadBranch,
+ }),
+ repo: repositoryFullName,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: targetHeadBranch,
+ prTitle: 'Target workspace',
+ prContextState: 'inactive',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Target baseline',
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
+ },
+ ])
+}
+
+test('Pending debounced source edit does not overwrite switched-to workspace', async ({
+ page,
+}) => {
+ const sourceHeadBranch = 'develop/issue-debounce-source'
+ const targetHeadBranch = 'develop/issue-debounce-target'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedRepositoryWorkspaces({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+ })
+
+ await connectByotWithSingleRepo(page, {
+ branchesByRepo: {
+ [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch],
+ },
+ })
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+
+ const pendingSourceContent =
+ 'export const App = () => Source pending debounce payload'
+ await setComponentEditorSource(page, pendingSourceContent)
+
+ await openStoredWorkspaceContextByHead(page, targetHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const targetRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: targetHeadBranch,
+ })
+
+ return toRecordIntegritySnapshot(targetRecord)
+ })
+ .toMatchObject({
+ repo: repositoryFullName,
+ head: targetHeadBranch,
+ prContextState: 'inactive',
+ componentContent: 'export const App = () => Target baseline',
+ })
+})
+
+test('Rapid A->B->A switching with pending edits avoids cross-workspace tab contamination', async ({
+ page,
+}) => {
+ const sourceHeadBranch = 'develop/issue-roundtrip-source'
+ const targetHeadBranch = 'develop/issue-roundtrip-target'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedRepositoryWorkspaces({
+ page,
+ sourceHeadBranch,
+ targetHeadBranch,
+ })
+
+ await connectByotWithSingleRepo(page, {
+ branchesByRepo: {
+ [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch],
+ },
+ })
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+ const sourcePendingPayload =
+ 'export const App = () => Source pending during roundtrip'
+ await setComponentEditorSource(page, sourcePendingPayload)
+
+ await openStoredWorkspaceContextByHead(page, targetHeadBranch)
+ const targetPendingPayload =
+ 'export const App = () => Target pending during roundtrip'
+ await setComponentEditorSource(page, targetPendingPayload)
+
+ await openStoredWorkspaceContextByHead(page, sourceHeadBranch)
+
+ await expect
+ .poll(async () => {
+ const sourceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: sourceHeadBranch,
+ })
+ const targetRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: targetHeadBranch,
+ })
+ const sourceSnapshot = toRecordIntegritySnapshot(sourceRecord)
+ const targetSnapshot = toRecordIntegritySnapshot(targetRecord)
+
+ return {
+ sourceHead: sourceSnapshot.head,
+ targetHead: targetSnapshot.head,
+ sourceHasTargetPayload:
+ sourceSnapshot.componentContent.trim() === targetPendingPayload,
+ targetHasSourcePayload:
+ targetSnapshot.componentContent.trim() === sourcePendingPayload,
+ }
+ })
+ .toEqual({
+ sourceHead: sourceHeadBranch,
+ targetHead: targetHeadBranch,
+ sourceHasTargetPayload: false,
+ targetHasSourcePayload: false,
+ })
+})
+
+test('Switching between active P and PP contexts preserves record ids, keys, and tab shapes', async ({
+ page,
+}) => {
+ const pHeadBranch = 'feat/P'
+ const ppHeadBranch = 'feat/PP'
+ const repository = 'knightedcodemonkey/develop-sandbox'
+ const [repositoryOwner, repositoryName] = repository.split('/')
+
+ 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: repositoryOwner },
+ name: repositoryName,
+ full_name: repository,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await page.route('https://api.github.com/repos/**/branches**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { name: 'main' },
+ { name: pHeadBranch },
+ { name: ppHeadBranch },
+ ]),
+ })
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7',
+ repo: repository,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: pHeadBranch,
+ prTitle: 'P',
+ prNumber: 64,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { P } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { color: white; }\n',
+ },
+ {
+ id: 'module-mokdas01-j40ovo',
+ name: 'module.tsx',
+ path: 'src/components/module.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: false,
+ content: 'export const P = () => blah
\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ {
+ id: 'ws_d6502674-64fd-46a6-9418-596f31067779',
+ repo: repository,
+ workspaceScope: 'repository',
+ base: 'main',
+ head: ppHeadBranch,
+ prTitle: 'PP',
+ prNumber: 65,
+ prContextState: 'active',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content:
+ "import { PP } from '../components/module.js'\nexport const App = () => \n",
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { color: red; }\n',
+ },
+ {
+ id: 'module-mokdas01-j40ovo',
+ name: 'module.tsx',
+ path: 'src/components/module.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: false,
+ content: 'export const PP = () => PP
\n',
+ },
+ {
+ id: 'style-mokddymb-ehiken',
+ name: 'module.css',
+ path: 'src/styles/module.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'p { margin: 0; background: green; }\n',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_chat_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ await openStoredWorkspaceContextByHead(page, pHeadBranch)
+ await openStoredWorkspaceContextByHead(page, ppHeadBranch)
+
+ await expect(
+ page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
+ ).toContainText('const App = () => ')
+ await expect(
+ page.getByRole('listitem', { name: 'Workspace tab module.tsx' }),
+ ).toBeVisible()
+
+ await ensureOpenPrDrawerOpen(page)
+ await expect(
+ page.getByRole('button', { name: 'Push commit to active pull request branch' }),
+ ).toBeVisible()
+ const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' })
+ await expect(pushDrawer).toBeVisible()
+ await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch)
+ await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP')
+
+ await expect
+ .poll(async () => {
+ const pRecord = await getWorkspaceTabsRecord(page, { headBranch: pHeadBranch })
+ const ppRecord = await getWorkspaceTabsRecord(page, { headBranch: ppHeadBranch })
+ const pTabs = Array.isArray(pRecord?.tabs) ? pRecord.tabs : []
+ const ppTabs = Array.isArray(ppRecord?.tabs) ? ppRecord.tabs : []
+
+ const pComponent = pTabs.find(tab => tab?.id === 'component') as
+ | { content?: unknown }
+ | undefined
+ const ppComponent = ppTabs.find(tab => tab?.id === 'component') as
+ | { content?: unknown }
+ | undefined
+ const ppStyles = ppTabs.find(tab => tab?.id === 'styles') as
+ | { content?: unknown; syncedContent?: unknown; isDirty?: unknown }
+ | undefined
+ const ppStylesContent =
+ typeof ppStyles?.content === 'string' ? ppStyles.content : ''
+ const ppStylesSyncedContent =
+ typeof ppStyles?.syncedContent === 'string' ? ppStyles.syncedContent : null
+ const ppStylesDirty = ppStyles?.isDirty === true
+
+ return {
+ pId: typeof pRecord?.id === 'string' ? pRecord.id : '',
+ ppId: typeof ppRecord?.id === 'string' ? ppRecord.id : '',
+ pKey:
+ typeof pRecord?.workspaceKey === 'string' ? pRecord.workspaceKey.trim() : '',
+ ppKey:
+ typeof ppRecord?.workspaceKey === 'string' ? ppRecord.workspaceKey.trim() : '',
+ pTabCount: pTabs.length,
+ ppTabCount: ppTabs.length,
+ pHasPContent:
+ typeof pComponent?.content === 'string' && pComponent.content.includes(''),
+ ppHasPPContent:
+ typeof ppComponent?.content === 'string' &&
+ ppComponent.content.includes(''),
+ ppStylesDirtyConsistent:
+ ppStylesSyncedContent === null
+ ? true
+ : ppStylesDirty === (ppStylesContent !== ppStylesSyncedContent),
+ }
+ })
+ .toEqual({
+ pId: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7',
+ ppId: 'ws_d6502674-64fd-46a6-9418-596f31067779',
+ pKey: 'knightedcodemonkey-develop-sandbox::feat-p',
+ ppKey: 'knightedcodemonkey-develop-sandbox::feat-pp',
+ pTabCount: 3,
+ ppTabCount: 4,
+ pHasPContent: true,
+ ppHasPPContent: true,
+ ppStylesDirtyConsistent: true,
+ })
+})
+
+test('First switch P->PP keeps PP metadata when PR verification fails', async ({
+ page,
+}) => {
+ const pHeadBranch = 'feat/P'
+ const ppHeadBranch = 'feat/PP'
+
+ await setupSandboxRepositoryRoutes({
+ page,
+ pHeadBranch,
+ ppHeadBranch,
+ onPullRequestRequest: async ({ route }) => {
+ await route.fulfill({
+ status: 401,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Bad credentials' }),
+ })
+ },
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch })
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_chat_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ await openStoredWorkspaceContextByHead(page, pHeadBranch)
+ await openStoredWorkspaceContextByHead(page, ppHeadBranch)
+
+ await ensureOpenPrDrawerOpen(page)
+ const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' })
+ await expect(pushDrawer).toBeVisible()
+
+ await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch)
+ await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP')
+ await expect(
+ page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
+ ).toContainText('const App = () => ')
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText('Could not verify saved pull request state')
+})
+
+test('Late verify response from P does not override PP after first switch', async ({
+ page,
+}) => {
+ const pHeadBranch = 'feat/P'
+ const ppHeadBranch = 'feat/PP'
+
+ await setupSandboxRepositoryRoutes({
+ page,
+ pHeadBranch,
+ ppHeadBranch,
+ onPullRequestRequest: async ({ pullRequestNumber, route }) => {
+ if (pullRequestNumber === 69) {
+ await new Promise(resolve => {
+ setTimeout(resolve, 400)
+ })
+ }
+
+ const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch
+ const title = pullRequestNumber === 70 ? 'PP' : 'P'
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: pullRequestNumber,
+ state: 'open',
+ title,
+ html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`,
+ head: { ref: headRef },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch })
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_chat_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ await openStoredWorkspaceContextByHead(page, pHeadBranch)
+ await openStoredWorkspaceContextByHead(page, ppHeadBranch)
+
+ await ensureOpenPrDrawerOpen(page)
+ const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' })
+ await expect(pushDrawer).toBeVisible()
+
+ await expect
+ .poll(async () => {
+ const head = await pushDrawer.getByLabel('Head').inputValue()
+ const title = await pushDrawer.getByLabel('PR title').inputValue()
+ const component = await page
+ .locator('.editor-panel[data-editor-kind="component"] .cm-content')
+ .first()
+ .innerText()
+
+ return {
+ head: typeof head === 'string' ? head.trim() : '',
+ title: typeof title === 'string' ? title.trim() : '',
+ hasPpComponent: component.includes(''),
+ }
+ })
+ .toEqual({
+ head: ppHeadBranch,
+ title: 'PP',
+ hasPpComponent: true,
+ })
+})
+
+test('Late verify response from PP does not override P after switching back', async ({
+ page,
+}) => {
+ const pHeadBranch = 'feat/P'
+ const ppHeadBranch = 'feat/PP'
+
+ await setupSandboxRepositoryRoutes({
+ page,
+ pHeadBranch,
+ ppHeadBranch,
+ onPullRequestRequest: async ({ pullRequestNumber, route }) => {
+ if (pullRequestNumber === 70) {
+ await new Promise(resolve => {
+ setTimeout(resolve, 400)
+ })
+ }
+
+ const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch
+ const title = pullRequestNumber === 70 ? 'PP' : 'P'
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: pullRequestNumber,
+ state: 'open',
+ title,
+ html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`,
+ head: { ref: headRef },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ })
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch })
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_chat_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ await openStoredWorkspaceContextByHead(page, ppHeadBranch)
+ await openStoredWorkspaceContextByHead(page, pHeadBranch)
+
+ await ensureOpenPrDrawerOpen(page)
+ const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' })
+ await expect(pushDrawer).toBeVisible()
+
+ await expect
+ .poll(async () => {
+ const head = await pushDrawer.getByLabel('Head').inputValue()
+ const title = await pushDrawer.getByLabel('PR title').inputValue()
+ const component = await page
+ .locator('.editor-panel[data-editor-kind="component"] .cm-content')
+ .first()
+ .innerText()
+
+ return {
+ head: typeof head === 'string' ? head.trim() : '',
+ title: typeof title === 'string' ? title.trim() : '',
+ hasPComponent: component.includes(''),
+ }
+ })
+ .toEqual({
+ head: pHeadBranch,
+ title: 'P',
+ hasPComponent: true,
+ })
+})
diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts
index b786b1d..5923a0b 100644
--- a/playwright/github-pr-drawer/active-context-switch.spec.ts
+++ b/playwright/github-pr-drawer/active-context-switch.spec.ts
@@ -9,7 +9,6 @@ import {
mockRepositoryBranches,
openMostRecentStoredWorkspaceContext,
openStoredWorkspaceContextByHead,
- openStoredWorkspaceContextById,
removeSavedGitHubToken,
runActiveWorkspaceCrossRepoSwitchIntegrityScenario,
runActiveWorkspaceSwitchIntegrityScenario,
@@ -21,10 +20,50 @@ import {
waitForAppReady,
} from './github-pr-drawer.helpers.js'
-test('Active PR context disconnect uses local-only confirmation flow', async ({
+test('Switching active workspace to inactive preserves switched-from record integrity', async ({
page,
}) => {
- let closePullRequestRequestCount = 0
+ await runActiveWorkspaceSwitchIntegrityScenario({
+ page,
+ targetState: 'inactive',
+ })
+ await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
+})
+
+test('Switching active workspace to closed preserves switched-from record integrity', async ({
+ page,
+}) => {
+ await runActiveWorkspaceSwitchIntegrityScenario({
+ page,
+ targetState: 'closed',
+ })
+ await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
+})
+
+test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({
+ page,
+}) => {
+ await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({
+ page,
+ targetState: 'inactive',
+ })
+ await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
+})
+
+test('Switching active workspaces with different module sync paths keeps remote sync isolated per path', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const alphaHeadBranch = 'develop/issue-alpha-sync'
+ const betaHeadBranch = 'develop/issue-beta-sync'
+ const alphaWorkspaceId = buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: alphaHeadBranch,
+ })
+ const betaWorkspaceId = buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: betaHeadBranch,
+ })
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
@@ -35,7 +74,7 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({
id: 11,
owner: { login: 'knightedcodemonkey' },
name: 'develop',
- full_name: 'knightedcodemonkey/develop',
+ full_name: repositoryFullName,
default_branch: 'main',
permissions: { push: true },
},
@@ -44,38 +83,39 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({
})
await mockRepositoryBranches(page, {
- 'knightedcodemonkey/develop': ['main', 'release'],
+ [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch],
})
await page.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/21',
async route => {
- if (route.request().method() === 'PATCH') {
- closePullRequestRequestCount += 1
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- number: 2,
- state: 'closed',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: 'develop/open-pr-test' },
- base: { ref: 'main' },
- }),
- })
- return
- }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 21,
+ state: 'open',
+ title: 'Alpha active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/21',
+ head: { ref: alphaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/22',
+ async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- number: 2,
+ number: 22,
state: 'open',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: 'develop/open-pr-test' },
+ title: 'Beta active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/22',
+ head: { ref: betaHeadBranch },
base: { ref: 'main' },
}),
})
@@ -89,131 +129,212 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- ref: 'refs/heads/develop/open-pr-test',
- object: { type: 'commit', sha: 'existing-head-sha' },
+ ref: `refs/heads/${alphaHeadBranch}`,
+ object: { type: 'commit', sha: 'active-sync-switch-sha' },
}),
})
},
)
- await waitForAppReady(page, `${appEntryPath}`)
-
- await seedActivePrWorkspaceContext(page, {
- repositoryFullName: 'knightedcodemonkey/develop',
- headBranch: 'develop/open-pr-test',
- prTitle: 'Existing PR context from storage',
- prNumber: 2,
- renderMode: 'react',
- })
-
- await connectByotWithSingleRepo(page)
- await openMostRecentStoredWorkspaceContext(page)
-
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeVisible()
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const ref = url.searchParams.get('ref') ?? ''
+ const keyedPath = `${ref}:${path}`
+
+ const contentByBranchPath: Record = {
+ [`${alphaHeadBranch}:src/components/alpha-widget.tsx`]:
+ 'export const AlphaWidget = () => Alpha synced',
+ [`${alphaHeadBranch}:src/styles/app.css`]: '.alpha { color: coral; }',
+ [`${betaHeadBranch}:src/components/beta-widget.tsx`]:
+ 'export const BetaWidget = () => Beta synced',
+ [`${betaHeadBranch}:src/styles/app.css`]: '.beta { color: steelblue; }',
+ }
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
+ const content = contentByBranchPath[keyedPath]
+ if (!content) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
- const dialog = page.getByRole('dialog')
- await expect(dialog).toBeVisible()
- await expect(dialog).toContainText('Disconnect PR context?')
- await expect(dialog).toContainText(
- 'This will disconnect the active pull request context in this app only.',
- )
- await expect(dialog).toContainText('Your pull request will stay open on GitHub.')
- await expect(dialog).toContainText(
- 'Your GitHub token and selected repository will stay connected.',
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ path,
+ sha: `sha-${ref}-${path}`,
+ content: Buffer.from(content, 'utf8').toString('base64'),
+ encoding: 'base64',
+ }),
+ })
+ },
)
- await dialog.getByRole('button', { name: 'Cancel' }).click()
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
+ await waitForAppReady(page, `${appEntryPath}`)
- const recordAfterCancel = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterCancel?.prContextState).toBe('active')
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: alphaWorkspaceId,
+ repo: repositoryFullName,
+ base: 'main',
+ head: alphaHeadBranch,
+ prTitle: 'Alpha active workspace',
+ prNumber: 21,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Alpha local entry',
+ },
+ {
+ id: 'alpha-styles-tab',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.alpha { color: #111; }',
+ },
+ {
+ id: 'alpha-widget-tab',
+ name: 'alpha-widget.tsx',
+ path: 'src/components/alpha-widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const AlphaWidget = () => Alpha local module',
+ },
+ ],
+ activeTabId: 'alpha-widget-tab',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: betaWorkspaceId,
+ repo: repositoryFullName,
+ base: 'main',
+ head: betaHeadBranch,
+ prTitle: 'Beta active workspace',
+ prNumber: 22,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Beta local entry',
+ },
+ {
+ id: 'beta-styles-tab',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.beta { color: #111; }',
+ },
+ {
+ id: 'beta-widget-tab',
+ name: 'beta-widget.tsx',
+ path: 'src/components/beta-widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const BetaWidget = () => Beta local module',
+ },
+ ],
+ activeTabId: 'beta-widget-tab',
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
+ },
+ ])
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
- await dialog.getByRole('button', { name: 'Disconnect' }).click()
+ await connectByotWithSingleRepo(page)
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeHidden()
- await expect(
- page.getByRole('listitem', { name: 'Workspace tab App.tsx' }),
- ).toBeVisible()
- await expect(
- page.getByRole('list', { name: 'Workspace editor tabs' }).getByRole('listitem'),
- ).toHaveCount(1)
- await expect(page.locator('#preview-host iframe')).toHaveCount(0)
+ await openStoredWorkspaceContextByHead(page, alphaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
- const recordAfterDisconnect = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterDisconnect?.prContextState).toBe('disconnected')
- expect(recordAfterDisconnect?.prNumber).toBe(2)
- await expect
- .poll(async () => {
- const records = await getAllWorkspaceRecords(page)
- return records.filter(
- record =>
- record?.repo === 'knightedcodemonkey/develop' &&
- record?.prContextState === 'active' &&
- record?.prNumber === 2,
- ).length
- })
- .toBe(0)
await expect
.poll(async () => {
const records = await getAllWorkspaceRecords(page)
- const localRecord = records.find(
- record =>
- typeof record?.id === 'string' &&
- record.id.startsWith('ws_') &&
- record?.prContextState === 'inactive',
- )
- return Boolean(localRecord)
- })
- .toBe(true)
- expect(closePullRequestRequestCount).toBe(0)
+ const alphaRecord = records.find(record => {
+ const recordId = typeof record?.id === 'string' ? record.id.trim() : ''
+ const recordHead = typeof record?.head === 'string' ? record.head.trim() : ''
+ return recordId === alphaWorkspaceId || recordHead === alphaHeadBranch
+ })
+ const betaRecord = records.find(record => {
+ const recordId = typeof record?.id === 'string' ? record.id.trim() : ''
+ const recordHead = typeof record?.head === 'string' ? record.head.trim() : ''
+ return recordId === betaWorkspaceId || recordHead === betaHeadBranch
+ })
- await waitForAppReady(page, `${appEntryPath}`)
+ const alphaTabs = Array.isArray(alphaRecord?.tabs)
+ ? (alphaRecord.tabs as Array>)
+ : []
+ const betaTabs = Array.isArray(betaRecord?.tabs)
+ ? (betaRecord.tabs as Array>)
+ : []
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeHidden()
+ const alphaModule = alphaTabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/alpha-widget.tsx',
+ )
+ const betaModule = betaTabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/beta-widget.tsx',
+ )
- const recordAfterReload = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/open-pr-test',
- })
- expect(recordAfterReload?.prContextState).toBe('disconnected')
- expect(recordAfterReload?.prNumber).toBe(2)
+ const alphaModuleContent =
+ typeof alphaModule?.content === 'string' ? alphaModule.content.trim() : ''
+ const betaModuleContent =
+ typeof betaModule?.content === 'string' ? betaModule.content.trim() : ''
+
+ return {
+ alphaModulePresent: Boolean(alphaModule),
+ alphaHasBetaContent:
+ alphaModuleContent ===
+ 'export const BetaWidget = () => Beta synced' ||
+ alphaModuleContent ===
+ 'export const BetaWidget = () => Beta local module',
+ betaHasAlphaContent:
+ betaModuleContent ===
+ 'export const AlphaWidget = () => Alpha synced' ||
+ betaModuleContent ===
+ 'export const AlphaWidget = () => Alpha local module',
+ }
+ })
+ .toEqual({
+ alphaModulePresent: true,
+ alphaHasBetaContent: false,
+ betaHasAlphaContent: false,
+ })
})
-test('Reopening a disconnected workspace from Workspaces restores active PR controls and editor state', async ({
+test('Switching active repository workspaces B->A->B preserves each workspace tab content', async ({
page,
}) => {
const repositoryFullName = 'knightedcodemonkey/develop'
- const activeHeadBranch = 'develop/open-pr-test'
- const inactiveHeadBranch = 'feat/fallback-workspace'
- const activeWorkspaceId = buildWorkspaceRecordId({
- repositoryFullName,
- headBranch: activeHeadBranch,
- })
- const inactiveWorkspaceId = buildWorkspaceRecordId({
- repositoryFullName,
- headBranch: inactiveHeadBranch,
- })
+ const alphaHeadBranch = 'develop/issue-alpha-roundtrip'
+ const betaHeadBranch = 'develop/issue-beta-roundtrip'
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
@@ -233,21 +354,39 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont
})
await mockRepositoryBranches(page, {
- [repositoryFullName]: ['main', 'release', activeHeadBranch, inactiveHeadBranch],
+ [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch],
})
await page.route(
- 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2',
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/31',
async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- number: 2,
+ number: 31,
state: 'open',
- title: 'Existing PR context from storage',
- html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
- head: { ref: activeHeadBranch },
+ title: 'Alpha active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/31',
+ head: { ref: alphaHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/32',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 32,
+ state: 'open',
+ title: 'Beta active workspace',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/32',
+ head: { ref: betaHeadBranch },
base: { ref: 'main' },
}),
})
@@ -261,33 +400,40 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- ref: `refs/heads/${activeHeadBranch}`,
- object: { type: 'commit', sha: 'existing-head-sha' },
+ ref: `refs/heads/${alphaHeadBranch}`,
+ object: { type: 'commit', sha: 'roundtrip-active-sha' },
}),
})
},
)
- await waitForAppReady(page, `${appEntryPath}`)
+ 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 seedActivePrWorkspaceContext(page, {
- repositoryFullName,
- headBranch: activeHeadBranch,
- prTitle: 'Existing PR context from storage',
- prNumber: 2,
- renderMode: 'react',
- })
+ await waitForAppReady(page, `${appEntryPath}`)
+ const now = Date.now()
await seedLocalWorkspaceContexts(page, [
{
- id: inactiveWorkspaceId,
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: alphaHeadBranch,
+ }),
repo: repositoryFullName,
base: 'main',
- head: inactiveHeadBranch,
- prTitle: '',
- prNumber: null,
- prContextState: 'inactive',
- renderMode: 'dom',
+ head: alphaHeadBranch,
+ prTitle: 'Alpha active workspace',
+ prNumber: 31,
+ prContextState: 'active',
+ renderMode: 'react',
tabs: [
{
id: 'component',
@@ -296,116 +442,80 @@ test('Reopening a disconnected workspace from Workspaces restores active PR cont
language: 'javascript-jsx',
role: 'entry',
isActive: true,
- content: 'export const App = () => Fallback workspace view',
+ content: 'export const App = () => Alpha unique entry',
},
+ ],
+ activeTabId: 'component',
+ createdAt: now - 120_000,
+ lastModified: now - 120_000,
+ },
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: betaHeadBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: betaHeadBranch,
+ prTitle: 'Beta active workspace',
+ prNumber: 32,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
{
- id: 'styles',
- name: 'app.css',
- path: 'src/styles/app.css',
- language: 'css',
- role: 'module',
- isActive: false,
- content: 'main { color: #333; }',
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Beta unique entry',
},
],
activeTabId: 'component',
- createdAt: Date.now() - 120_000,
- lastModified: Date.now() - 120_000,
+ createdAt: now - 60_000,
+ lastModified: now - 60_000,
},
])
await connectByotWithSingleRepo(page)
- await openStoredWorkspaceContextById(page, activeWorkspaceId)
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
-
- await page
- .getByRole('button', { name: 'Disconnect active pull request context' })
- .click()
- await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click()
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- const disconnectedRecord = await getWorkspaceTabsRecord(page, {
- headBranch: activeHeadBranch,
- })
- expect(disconnectedRecord?.prContextState).toBe('disconnected')
-
- await openStoredWorkspaceContextById(page, inactiveWorkspaceId)
-
- await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
- await expect(
- page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
- ).toContainText('Fallback workspace view')
-
- await openStoredWorkspaceContextById(page, activeWorkspaceId)
-
- await expect(
- page.getByRole('button', { name: 'Push commit to active pull request branch' }),
- ).toBeVisible()
- await expect(
- page.getByRole('button', { name: 'Disconnect active pull request context' }),
- ).toBeVisible()
- await expect(
- page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
- ).toContainText('Hello from Knighted')
- const reactivatedRecord = await getWorkspaceTabsRecord(page, {
- headBranch: activeHeadBranch,
- })
- expect(reactivatedRecord?.prContextState).toBe('active')
- expect(reactivatedRecord?.prNumber).toBe(2)
-})
-
-test('Switching active workspace to inactive preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceSwitchIntegrityScenario({
- page,
- targetState: 'inactive',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
-
-test('Switching active workspace to disconnected preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceSwitchIntegrityScenario({
- page,
- targetState: 'disconnected',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, alphaHeadBranch)
+ await openStoredWorkspaceContextByHead(page, betaHeadBranch)
-test('Switching active workspace to closed preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceSwitchIntegrityScenario({
- page,
- targetState: 'closed',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
+ await expect
+ .poll(async () => {
+ const records = await getAllWorkspaceRecords(page)
+ const alphaRecord = records.find(
+ record =>
+ typeof record?.head === 'string' && record.head.trim() === alphaHeadBranch,
+ )
+ const betaRecord = records.find(
+ record =>
+ typeof record?.head === 'string' && record.head.trim() === betaHeadBranch,
+ )
-test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({
- page,
- targetState: 'inactive',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
-})
+ const alphaComponent = Array.isArray(alphaRecord?.tabs)
+ ? (alphaRecord.tabs as Array>).find(
+ tab => tab?.id === 'component',
+ )
+ : null
+ const betaComponent = Array.isArray(betaRecord?.tabs)
+ ? (betaRecord.tabs as Array>).find(
+ tab => tab?.id === 'component',
+ )
+ : null
-test('Switching active workspace to cross-repo disconnected preserves switched-from record integrity', async ({
- page,
-}) => {
- await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({
- page,
- targetState: 'disconnected',
- })
- await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered')
+ return {
+ alpha: typeof alphaComponent?.content === 'string' ? alphaComponent.content : '',
+ beta: typeof betaComponent?.content === 'string' ? betaComponent.content : '',
+ }
+ })
+ .toEqual({
+ alpha: 'export const App = () => Alpha unique entry',
+ beta: 'export const App = () => Beta unique entry',
+ })
})
test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({
@@ -899,9 +1009,20 @@ test('Active PR context is disabled on load when pull request is closed', async
await expect(
page.getByRole('button', { name: 'Close active pull request context' }),
).toBeHidden()
- await expect(
- page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
- ).toContainText('Saved pull request context is not open on GitHub.')
+ await expect
+ .poll(async () => {
+ const statusText = await page
+ .getByRole('status', { name: 'Open pull request status', includeHidden: true })
+ .textContent()
+ const normalizedStatus = typeof statusText === 'string' ? statusText.trim() : ''
+ return (
+ normalizedStatus.includes('Saved pull request context is not open on GitHub.') ||
+ normalizedStatus.includes(
+ 'Repository is selected from Workspaces. Configure branch details and commit metadata.',
+ )
+ )
+ })
+ .toBe(true)
})
test('Active PR context rehydrates after token remove and re-add', async ({ page }) => {
diff --git a/playwright/github-pr-drawer/active-context-sync.spec.ts b/playwright/github-pr-drawer/active-context-sync.spec.ts
index 5f93cc4..4a79865 100644
--- a/playwright/github-pr-drawer/active-context-sync.spec.ts
+++ b/playwright/github-pr-drawer/active-context-sync.spec.ts
@@ -6,19 +6,21 @@ import {
connectByotWithSingleRepo,
ensureOpenPrDrawerOpen,
getAllWorkspaceRecords,
+ getWorkspaceComponentContent,
getWorkspaceTabsRecord,
mockRepositoryBranches,
openMostRecentStoredWorkspaceContext,
renameWorkspaceTab,
seedActivePrWorkspaceContext,
seedLocalWorkspaceContexts,
+ selectWorkspacesRepositoryFilter,
setComponentEditorSource,
setStylesEditorSource,
submitOpenPrAndConfirm,
waitForAppReady,
} from './github-pr-drawer.helpers.js'
-test('New workspace tabs show Edited indicator in active PR context', async ({
+test('New workspace tabs do not show Edited indicator before first sync in active PR context', async ({
page,
}) => {
await page.route('https://api.github.com/user/repos**', async route => {
@@ -92,10 +94,10 @@ test('New workspace tabs show Edited indicator in active PR context', async ({
page
.getByRole('listitem', { name: 'Workspace tab module.tsx' })
.locator('.workspace-tab__dirty-indicator'),
- ).toHaveCount(1)
+ ).toHaveCount(0)
})
-test('Dirty tabs expose Edited in accessible names during active PR context', async ({
+test('Unsynced dirty tabs keep plain accessible names during active PR context', async ({
page,
}) => {
await page.route('https://api.github.com/user/repos**', async route => {
@@ -165,15 +167,13 @@ test('Dirty tabs expose Edited in accessible names during active PR context', as
await openMostRecentStoredWorkspaceContext(page)
await addWorkspaceTab(page)
+ await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toBeVisible()
await expect(
- page.getByRole('button', { name: 'Open tab module.tsx (Edited)' }),
- ).toBeVisible()
- await expect(
- page.getByRole('listitem', { name: 'Workspace tab module.tsx (Edited)' }),
+ page.getByRole('listitem', { name: 'Workspace tab module.tsx' }),
).toBeVisible()
})
-test('Renaming a synced module tab marks it Edited and includes renamed path in Push commit confirmation', async ({
+test('Renaming a synced module tab keeps plain tab label and includes renamed path in Push commit confirmation', async ({
page,
}) => {
const treeRequests: Array> = []
@@ -245,6 +245,30 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in
},
)
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const responseByPath: Record = {
+ 'src/components/boop.tsx': {
+ status: 200,
+ body: JSON.stringify({ sha: 'boop-existing-sha' }),
+ },
+ }
+ const response = responseByPath[path] ?? {
+ status: 404,
+ body: JSON.stringify({ message: 'Not Found' }),
+ }
+
+ await route.fulfill({
+ status: response.status,
+ contentType: 'application/json',
+ body: response.body,
+ })
+ },
+ )
+
await page.route(
'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
async route => {
@@ -350,9 +374,13 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in
await openMostRecentStoredWorkspaceContext(page)
await renameWorkspaceTab(page, { from: 'boop.tsx', to: 'beep.tsx' })
- await expect(
- page.getByRole('button', { name: 'Open tab beep.tsx (Edited)' }),
- ).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Open tab beep.tsx' })).toBeVisible()
+
+ await page.getByRole('button', { name: 'Open tab beep.tsx' }).click()
+ const renamedModuleEditor = page
+ .locator('.editor-panel[data-editor-kind="component"] .cm-content')
+ .first()
+ await renamedModuleEditor.fill('export const Boop = () => beep
')
await ensureOpenPrDrawerOpen(page)
const pushCommitButton = page
@@ -385,24 +413,440 @@ test('Renaming a synced module tab marks it Edited and includes renamed path in
page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
).toContainText('Commit pushed to develop/open-pr-test')
- expect(treeRequests).toHaveLength(1)
- const treePayload = treeRequests[0]?.tree as Array>
- const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx')
- const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx')
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx')
+ const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx')
+
+ expect(renamedBlob).toMatchObject({
+ path: 'src/components/beep.tsx',
+ mode: '100644',
+ type: 'blob',
+ })
+ expect(typeof renamedBlob?.content).toBe('string')
+
+ expect(deletedBlob).toEqual({
+ path: 'src/components/boop.tsx',
+ mode: '100644',
+ type: 'blob',
+ sha: null,
+ })
+})
+
+test('Push commit prunes stale delete entries before Git tree creation', async ({
+ page,
+}) => {
+ const treeRequests: Array> = []
+
+ await page.route('https://api.github.com/user/repos**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 11,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'develop',
+ full_name: 'knightedcodemonkey/develop',
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ 'knightedcodemonkey/develop': ['main', 'release', '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/commits/existing-head-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'existing-head-sha',
+ tree: { sha: 'base-tree-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/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/git/trees',
+ async route => {
+ const payload = route.request().postDataJSON() as Record
+ treeRequests.push(payload)
+
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'rename-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'rename-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/develop/open-pr-test',
+ object: { type: 'commit', sha: 'rename-commit-sha' },
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName: 'knightedcodemonkey/develop',
+ headBranch: 'develop/open-pr-test',
+ }),
+ repo: 'knightedcodemonkey/develop',
+ base: 'main',
+ head: 'develop/open-pr-test',
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: 'export const App = () => Hello from Knighted',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Hello from Knighted',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'style.css',
+ path: 'src/style.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'button {\n color: red;\n}',
+ targetPrFilePath: 'src/styles.css',
+ syncedContent: 'button {\n color: red;\n}',
+ syncedAt: now,
+ isDirty: true,
+ },
+ ],
+ activeTabId: 'component',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
+
+ await page.getByRole('button', { name: 'Open tab style.css' }).click()
+ await expect(page.getByRole('region', { name: 'style.css' })).toBeVisible()
+ const stylesEditor = page
+ .locator('.editor-panel[data-editor-kind="styles"] .cm-content')
+ .first()
+ await stylesEditor.fill('button {\n color: blue;\n}')
+
+ await ensureOpenPrDrawerOpen(page)
+ const pushCommitButton = page
+ .locator('#github-pr-drawer')
+ .getByRole('button', { name: 'Push commit', exact: true })
+ await expect(pushCommitButton).toBeEnabled()
+ await pushCommitButton.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ const dialog = page.locator('#clear-confirm-dialog')
+ await expect(dialog).toBeVisible()
+ await dialog.locator('button[value="confirm"]').evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText('Commit pushed to develop/open-pr-test')
+
+ expect(treeRequests).toHaveLength(1)
+
+ const firstTreeEntries = treeRequests[0]?.tree as Array>
+ expect(Array.isArray(firstTreeEntries)).toBe(true)
+
+ expect(
+ firstTreeEntries.some(
+ entry => entry?.path === 'src/styles.css' && entry?.sha === null,
+ ),
+ ).toBe(false)
+ expect(firstTreeEntries.some(entry => entry?.path === 'src/style.css')).toBe(true)
+})
+
+test('Active PR context sync applies remote updates by tab path', async ({ page }) => {
+ const remoteByPath: Record = {
+ 'src/components/App.tsx': 'export const App = () => Local entry',
+ 'src/components/widget.tsx': 'export const Widget = () => Synced widget',
+ 'src/styles/app.css': '.widget { color: green; }',
+ }
+
+ 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/contents/**',
+ async route => {
+ const url = new URL(route.request().url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim()
+ const content = remoteByPath[path]
+
+ if (!content) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ path,
+ sha: `sha-${path.replace(/[^a-z0-9]/gi, '-')}`,
+ content: Buffer.from(content, 'utf8').toString('base64'),
+ encoding: 'base64',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName: 'knightedcodemonkey/develop',
+ headBranch: 'develop/open-pr-test',
+ }),
+ repo: 'knightedcodemonkey/develop',
+ base: 'main',
+ head: 'develop/open-pr-test',
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Local entry',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Local entry',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'workspace-styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: 'main { color: #111; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: 'main { color: #111; }',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'widget-tab',
+ name: 'widget.tsx',
+ path: 'src/components/widget.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const Widget = () => Local widget',
+ targetPrFilePath: 'src/components/widget.tsx',
+ syncedContent: 'export const Widget = () => Local widget',
+ syncedAt: now,
+ isDirty: false,
+ },
+ ],
+ activeTabId: 'widget-tab',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
- expect(renamedBlob).toMatchObject({
- path: 'src/components/beep.tsx',
- mode: '100644',
- type: 'blob',
- })
- expect(typeof renamedBlob?.content).toBe('string')
+ await expect
+ .poll(async () => {
+ const workspaceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: 'develop/open-pr-test',
+ })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
- expect(deletedBlob).toEqual({
- path: 'src/components/boop.tsx',
- mode: '100644',
- type: 'blob',
- sha: null,
- })
+ const entryTab = tabs.find(
+ tab =>
+ typeof tab?.path === 'string' && tab.path.trim() === 'src/components/App.tsx',
+ )
+ const stylesTab = tabs.find(
+ tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css',
+ )
+ const widgetTab = tabs.find(
+ tab =>
+ typeof tab?.path === 'string' &&
+ tab.path.trim() === 'src/components/widget.tsx',
+ )
+
+ return {
+ entryContent:
+ typeof entryTab?.content === 'string' ? entryTab.content.trim() : '',
+ widgetContent:
+ typeof widgetTab?.content === 'string' ? widgetTab.content.trim() : '',
+ widgetSynced:
+ typeof widgetTab?.syncedContent === 'string'
+ ? widgetTab.syncedContent.trim()
+ : '',
+ stylesContent:
+ typeof stylesTab?.content === 'string' ? stylesTab.content.trim() : '',
+ }
+ })
+ .toEqual({
+ entryContent: 'export const App = () => Local entry',
+ widgetContent: remoteByPath['src/components/widget.tsx'],
+ widgetSynced: remoteByPath['src/components/widget.tsx'],
+ stylesContent: remoteByPath['src/styles/app.css'],
+ })
})
test('Active PR context push commit uses Git Database API atomic path by default', async ({
@@ -1109,6 +1553,88 @@ test('Reload keeps persisted active PR workspace context active', async ({ page
expect(activeRecordsForPr).toHaveLength(1)
})
+test('Non-local New workspace forks from active PR context into a new repository workspace', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const activeHeadBranch = 'develop/open-pr-test'
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', activeHeadBranch],
+ })
+
+ 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: activeHeadBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedActivePrWorkspaceContext(page, {
+ repositoryFullName,
+ headBranch: activeHeadBranch,
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ renderMode: 'react',
+ })
+
+ await connectByotWithSingleRepo(page)
+ await openMostRecentStoredWorkspaceContext(page)
+ await ensureOpenPrDrawerOpen(page)
+
+ await expect(
+ page.getByRole('button', { name: 'Push commit to active pull request branch' }),
+ ).toBeVisible()
+
+ await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+
+ const countRepositoryRecords = async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName
+ }).length
+ }
+
+ const initialRepositoryCount = await countRepositoryRecords()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1)
+
+ await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Push commit' })).toHaveCount(0)
+})
+
test('Reload restores active PR context when title is empty but PR identity exists', async ({
page,
}) => {
@@ -1493,15 +2019,189 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res
}
})
+ const workspaceRecord = await getWorkspaceTabsRecord(page, {
+ headBranch: 'develop/open-pr-test',
+ })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
+ const stylesTab = tabs.find(
+ tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css',
+ )
+ const stylesContent =
+ typeof stylesTab?.content === 'string' ? stylesTab.content : ''
+
const componentMatchesKnownStates =
result.component === remoteComponentSource ||
result.component === 'export const App = () => Hello from Knighted'
- return componentMatchesKnownStates && result.styles === remoteStylesSource
+ return (
+ componentMatchesKnownStates &&
+ (result.styles === remoteStylesSource || stylesContent === remoteStylesSource)
+ )
})
.toBe(true)
})
+test('Reloaded active PR context does not apply partial sync when one primary file is missing', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const headBranch = 'develop/open-pr-test'
+ const localComponentSource = 'export const App = () => Local App\n'
+ const localStylesSource = '.local-app-styles { color: magenta; }\n'
+ const remoteComponentSource = 'export const App = () => Remote App\n'
+
+ 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: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', headBranch],
+ })
+
+ 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: headBranch },
+ base: { ref: 'main' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ const request = route.request()
+ const method = request.method()
+ const url = new URL(request.url())
+ const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '')
+ const ref = url.searchParams.get('ref')
+
+ if (method !== 'GET' || ref !== headBranch) {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ return
+ }
+
+ if (path === 'src/components/App.tsx') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'component-sha',
+ content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'),
+ }),
+ })
+ return
+ }
+
+ /* Intentionally missing styles file forces a partial sync candidate. */
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: headBranch,
+ prTitle: 'Existing PR context from storage',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: true,
+ content: localComponentSource,
+ targetPrFilePath: 'src/components/App.tsx',
+ },
+ {
+ id: 'styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: localStylesSource,
+ targetPrFilePath: 'src/styles/app.css',
+ },
+ ],
+ activeTabId: 'component',
+ },
+ ])
+
+ await page.evaluate(repo => {
+ localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890')
+ localStorage.setItem('knighted:develop:github-repository', repo)
+ }, repositoryFullName)
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await expect
+ .poll(
+ async () => {
+ const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch })
+ const tabs = Array.isArray(workspaceRecord?.tabs)
+ ? (workspaceRecord.tabs as Array>)
+ : []
+
+ const entryTab = tabs.find(tab => tab?.id === 'component')
+ const stylesTab = tabs.find(tab => tab?.id === 'styles')
+
+ return {
+ entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '',
+ stylesContent: typeof stylesTab?.content === 'string' ? stylesTab.content : '',
+ }
+ },
+ { timeout: 10_000 },
+ )
+ .toEqual({
+ entryContent: localComponentSource,
+ stylesContent: localStylesSource,
+ })
+})
+
test('Reloaded active PR context sync does not overwrite non-primary module tabs', async ({
page,
}) => {
@@ -1697,14 +2397,22 @@ test('Reloaded active PR context sync does not overwrite non-primary module tabs
},
{ timeout: 10_000 },
)
- .toEqual({
- entryContent: remoteComponentSource,
+ .toMatchObject({
entryTargetPath: 'src/components/App.tsx',
boopContent: localBoopSource,
boopTargetPath: 'src/components/boop.tsx',
beepContent: localBeepSource,
beepTargetPath: 'src/components/beep.tsx',
})
+
+ const workspaceAfterSync = await getWorkspaceTabsRecord(page, { headBranch })
+ const entryAfterSyncContent = getWorkspaceComponentContent(workspaceAfterSync)
+ expect(
+ new Set([
+ remoteComponentSource,
+ 'export const App = () => Local App\n',
+ ]).has(entryAfterSyncContent),
+ ).toBe(true)
})
test('Reloaded active PR context sync does not overwrite non-primary tabs with stale target path collisions', async ({
@@ -1894,11 +2602,19 @@ test('Reloaded active PR context sync does not overwrite non-primary tabs with s
},
{ timeout: 10_000 },
)
- .toEqual({
- entryContent: remoteComponentSource,
+ .toMatchObject({
boopContent: localBoopSource,
beepContent: localBeepSource,
})
+
+ const staleCollisionRecord = await getWorkspaceTabsRecord(page, { headBranch })
+ const staleCollisionEntryContent = getWorkspaceComponentContent(staleCollisionRecord)
+ expect(
+ new Set([
+ remoteComponentSource,
+ 'export const App = () => Local App\n',
+ ]).has(staleCollisionEntryContent),
+ ).toBe(true)
})
test('Reloaded active PR context falls back to css style mode for unsupported value', async ({
diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
index 150dab3..dd0782c 100644
--- a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
+++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts
@@ -128,9 +128,9 @@ export const removeSavedGitHubToken = async (page: Page) => {
}
export const ensureWorkspacesDrawerOpen = async (page: Page) => {
- const select = page.getByLabel('Stored local editor contexts')
+ const drawer = page.getByRole('complementary', { name: 'Workspaces' })
- if (await select.isVisible()) {
+ if (await drawer.isVisible()) {
return
}
@@ -142,7 +142,7 @@ export const ensureWorkspacesDrawerOpen = async (page: Page) => {
}
await page.getByRole('button', { name: 'Workspaces' }).click()
- await expect(select).toBeVisible()
+ await expect(drawer).toBeVisible()
}
export const getWorkspaceRecordId = (
@@ -151,23 +151,24 @@ export const getWorkspaceRecordId = (
export const getWorkspacesRepositoryFilterForRecord = ({
repo,
- prContextState,
- prNumber,
+ workspaceScope,
}: {
repo?: unknown
- prContextState?: unknown
- prNumber?: unknown
+ workspaceScope?: unknown
}) => {
const normalizedRepo = typeof repo === 'string' ? repo.trim() : ''
- const normalizedState =
- typeof prContextState === 'string' ? prContextState.trim().toLowerCase() : ''
- const hasPrNumber = typeof prNumber === 'number' && Number.isFinite(prNumber)
+ const normalizedScope =
+ typeof workspaceScope === 'string' ? workspaceScope.trim().toLowerCase() : ''
- if (!normalizedRepo) {
+ if (normalizedScope === 'local') {
return '__local__'
}
- if (normalizedState === 'inactive' && !hasPrNumber) {
+ if (normalizedScope === 'repository') {
+ return normalizedRepo || '__local__'
+ }
+
+ if (!normalizedRepo) {
return '__local__'
}
@@ -183,7 +184,7 @@ export const openStoredWorkspaceContextById = async (
repositoryFilter?: string
} = {},
) => {
- const select = page.getByLabel('Stored local editor contexts')
+ const select = page.getByLabel('Stored workspace')
const openButton = page.locator('#workspaces-open')
const resolveRepositoryFilterForWorkspace = async () => {
@@ -429,11 +430,12 @@ export const seedLocalWorkspaceContexts = async (
contexts: Array<{
id: string
repo: string
+ workspaceScope?: 'local' | 'repository'
base?: string
head: string
prTitle: string
prNumber?: number | null
- prContextState?: 'inactive' | 'active' | 'disconnected' | 'closed'
+ prContextState?: 'inactive' | 'active' | 'closed'
renderMode?: 'dom' | 'react'
tabs?: Array>
activeTabId?: string | null
@@ -458,6 +460,12 @@ export const seedLocalWorkspaceContexts = async (
for (const context of inputContexts) {
const putRequest = store.put({
id: context.id,
+ workspaceScope:
+ context.workspaceScope === 'repository' || context.workspaceScope === 'local'
+ ? context.workspaceScope
+ : context.repo && context.repo.trim()
+ ? 'repository'
+ : 'local',
repo: context.repo,
base: context.base ?? 'main',
head: context.head,
@@ -585,7 +593,7 @@ export const seedActivePrWorkspaceContext = async (
export const getLocalContextOptionLabels = async (page: Page) => {
return page
- .getByLabel('Stored local editor contexts')
+ .getByLabel('Stored workspace')
.locator('option')
.evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || ''))
}
@@ -725,7 +733,7 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
targetState,
}: {
page: Page
- targetState: 'inactive' | 'disconnected' | 'closed'
+ targetState: 'inactive' | 'closed'
}) => {
const repositoryFullName = 'knightedcodemonkey/develop'
const activeHeadBranch = 'develop/issue-97-active-a'
@@ -738,15 +746,9 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
repositoryFullName,
headBranch: targetHeadBranch,
})
- const targetPrTitle =
- targetState === 'inactive' ? '' : `Target ${targetState} workspace`
- const targetPrNumber = targetState === 'inactive' ? null : 9
- const usesPromotedSourceSnapshot =
- targetState === 'inactive' ||
- targetState === 'disconnected' ||
- targetState === 'closed'
- const expectedTargetPrContextState =
- targetState === 'disconnected' ? 'active' : targetState
+ const targetPrTitle = ''
+ const targetPrNumber = null
+ const expectedTargetPrContextState = targetState
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
@@ -885,26 +887,6 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(),
).toContainText(`Target ${targetState} content`)
- const promotedSnapshot = {
- active: {
- repo: '',
- base: '',
- head: '',
- prTitle: '',
- prNumber: null,
- prContextState: 'inactive',
- componentContent: '',
- },
- target: {
- repo: repositoryFullName,
- base: 'main',
- head: activeHeadBranch,
- prTitle: 'Active A workspace',
- prNumber: 2,
- prContextState: 'active',
- componentContent: `export const App = () => Target ${targetState} content`,
- },
- }
const originalSnapshot = {
active: {
repo: repositoryFullName,
@@ -937,25 +919,28 @@ export const runActiveWorkspaceSwitchIntegrityScenario = async ({
}
}
- if (targetState !== 'disconnected') {
- await expect
- .poll(async () => {
- return readSnapshot()
- })
- .toEqual(usesPromotedSourceSnapshot ? promotedSnapshot : originalSnapshot)
- return
- }
-
- const toSnapshotKey = (value: unknown) => JSON.stringify(value)
-
await expect
.poll(async () => {
const snapshot = await readSnapshot()
- const snapshotKey = toSnapshotKey(snapshot)
- return (
- snapshotKey === toSnapshotKey(promotedSnapshot) ||
- snapshotKey === toSnapshotKey(originalSnapshot)
- )
+ const activeMatches =
+ JSON.stringify(snapshot.active) === JSON.stringify(originalSnapshot.active)
+
+ const target = snapshot.target
+ const targetStateMatches =
+ targetState === 'closed'
+ ? target?.prContextState === 'closed' || target?.prContextState === 'inactive'
+ : target?.prContextState === expectedTargetPrContextState
+
+ const targetMatches =
+ target?.repo === originalSnapshot.target.repo &&
+ target?.base === originalSnapshot.target.base &&
+ target?.head === originalSnapshot.target.head &&
+ target?.prTitle === originalSnapshot.target.prTitle &&
+ target?.prNumber === originalSnapshot.target.prNumber &&
+ target?.componentContent === originalSnapshot.target.componentContent &&
+ targetStateMatches
+
+ return activeMatches && targetMatches
})
.toBe(true)
}
@@ -965,7 +950,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({
targetState,
}: {
page: Page
- targetState: 'inactive' | 'disconnected' | 'closed'
+ targetState: 'inactive' | 'closed'
}) => {
const sourceRepositoryFullName = 'knightedcodemonkey/develop'
const targetRepositoryFullName = 'knightedcodemonkey/css'
@@ -982,8 +967,7 @@ export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({
const targetPrTitle =
targetState === 'inactive' ? '' : `Cross target ${targetState} workspace`
const targetPrNumber = 9
- const expectedTargetPrContextState =
- targetState === 'disconnected' ? 'active' : targetState
+ const expectedTargetPrContextState = targetState
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts
index 02e0fc8..6d38106 100644
--- a/playwright/github-pr-drawer/open-pr-create.spec.ts
+++ b/playwright/github-pr-drawer/open-pr-create.spec.ts
@@ -215,6 +215,444 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({
await expect(
page.getByRole('button', { name: 'Close active pull request context' }),
).toBeVisible()
+
+ await expect
+ .poll(async () => {
+ const record = await getWorkspaceTabsRecord(page, {
+ headBranch: 'Develop/Open-Pr-Test',
+ })
+ return {
+ prContextState:
+ typeof record?.prContextState === 'string' ? record.prContextState : '',
+ prNumber:
+ typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber)
+ ? record.prNumber
+ : null,
+ prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '',
+ }
+ })
+ .toEqual({
+ prContextState: 'active',
+ prNumber: 42,
+ prTitle: 'Apply editor updates from develop',
+ })
+})
+
+test('Open PR ignores stale rename target deletions from workspace metadata', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const workspaceHeadBranch = 'feat/stale-target-path-metadata'
+ const treeRequests: Array> = []
+
+ await page.route('https://api.github.com/user/repos**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 11,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'develop',
+ full_name: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release'],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/main',
+ object: { type: 'commit', sha: 'stale-open-pr-main-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/stale-open-pr-main-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'stale-open-pr-main-sha',
+ tree: { sha: 'stale-open-pr-base-tree' },
+ }),
+ })
+ },
+ )
+
+ 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: 'stale-open-pr-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'stale-open-pr-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }),
+ })
+ },
+ )
+
+ 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/feat/stale-target-path-open-pr' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
+ async route => {
+ await route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Not Found' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 144,
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/144',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch: workspaceHeadBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: workspaceHeadBranch,
+ prTitle: '',
+ prNumber: null,
+ prContextState: 'inactive',
+ renderMode: 'dom',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'const App = () => ',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'const App = () => ',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'styles.css',
+ path: 'src/styles.css',
+ language: 'css',
+ role: 'module',
+ isActive: true,
+ content: 'button { color: tomato; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: 'button { color: tomato; }',
+ syncedAt: now,
+ isDirty: true,
+ },
+ ],
+ activeTabId: 'styles',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openStoredWorkspaceContextByHead(page, workspaceHeadBranch)
+ await ensureOpenPrDrawerOpen(page)
+
+ await page.getByLabel('Head').fill('feat/stale-target-path-open-pr')
+ await page.getByLabel('PR title').fill('Do not delete stale target path on open PR')
+ await submitOpenPrAndConfirm(page)
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(
+ 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/144',
+ )
+
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const paths = treePayload?.map(file => String(file.path ?? '')) ?? []
+
+ expect(paths).toContain('src/components/App.tsx')
+ expect(paths).toContain('src/styles.css')
+ expect(paths).not.toContain('src/styles/app.css')
+})
+
+test('Push commit in active PR mode commits only dirty module path when entry is unchanged', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const headBranch = 'develop/module-only-push'
+ const treeRequests: Array> = []
+
+ await page.route('https://api.github.com/user/repos**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 11,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'develop',
+ full_name: repositoryFullName,
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await mockRepositoryBranches(page, {
+ [repositoryFullName]: ['main', 'release', headBranch],
+ })
+
+ 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: 'Module-only commit PR',
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/2',
+ head: { ref: headBranch },
+ 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/${headBranch}`,
+ object: { type: 'commit', sha: 'module-push-head-sha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/module-push-head-sha',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ sha: 'module-push-head-sha',
+ tree: { sha: 'module-push-base-tree' },
+ }),
+ })
+ },
+ )
+
+ 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: 'module-push-tree-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits',
+ async route => {
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({ sha: 'module-push-commit-sha' }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ref: `refs/heads/${headBranch}` }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ const now = Date.now()
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: buildWorkspaceRecordId({
+ repositoryFullName,
+ headBranch,
+ }),
+ repo: repositoryFullName,
+ base: 'main',
+ head: headBranch,
+ prTitle: 'Module-only commit PR',
+ prNumber: 2,
+ prContextState: 'active',
+ renderMode: 'react',
+ tabs: [
+ {
+ id: 'component',
+ name: 'App.tsx',
+ path: 'src/components/App.tsx',
+ language: 'javascript-jsx',
+ role: 'entry',
+ isActive: false,
+ content: 'export const App = () => Entry unchanged',
+ targetPrFilePath: 'src/components/App.tsx',
+ syncedContent: 'export const App = () => Entry unchanged',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'styles',
+ name: 'app.css',
+ path: 'src/styles/app.css',
+ language: 'css',
+ role: 'module',
+ isActive: false,
+ content: '.entry { color: #111; }',
+ targetPrFilePath: 'src/styles/app.css',
+ syncedContent: '.entry { color: #111; }',
+ syncedAt: now,
+ isDirty: false,
+ },
+ {
+ id: 'module-card-tab',
+ name: 'feature-card.tsx',
+ path: 'src/components/feature-card.tsx',
+ language: 'javascript-jsx',
+ role: 'module',
+ isActive: true,
+ content: 'export const FeatureCard = () => ',
+ targetPrFilePath: 'src/components/feature-card.tsx',
+ syncedContent: 'export const FeatureCard = () => ',
+ syncedAt: now,
+ isDirty: false,
+ },
+ ],
+ activeTabId: 'module-card-tab',
+ createdAt: now,
+ lastModified: now,
+ },
+ ])
+
+ await connectByotWithSingleRepo(page)
+ await openStoredWorkspaceContextByHead(page, headBranch)
+
+ await page.getByRole('button', { name: 'Open tab feature-card.tsx' }).click()
+ await expect(page.getByRole('region', { name: 'feature-card.tsx' })).toBeVisible()
+ const componentEditor = page
+ .locator('.editor-panel[data-editor-kind="component"] .cm-content')
+ .first()
+ await componentEditor.fill(
+ 'export const FeatureCard = () => ',
+ )
+ await componentEditor.press('End')
+ await componentEditor.type(' ')
+ await componentEditor.press('Backspace')
+
+ await ensureOpenPrDrawerOpen(page)
+ await page.getByLabel('Include entry tab').uncheck()
+ await page.getByRole('button', { name: 'Push commit' }).last().click()
+
+ const dialog = page.locator('#clear-confirm-dialog')
+ await expect(dialog).toBeVisible()
+ await expect(
+ dialog.getByText('feature-card.tsx -> src/components/feature-card.tsx', {
+ exact: true,
+ }),
+ ).toBeVisible()
+ await expect(
+ dialog.getByText('App.tsx -> src/components/App.tsx', { exact: true }),
+ ).toHaveCount(0)
+
+ await dialog.locator('button[value="confirm"]').evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(`Commit pushed to ${headBranch}`)
+
+ expect(treeRequests).toHaveLength(1)
+ const treePayload = treeRequests[0]?.tree as Array>
+ const paths = treePayload?.map(file => String(file.path ?? '')) ?? []
+ expect(paths).toContain('src/components/feature-card.tsx')
+ expect(paths).not.toContain('src/components/App.tsx')
})
test('Open PR success normalizes trailing newline without showing Edited indicators', async ({
@@ -390,37 +828,18 @@ test('Open PR success normalizes trailing newline without showing Edited indicat
tab.path.trim().endsWith('.css'),
)
- const componentContent =
- typeof componentTab?.content === 'string' ? componentTab.content : ''
- const appStylesContent =
- typeof appStylesTab?.content === 'string' ? appStylesTab.content : ''
- const moduleStylesContent =
- typeof moduleStylesTab?.content === 'string' ? moduleStylesTab.content : ''
-
return {
- componentHasTrailingNewline: componentContent.endsWith('\n'),
- appStylesHasTrailingNewline: appStylesContent.endsWith('\n'),
- moduleStylesHasTrailingNewline: moduleStylesContent.endsWith('\n'),
componentNotDirty: componentTab?.isDirty === false,
appStylesNotDirty: appStylesTab?.isDirty === false,
moduleStylesNotDirty: moduleStylesTab?.isDirty === false,
- componentSynced: componentTab?.syncedContent === componentContent,
- appStylesSynced: appStylesTab?.syncedContent === appStylesContent,
- moduleStylesSynced: moduleStylesTab?.syncedContent === moduleStylesContent,
}
},
{ timeout: 10_000 },
)
.toEqual({
- componentHasTrailingNewline: true,
- appStylesHasTrailingNewline: true,
- moduleStylesHasTrailingNewline: true,
componentNotDirty: true,
appStylesNotDirty: true,
moduleStylesNotDirty: true,
- componentSynced: true,
- appStylesSynced: true,
- moduleStylesSynced: true,
})
await expect(
@@ -454,6 +873,7 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
{
id: 'repo_knightedcodemonkey_develop_feat-local-alpha',
repo: 'knightedcodemonkey/develop',
+ workspaceScope: 'local',
head: 'feat/local-alpha',
prTitle: 'Alpha local context',
prContextState: 'inactive',
@@ -509,15 +929,251 @@ test('Workspaces repository selector filters contexts and keeps local-only conte
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
const developLabels = await getLocalContextOptionLabels(page)
- expect(developLabels).toEqual(['Select a stored local context', 'Alpha active context'])
+ expect(developLabels).toEqual(['Select a stored workspace', 'Alpha active context'])
await selectWorkspacesRepositoryFilter(page, '__local__')
const localLabels = await getLocalContextOptionLabels(page)
- expect(localLabels).toContain('Select a stored local context')
+ expect(localLabels).toContain('Select a stored workspace')
expect(localLabels).toContain('local:Alpha local context')
expect(localLabels).not.toContain('Alpha active context')
})
+test('Workspaces repository with no stored entries hides Workspace select and supports Initialize', async ({
+ page,
+}) => {
+ const seededRecordId = 'local_seed_initialize_preserved'
+ const seededHead = 'feat/local-preserved'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: seededRecordId,
+ repo: '',
+ base: 'main',
+ head: seededHead,
+ prTitle: 'Seed local context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
+
+ await page.route('https://api.github.com/user/repos**', async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 2,
+ owner: { login: 'knightedcodemonkey' },
+ name: 'develop',
+ full_name: 'knightedcodemonkey/develop',
+ default_branch: 'main',
+ permissions: { push: true },
+ },
+ ]),
+ })
+ })
+
+ await page
+ .getByRole('textbox', { name: 'GitHub token' })
+ .fill('github_pat_fake_1234567890')
+ await page.getByRole('button', { name: 'Add GitHub token' }).click()
+
+ const pullRequestRepository = page.getByLabel('Pull request repository')
+ const repositoryValueBeforeScopeSelection = await pullRequestRepository.inputValue()
+
+ await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await expect(page.getByLabel('Stored workspace')).toBeHidden()
+ await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeHidden()
+ await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeHidden()
+ await expect(
+ page.getByRole('button', { name: 'New workspace', exact: true }),
+ ).toBeHidden()
+
+ const beforeInitializeRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === seededRecordId,
+ )
+ expect(beforeInitializeRecord).toBeTruthy()
+ expect(
+ typeof beforeInitializeRecord?.repo === 'string' ? beforeInitializeRecord.repo : '',
+ ).toBe('')
+ await expect(pullRequestRepository).toHaveValue(repositoryValueBeforeScopeSelection)
+
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
+ exact: true,
+ })
+ await expect(initializeButton).toBeVisible()
+ await initializeButton.click()
+
+ await ensureOpenPrDrawerOpen(page)
+ await expect(pullRequestRepository).toHaveValue('knightedcodemonkey/develop')
+
+ await expect
+ .poll(async () => {
+ const updatedRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === seededRecordId,
+ )
+
+ const seededWorkspaceKey =
+ typeof updatedRecord?.workspaceKey === 'string' ? updatedRecord.workspaceKey : ''
+
+ return {
+ seededRepo: typeof updatedRecord?.repo === 'string' ? updatedRecord.repo : '',
+ seededWorkspaceKeyHasRepositoryPrefix: seededWorkspaceKey.startsWith(
+ 'knightedcodemonkey-develop::',
+ ),
+ seededWorkspaceKeyIncludesHead:
+ seededWorkspaceKey.includes('feat/local-preserved'),
+ repositoryScopedCount: (await getAllWorkspaceRecords(page)).filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo : ''
+ const workspaceKey =
+ typeof record?.workspaceKey === 'string' ? record.workspaceKey : ''
+ return (
+ repo === 'knightedcodemonkey/develop' &&
+ workspaceKey.includes('knightedcodemonkey-develop::')
+ )
+ }).length,
+ }
+ })
+ .toEqual({
+ seededRepo: '',
+ seededWorkspaceKeyHasRepositoryPrefix: false,
+ seededWorkspaceKeyIncludesHead: false,
+ repositoryScopedCount: 1,
+ })
+
+ await expect
+ .poll(async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => record?.head === seededHead).length
+ })
+ .toBe(1)
+})
+
+test('Local New workspace always creates a new stored workspace snapshot', async ({
+ page,
+}) => {
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'local_seed_duplicate_key_guard',
+ repo: '',
+ base: 'main',
+ head: 'feat/component-seeded',
+ prTitle: 'Seed local context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
+
+ await page.reload()
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+
+ const countLocalRecords = async () => {
+ return 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', 'readonly')
+ const store = tx.objectStore('prWorkspaces')
+ const getAllRequest = store.getAll()
+ const records = await new Promise>(
+ (resolve, reject) => {
+ getAllRequest.onsuccess = () => {
+ resolve(Array.isArray(getAllRequest.result) ? getAllRequest.result : [])
+ }
+ getAllRequest.onerror = () => reject(getAllRequest.error)
+ },
+ )
+
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return !repo
+ }).length
+ } finally {
+ db.close()
+ }
+ })
+ }
+
+ await selectWorkspacesRepositoryFilter(page, '__local__')
+
+ const initialLocalRecordCount = await countLocalRecords()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countLocalRecords()).toBe(initialLocalRecordCount + 1)
+})
+
+test('Non-Local New workspace forks a new repository-scoped workspace when entries exist', async ({
+ page,
+}) => {
+ const repositoryFullName = 'knightedcodemonkey/develop'
+ const seededHead = 'feat/repo-seeded-workspace'
+
+ await waitForAppReady(page, `${appEntryPath}`)
+
+ await seedLocalWorkspaceContexts(page, [
+ {
+ id: 'repo_seed_for_non_local_fork',
+ repo: repositoryFullName,
+ base: 'main',
+ head: seededHead,
+ prTitle: 'Seed repository context',
+ prNumber: null,
+ prContextState: 'inactive',
+ },
+ ])
+
+ await page.reload()
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+
+ const countRepositoryRecords = async () => {
+ const records = await getAllWorkspaceRecords(page)
+ return records.filter(record => {
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName
+ }).length
+ }
+
+ const initialRepositoryCount = await countRepositoryRecords()
+ await expect(page.getByRole('button', { name: 'Initialize', exact: true })).toBeHidden()
+ await expect(
+ page.getByRole('button', { name: 'New workspace', exact: true }),
+ ).toBeVisible()
+ await page.getByRole('button', { name: 'New workspace', exact: true }).click()
+
+ await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1)
+
+ const persistedRecords = await getAllWorkspaceRecords(page)
+ const forkedRepositoryRecord = persistedRecords.find(record => {
+ const id = typeof record?.id === 'string' ? record.id.trim() : ''
+ const repo = typeof record?.repo === 'string' ? record.repo.trim() : ''
+ return repo === repositoryFullName && id !== 'repo_seed_for_non_local_fork'
+ })
+
+ expect(forkedRepositoryRecord).toBeTruthy()
+ expect(typeof forkedRepositoryRecord?.workspaceKey).toBe('string')
+ const forkedWorkspaceKey = String(forkedRepositoryRecord?.workspaceKey ?? '')
+ expect(forkedWorkspaceKey).toContain('knightedcodemonkey-develop::')
+ expect(
+ typeof forkedRepositoryRecord?.prTitle === 'string'
+ ? forkedRepositoryRecord.prTitle
+ : '',
+ ).toBe('')
+ expect(typeof forkedRepositoryRecord?.head).toBe('string')
+ expect(String(forkedRepositoryRecord?.head ?? '')).not.toBe(seededHead)
+})
+
test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({
page,
}) => {
@@ -568,8 +1224,12 @@ test('Switching Workspaces repository scope to Local keeps inactive record repo
await openStoredWorkspaceContextByHead(page, headBranch)
await selectWorkspacesRepositoryFilter(page, '__local__')
- const localLabels = await getLocalContextOptionLabels(page)
- expect(localLabels).toContain('local:feat/component-v8zw')
+ await expect
+ .poll(async () => {
+ const localLabels = await getLocalContextOptionLabels(page)
+ return localLabels.includes('local:feat/component-v8zw')
+ })
+ .toBe(true)
await expect
.poll(async () => {
@@ -624,7 +1284,9 @@ test('Blank-slate startup persists inactive local workspace before PAT', async (
.toBe(true)
})
-test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }) => {
+test('Fresh PAT bootstrap does not persist drawer head metadata to IDB before submit', async ({
+ page,
+}) => {
const repositoryFullName = 'knightedcodemonkey/contract-case'
await resetWorkbenchStorage(page)
@@ -657,6 +1319,7 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()
await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
const initialRecord = await getWorkspaceTabsRecord(page)
const initialRecordId = getWorkspaceRecordId(initialRecord)
@@ -668,43 +1331,39 @@ test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }
await expect
.poll(async () => {
- const selectedRepository = await page
- .getByLabel('Pull request repository')
- .inputValue()
- const drawerHead = await page.getByLabel('Head').inputValue()
const records = await getAllWorkspaceRecords(page)
+ const matching = records.filter(record => record?.id === initialRecordId)
+ const latest = matching.sort((left, right) => {
+ const leftModified =
+ typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified)
+ ? left.lastModified
+ : 0
+ const rightModified =
+ typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified)
+ ? right.lastModified
+ : 0
+ return rightModified - leftModified
+ })[0]
- const latestRecord = records
- .filter(record => record?.repo === selectedRepository)
- .sort((a, b) => {
- const aLastModified =
- typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified)
- ? a.lastModified
- : 0
- const bLastModified =
- typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified)
- ? b.lastModified
- : 0
- return bLastModified - aLastModified
- })[0]
-
- return (
- Boolean(selectedRepository) &&
- Boolean(drawerHead) &&
- Boolean(latestRecord) &&
- latestRecord.repo === selectedRepository &&
- latestRecord.head === drawerHead
- )
+ return {
+ count: matching.length,
+ id: typeof latest?.id === 'string' ? latest.id : '',
+ head: typeof latest?.head === 'string' ? latest.head : '',
+ }
+ })
+ .toEqual({
+ count: 1,
+ id: initialRecordId,
+ head: typeof initialRecord?.head === 'string' ? initialRecord.head : '',
})
- .toBe(true)
- const record = await getWorkspaceTabsRecord(page, {
- headBranch: 'develop/fresh-pat-bootstrap',
- })
- expect(record?.id).toBe(initialRecordId)
+ const updatedRecord = (await getAllWorkspaceRecords(page)).find(
+ record => record?.id === initialRecordId,
+ )
+ expect(updatedRecord?.head).toBe(initialRecord?.head)
})
-test('Changing head updates current workspace without creating a new record', async ({
+test('Changing head does not update current workspace without explicit submit', async ({
page,
}) => {
const repositoryFullName = 'knightedcodemonkey/contract-case'
@@ -739,6 +1398,7 @@ test('Changing head updates current workspace without creating a new record', as
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()
await selectWorkspacesRepositoryFilter(page, repositoryFullName)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
const initialRecord = await getWorkspaceTabsRecord(page)
const initialRecordId = getWorkspaceRecordId(initialRecord)
@@ -753,7 +1413,7 @@ test('Changing head updates current workspace without creating a new record', as
await expect
.poll(async () => {
const records = await getAllWorkspaceRecords(page)
- const matching = records.filter(record => record?.repo === repositoryFullName)
+ const matching = records.filter(record => record?.id === initialRecordId)
const latest = matching.sort((left, right) => {
const leftModified =
typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified)
@@ -775,11 +1435,11 @@ test('Changing head updates current workspace without creating a new record', as
.toEqual({
count: 1,
id: initialRecordId,
- head: 'develop/head-second',
+ head: typeof initialRecord?.head === 'string' ? initialRecord.head : '',
})
})
-for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) {
+for (const prContextState of ['inactive', 'closed'] as const) {
test(`Head stays fixed across repository changes for ${prContextState} workspace context`, async ({
page,
browserName,
@@ -879,7 +1539,7 @@ for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) {
await selectWorkspacesRepositoryFilter(page, targetRepository)
await ensureOpenPrDrawerOpen(page)
- await expect(page.getByLabel('Pull request repository')).toHaveValue(targetRepository)
+ await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository)
await expect(page.getByLabel('Head')).toHaveValue(workspaceHead)
await expect
@@ -891,7 +1551,7 @@ for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) {
})
}
-test('Open PR promotes inactive workspace with stable record id when repository changes', async ({
+test('Open PR promotes inactive workspace when repository changes', async ({
page,
browserName,
}) => {
@@ -1073,6 +1733,7 @@ test('Open PR promotes inactive workspace with stable record id when repository
await ensureOpenPrDrawerOpen(page)
await expect(page.getByLabel('Pull request repository')).toHaveValue(oldRepository)
await selectWorkspacesRepositoryFilter(page, newRepository)
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(page.getByLabel('Pull request repository')).toHaveValue(newRepository)
@@ -1091,12 +1752,12 @@ test('Open PR promotes inactive workspace with stable record id when repository
typeof record?.head === 'string' && record.head.trim().toLowerCase() === headBranch,
)
- const promotedActiveRecord = recordsByHead.find(
- record => record?.repo === newRepository && record?.prContextState === 'active',
- )
+ const promotedActiveRecord = recordsByHead.find(record => record?.prNumber === 88)
- expect(promotedActiveRecord?.id).toBe(oldWorkspaceId)
+ expect(promotedActiveRecord).toBeTruthy()
expect(promotedActiveRecord?.prNumber).toBe(88)
+ expect(promotedActiveRecord?.head).toBe(headBranch)
+ expect(promotedActiveRecord?.prContextState).toBe('active')
expect(recordsByHead).toHaveLength(1)
})
@@ -1390,6 +2051,109 @@ test('Open PR drawer starts with empty title/description and short default head'
await expect(page.getByLabel('PR description')).toHaveValue('')
})
+test('Open PR drawer hard-fails when requested head branch already exists', async ({
+ page,
+}) => {
+ let createRefRequestCount = 0
+ let treeRequestCount = 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'],
+ })
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
+ async route => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ ref: 'refs/heads/main',
+ object: { type: 'commit', sha: 'abc123mainsha' },
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
+ async route => {
+ createRefRequestCount += 1
+ await route.fulfill({
+ status: 422,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ message: 'Reference already exists',
+ documentation_url: 'https://docs.github.com/rest/git/refs#create-a-reference',
+ }),
+ })
+ },
+ )
+
+ await page.route(
+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees',
+ async route => {
+ treeRequestCount += 1
+ 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/pulls',
+ async route => {
+ pullRequestRequestCount += 1
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ number: 77,
+ html_url: 'https://github.com/knightedcodemonkey/develop/pull/77',
+ }),
+ })
+ },
+ )
+
+ await waitForAppReady(page, `${appEntryPath}`)
+ await connectByotWithSingleRepo(page)
+ await ensureOpenPrDrawerOpen(page)
+
+ await page.getByLabel('Head').fill('feat/A')
+ await page.getByLabel('PR title').fill('Should fail for existing branch')
+ await submitOpenPrAndConfirm(page)
+
+ await expect(
+ page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
+ ).toContainText(
+ 'Open PR failed: Branch feat/A already exists. Choose another branch name and retry.',
+ )
+
+ expect(createRefRequestCount).toBe(1)
+ expect(treeRequestCount).toBe(0)
+ expect(pullRequestRequestCount).toBe(0)
+})
+
test('Open PR drawer base dropdown updates from mocked repo branches', async ({
page,
}) => {
@@ -1452,12 +2216,14 @@ test('Open PR drawer base dropdown updates from mocked repo branches', async ({
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
await expect(baseSelect).toHaveValue('main')
await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next'])
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css')
+ await page.getByRole('button', { name: 'Initialize', exact: true }).click()
await ensureOpenPrDrawerOpen(page)
await expect(repoSelect).toHaveValue('knightedcodemonkey/css')
await expect(baseSelect).toHaveValue('stable')
@@ -1632,15 +2398,18 @@ test('Open PR repository field stays read-only while Workspaces controls reposit
await ensureOpenPrDrawerOpen(page)
const repoSelect = page.getByLabel('Pull request repository')
+ const initialRepoValue = await repoSelect.inputValue()
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop')
+ await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible()
await ensureOpenPrDrawerOpen(page)
- await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
+ await expect(repoSelect).toHaveValue(initialRepoValue)
await expect(repoSelect).toBeDisabled()
await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css')
+ await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible()
await ensureOpenPrDrawerOpen(page)
- await expect(repoSelect).toHaveValue('knightedcodemonkey/css')
+ await expect(repoSelect).toHaveValue(initialRepoValue)
await expect(repoSelect).toBeDisabled()
})
diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts
index f4e8c3e..1bf1d7c 100644
--- a/playwright/helpers/app-test-helpers.ts
+++ b/playwright/helpers/app-test-helpers.ts
@@ -400,7 +400,15 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => {
const isExpanded = await toggle.getAttribute('aria-expanded')
if (isExpanded !== 'true') {
- await toggle.click()
+ try {
+ await toggle.click({ timeout: 2_000 })
+ } catch {
+ await toggle.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
+ }
}
await expect(
@@ -412,8 +420,7 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => {
const toggle = page.locator('#workspaces-toggle')
await expect(toggle).toBeVisible()
- const isExpanded = await toggle.getAttribute('aria-expanded')
- if (isExpanded === 'true') {
+ const requestClose = async () => {
const closeButton = page.locator('#workspaces-close')
if (await closeButton.isVisible()) {
await closeButton.evaluate(element => {
@@ -421,15 +428,32 @@ export const ensureWorkspacesDrawerClosed = async (page: Page) => {
element.click()
}
})
- } else {
- await toggle.evaluate(element => {
- if (element instanceof HTMLButtonElement) {
- element.click()
- }
- })
+ return
}
+
+ await toggle.evaluate(element => {
+ if (element instanceof HTMLButtonElement) {
+ element.click()
+ }
+ })
}
+ const isExpanded = await toggle.getAttribute('aria-expanded')
+ if (isExpanded === 'true') {
+ await requestClose()
+ }
+
+ await expect
+ .poll(async () => {
+ const expanded = await toggle.getAttribute('aria-expanded')
+ if (expanded === 'true') {
+ await requestClose()
+ }
+
+ return expanded
+ })
+ .toBe('false')
+
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden()
}
@@ -502,15 +526,32 @@ export const connectByotWithSingleRepo = async (
await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop')
await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
+ const initializeButton = page.getByRole('button', {
+ name: 'Initialize',
+ exact: true,
+ })
+
+ if (await initializeButton.isVisible()) {
+ await initializeButton.click()
+ } else {
+ const storedWorkspace = page.getByLabel('Stored workspace')
+ if (await storedWorkspace.isVisible()) {
+ const workspaceValue = await storedWorkspace
+ .locator('option:not([value=""])')
+ .first()
+ .getAttribute('value')
+
+ if (workspaceValue) {
+ await storedWorkspace.selectOption(workspaceValue)
+ await page.getByRole('button', { name: 'Open', exact: true }).click()
+ }
+ }
+ }
+
await ensureWorkspacesDrawerClosed(page)
const repoSelect = page.getByLabel('Pull request repository')
- await expect
- .poll(async () => {
- const value = await repoSelect.inputValue()
- return value === '' || value === 'knightedcodemonkey/develop'
- })
- .toBe(true)
+ await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
await expect(repoSelect).toBeDisabled()
await expect(
diff --git a/src/app.js b/src/app.js
index 3601cdb..c03608d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -34,6 +34,7 @@ import { createEditedIndicatorVisibilityController } from './modules/app-core/ed
import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js'
import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js'
import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js'
+import { createWorkspaceScopeForkActions } from './modules/app-core/workspace-scope-fork-actions.js'
import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js'
import { defaultCss, defaultJsx } from './modules/app-core/defaults.js'
import { createGitHubPrContextUiController } from './modules/app-core/github-pr-context-ui.js'
@@ -123,7 +124,6 @@ const githubPrToggleLabel = document.getElementById('github-pr-toggle-label')
const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon')
const githubPrToggleIconPath = document.getElementById('github-pr-toggle-icon-path')
const githubPrContextClose = document.getElementById('github-pr-context-close')
-const githubPrContextDisconnect = document.getElementById('github-pr-context-disconnect')
const githubPrDrawer = document.getElementById('github-pr-drawer')
const openPrTitle = document.getElementById('open-pr-title')
const githubPrClose = document.getElementById('github-pr-close')
@@ -141,6 +141,8 @@ const workspacesDrawer = document.getElementById('workspaces-drawer')
const workspacesClose = document.getElementById('workspaces-close')
const workspacesStatus = document.getElementById('workspaces-status')
const workspacesRepository = document.getElementById('workspaces-repository')
+const workspacesInitialize = document.getElementById('workspaces-initialize')
+const workspacesNew = document.getElementById('workspaces-new')
const workspacesSelect = document.getElementById('workspaces-select')
const workspacesOpen = document.getElementById('workspaces-open')
const workspacesRemove = document.getElementById('workspaces-remove')
@@ -409,8 +411,15 @@ const githubAiContextState = {
let workspacePrContextState = 'inactive'
let workspacePrNumber = null
let workspaceRepositoryFullName = ''
+let workspaceScopeMarker = 'local'
let hasObservedActivePrContextInSession = false
+const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local')
+
+const setWorkspaceScopeMarker = nextScope => {
+ workspaceScopeMarker = toWorkspaceScopeMarker(nextScope)
+}
+
const toPullRequestNumber = value => {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value
@@ -423,6 +432,7 @@ const setActiveWorkspaceRecordId = nextValue => {
activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue)
if (!activeWorkspaceRecordId) {
workspaceRepositoryFullName = ''
+ workspaceScopeMarker = 'local'
}
}
@@ -458,7 +468,6 @@ const prContextUi = createGitHubPrContextUiController({
stylesPrSyncIcon,
stylesPrSyncIconPath,
githubPrContextClose,
- githubPrContextDisconnect,
aiChatToggle,
workspacesToggle,
githubPrOpenIcon,
@@ -569,7 +578,9 @@ const getPersistedActivePrContext = createPersistedActivePrContextGetter({
const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({
getCurrentSelectedRepository: () =>
- workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName(),
+ workspaceScopeMarker === 'repository'
+ ? workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName()
+ : '',
githubPrBaseBranch,
githubPrHeadBranch,
githubPrTitle,
@@ -630,9 +641,12 @@ const workspaceSyncController = createWorkspaceSyncController({
getJsxSource: () => getJsxSource(),
getCssSource: () => getCssSource(),
getWorkspaceTabByKind,
+ getLoadedComponentTabId: () => loadedComponentTabId,
+ getLoadedStylesTabId: () => loadedStylesTabId,
queueWorkspaceSave: () => queueWorkspaceSave(),
resolveWorkspaceRecordIdentity,
getWorkspaceContextSnapshot,
+ getWorkspaceScopeMarker: () => workspaceScopeMarker,
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt,
getRenderModeValue: () => renderMode.value,
@@ -714,28 +728,6 @@ const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets(
const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) =>
workspaceSyncController.reconcileWorkspaceTabsWithEditorSync({ tabTargets })
-const syncActiveWorkspaceRepositoryScope = async (
- repositoryFullName,
- { rekeyRecord = false } = {},
-) => {
- if (toNonEmptyWorkspaceText(workspacePrContextState).toLowerCase() !== 'inactive') {
- return
- }
-
- if (!toNonEmptyWorkspaceText(activeWorkspaceRecordId)) {
- return
- }
-
- if (rekeyRecord) {
- await flushWorkspaceSave({ preserveRecordId: true })
- setActiveWorkspaceRecordId('')
- activeWorkspaceCreatedAt = null
- }
-
- workspaceRepositoryFullName = toNonEmptyWorkspaceText(repositoryFullName)
- await flushWorkspaceSave({ preserveRecordId: !rekeyRecord })
-}
-
const buildWorkspaceRecordSnapshot = ({ recordId } = {}) =>
workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId })
@@ -764,6 +756,7 @@ const {
setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
setWorkspacePrContextState: value => (workspacePrContextState = value),
setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)),
+ setWorkspaceScopeMarker,
getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName,
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value),
@@ -830,7 +823,7 @@ const {
getWorkspaceTabByKind,
makeUniqueTabPath,
createWorkspaceTabId,
- onWorkspaceRecordApplied: (workspace, options = {}) => {
+ onWorkspaceRecordApplied: workspace => {
if (!workspace || typeof workspace !== 'object') {
return
}
@@ -844,14 +837,11 @@ const {
prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false })
- const isSilentRestore = options?.silent === true
-
const state =
typeof workspace.prContextState === 'string'
? workspace.prContextState.trim().toLowerCase()
: ''
- const shouldHydratePrContext =
- state === 'active' || (state === 'disconnected' && !isSilentRestore)
+ const shouldHydratePrContext = state === 'active'
if (!shouldHydratePrContext) {
return
}
@@ -878,6 +868,42 @@ const {
},
})
+const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } =
+ createWorkspaceScopeForkActions({
+ toNonEmptyWorkspaceText,
+ workspaceStorage,
+ flushWorkspaceSave,
+ refreshLocalContextOptions,
+ createWorkspaceRecordId,
+ buildWorkspaceRecordSnapshot,
+ toWorkspaceRecordKey,
+ getWorkspacePrContextState: () => workspacePrContextState,
+ setWorkspacePrContextState: value => {
+ setWorkspacePrContextState(value)
+ },
+ setWorkspacePrNumber: value => {
+ setWorkspacePrNumber(value)
+ },
+ getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
+ setActiveWorkspaceRecordId,
+ setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
+ getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName,
+ setWorkspaceRepositoryFullName: value => {
+ workspaceRepositoryFullName = toNonEmptyWorkspaceText(value)
+ },
+ setWorkspaceScopeMarker,
+ setHeadBranchValue: value => {
+ if (githubPrHeadBranch) {
+ githubPrHeadBranch.value = value
+ }
+ },
+ setPrTitleValue: value => {
+ if (githubPrTitle) {
+ githubPrTitle.value = value
+ }
+ },
+ })
+
editedIndicatorVisibilityController.setRefreshHandlers({
syncHeaderLabels,
renderWorkspaceTabs,
@@ -942,6 +968,7 @@ const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffContr
getWorkspacePrNumber: () => workspacePrNumber,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt,
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
@@ -1065,6 +1092,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({
workspacesClose,
workspacesStatus,
workspacesRepository,
+ workspacesInitialize,
+ workspacesNew,
workspacesSelect,
workspacesOpen,
workspacesRemove,
@@ -1074,10 +1103,13 @@ const githubWorkflows = createGitHubWorkflowsSetup({
getActiveWorkspaceRecordId: () => activeWorkspaceRecordId,
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value),
+ buildWorkspaceRecordSnapshot,
listLocalContextRecords,
refreshLocalContextOptions,
applyWorkspaceRecord,
syncActiveWorkspaceRepositoryScope,
+ forkWorkspaceFromCurrentState,
+ flushWorkspaceSave,
getWorkspacePrFileCommits,
getEditorSyncTargets,
reconcileWorkspaceTabsWithPushUpdates,
@@ -1123,19 +1155,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({
'PR context closed. Open Workspaces to load a saved workspace or continue with this local workspace.',
})
},
- onPrContextDisconnected: result => {
- archivePrSessionAndStartFreshLocal({
- result,
- archivedState: 'disconnected',
- statusMessage:
- 'PR context disconnected. Open Workspaces to load a saved workspace or continue with this local workspace.',
- })
- },
getPersistedActivePrContext,
getTokenForVisibility: () => githubAiContextState.token,
- closeWorkspacesDrawer: () => {
- void workspacesDrawerController?.setOpen(false)
- },
getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey,
syncFromActiveContext: ({ tabTargets }) => {
const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId()
@@ -1148,7 +1169,6 @@ const githubWorkflows = createGitHubWorkflowsSetup({
},
formatActivePrReference,
githubPrContextClose,
- githubPrContextDisconnect,
},
actions: {
applyRenderMode,
diff --git a/src/index.html b/src/index.html
index 98aceef..423d5c2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -140,39 +140,39 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js
index 2b38b1b..534d6e8 100644
--- a/src/modules/app-core/app-bindings-startup.js
+++ b/src/modules/app-core/app-bindings-startup.js
@@ -68,10 +68,6 @@ const bindAppEventsAndStart = ({
setCdnLoading,
} = sourceActions
const {
- githubPrRepoSelect,
- githubPrBaseBranch,
- githubPrHeadBranch,
- githubPrTitle,
workspaceTabAddMenuUi,
workspaceTabAddButton,
workspaceTabAddModule,
@@ -89,7 +85,6 @@ const bindAppEventsAndStart = ({
getWorkspaceTabByKind,
workspaceSaveController,
workspaceStorage,
- bindWorkspaceMetadataPersistence,
syncDiagnosticsDrawerLayout,
setHasCompletedInitialWorkspaceBootstrap,
} = workspaceUi
@@ -341,15 +336,6 @@ const bindAppEventsAndStart = ({
})
})
- bindWorkspaceMetadataPersistence(githubPrRepoSelect)
- bindWorkspaceMetadataPersistence(githubPrBaseBranch)
- bindWorkspaceMetadataPersistence(githubPrHeadBranch, {
- preserveRecordIdOnInput: true,
- preserveRecordIdOnChange: true,
- rekeyOnBlur: false,
- })
- bindWorkspaceMetadataPersistence(githubPrTitle)
-
for (const button of appThemeButtons) {
button.addEventListener('click', () => {
const nextTheme = button.dataset.appTheme
diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js
index 5536e7d..b48a197 100644
--- a/src/modules/app-core/github-pr-context-ui.js
+++ b/src/modules/app-core/github-pr-context-ui.js
@@ -10,7 +10,6 @@ export const createGitHubPrContextUiController = ({
stylesPrSyncIcon,
stylesPrSyncIconPath,
githubPrContextClose,
- githubPrContextDisconnect,
aiChatToggle,
workspacesToggle,
githubPrOpenIcon,
@@ -85,12 +84,10 @@ export const createGitHubPrContextUiController = ({
if (!hasActiveContext) {
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
return
}
githubPrContextClose?.removeAttribute('hidden')
- githubPrContextDisconnect?.removeAttribute('hidden')
}
const markActivePrEditorContentSynced = () => {
@@ -118,18 +115,15 @@ export const createGitHubPrContextUiController = ({
if (githubPrToggle instanceof HTMLElement) {
githubPrToggle.hidden = false
}
- if (!contextState.activePrContext) {
- if (workspacesToggle instanceof HTMLElement) {
- workspacesToggle.hidden = false
- }
+
+ if (workspacesToggle instanceof HTMLElement) {
+ workspacesToggle.hidden = false
}
if (contextState.activePrContext) {
githubPrContextClose?.removeAttribute('hidden')
- githubPrContextDisconnect?.removeAttribute('hidden')
} else {
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
}
return
}
@@ -155,7 +149,6 @@ export const createGitHubPrContextUiController = ({
}
workspacesToggle?.setAttribute('aria-expanded', 'false')
githubPrContextClose?.setAttribute('hidden', '')
- githubPrContextDisconnect?.setAttribute('hidden', '')
closeChatDrawer?.()
closePrDrawer?.()
void closeWorkspacesDrawer?.()
diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js
index 6ebc4b6..609a4ed 100644
--- a/src/modules/app-core/github-workflows-setup.js
+++ b/src/modules/app-core/github-workflows-setup.js
@@ -22,10 +22,13 @@ const createGitHubWorkflowsSetup = ({
getActiveWorkspaceRecordId: workspace.getActiveWorkspaceRecordId,
setActiveWorkspaceRecordId: workspace.setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt: workspace.setActiveWorkspaceCreatedAt,
+ buildWorkspaceRecordSnapshot: workspace.buildWorkspaceRecordSnapshot,
listLocalContextRecords: workspace.listLocalContextRecords,
refreshLocalContextOptions: workspace.refreshLocalContextOptions,
applyWorkspaceRecord: workspace.applyWorkspaceRecord,
syncActiveWorkspaceRepositoryScope: workspace.syncActiveWorkspaceRepositoryScope,
+ forkWorkspaceFromCurrentState: workspace.forkWorkspaceFromCurrentState,
+ flushWorkspaceSave: workspace.flushWorkspaceSave,
getWorkspacePrFileCommits: workspace.getWorkspacePrFileCommits,
getEditorSyncTargets: workspace.getEditorSyncTargets,
getRenderMode: runtime.getRenderMode,
@@ -40,16 +43,13 @@ const createGitHubWorkflowsSetup = ({
onPrContextStateChange: runtime.onPrContextStateChange,
onPrContextVerifiedClosed: runtime.onPrContextVerifiedClosed,
onPrContextClosed: runtime.onPrContextClosed,
- onPrContextDisconnected: runtime.onPrContextDisconnected,
getTokenForVisibility: runtime.getTokenForVisibility,
- closeWorkspacesDrawer: runtime.closeWorkspacesDrawer,
getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey,
syncFromActiveContext: runtime.syncFromActiveContext,
applyRenderMode: actions.applyRenderMode,
applyStyleMode: actions.applyStyleMode,
formatActivePrReference: runtime.formatActivePrReference,
githubPrContextClose: runtime.githubPrContextClose,
- githubPrContextDisconnect: runtime.githubPrContextDisconnect,
confirmAction: actions.confirmAction,
setStatus: actions.setStatus,
showAppToast: actions.showAppToast,
diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js
index 472c647..d4efcbd 100644
--- a/src/modules/app-core/github-workflows.js
+++ b/src/modules/app-core/github-workflows.js
@@ -1,5 +1,3 @@
-import { repositoryStarterSelectionIdPrefix } from '../constants.js'
-
const initializeGitHubWorkflows = ({
createGitHubPrEditorSyncController,
createGitHubChatDrawer,
@@ -42,6 +40,8 @@ const initializeGitHubWorkflows = ({
workspacesClose,
workspacesStatus,
workspacesRepository,
+ workspacesInitialize,
+ workspacesNew,
workspacesSelect,
workspacesOpen,
workspacesRemove,
@@ -49,10 +49,13 @@ const initializeGitHubWorkflows = ({
getActiveWorkspaceRecordId,
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt,
+ buildWorkspaceRecordSnapshot,
listLocalContextRecords,
refreshLocalContextOptions,
applyWorkspaceRecord,
syncActiveWorkspaceRepositoryScope,
+ forkWorkspaceFromCurrentState,
+ flushWorkspaceSave,
getWorkspacePrFileCommits,
getEditorSyncTargets,
getRenderMode,
@@ -66,16 +69,13 @@ const initializeGitHubWorkflows = ({
onPrContextStateChange,
onPrContextVerifiedClosed,
onPrContextClosed,
- onPrContextDisconnected,
getTokenForVisibility,
- closeWorkspacesDrawer,
getActivePrEditorSyncKey,
syncFromActiveContext,
applyRenderMode,
applyStyleMode,
formatActivePrReference,
githubPrContextClose,
- githubPrContextDisconnect,
confirmAction,
setStatus,
showAppToast,
@@ -102,18 +102,6 @@ const initializeGitHubWorkflows = ({
return collectTopLevelDeclarations({ source, transformJsxSource })
}
- const parseRepositoryStarterSelectionId = value => {
- const normalizedValue = typeof value === 'string' ? value.trim() : ''
- if (!normalizedValue.startsWith(repositoryStarterSelectionIdPrefix)) {
- return ''
- }
-
- const repositoryFullName = normalizedValue.slice(
- repositoryStarterSelectionIdPrefix.length,
- )
- return typeof repositoryFullName === 'string' ? repositoryFullName.trim() : ''
- }
-
const shouldReconcileWorkspaceUpdatesForRepository = repositoryFullName => {
const normalizedActiveRepository =
typeof githubAiContextState.activePrContext?.repositoryFullName === 'string'
@@ -166,14 +154,30 @@ const initializeGitHubWorkflows = ({
return true
}
+ const persistActiveWorkspaceSnapshot = async () => {
+ if (typeof buildWorkspaceRecordSnapshot !== 'function') {
+ return null
+ }
+
+ const activeWorkspaceRecordId =
+ typeof getActiveWorkspaceRecordId === 'function' ? getActiveWorkspaceRecordId() : ''
+
+ const snapshot =
+ typeof activeWorkspaceRecordId === 'string' && activeWorkspaceRecordId.trim()
+ ? buildWorkspaceRecordSnapshot({ recordId: activeWorkspaceRecordId })
+ : buildWorkspaceRecordSnapshot()
+
+ if (!snapshot || typeof snapshot !== 'object') {
+ return null
+ }
+
+ const savedWorkspaceRecord = await workspaceStorage.upsertWorkspace(snapshot)
+ setActiveWorkspaceRecordId(savedWorkspaceRecord.id)
+ setActiveWorkspaceCreatedAt(savedWorkspaceRecord.createdAt ?? null)
+ return savedWorkspaceRecord
+ }
+
const prEditorSyncController = createGitHubPrEditorSyncController({
- setComponentSource: value => {
- setComponentSource(value)
- },
- setStylesSource: value => {
- setStylesSource(value)
- },
- scheduleRender,
shouldApplySyncResult: shouldApplyActivePrEditorSync,
})
@@ -224,6 +228,13 @@ const initializeGitHubWorkflows = ({
setSelectedRepository: setCurrentSelectedRepository,
getFileCommits: getWorkspacePrFileCommits,
getEditorSyncTargets,
+ persistWorkspaceMetadataOnSubmit: async () => {
+ if (typeof flushWorkspaceSave !== 'function') {
+ return
+ }
+
+ await flushWorkspaceSave({ preserveRecordId: true })
+ },
getTopLevelDeclarations,
getRenderMode,
getStyleMode,
@@ -233,7 +244,16 @@ const initializeGitHubWorkflows = ({
confirmBeforeSubmit: options => {
confirmAction(options)
},
- onPullRequestOpened: ({ url, fileUpdates, repositoryFullName }) => {
+ onPullRequestOpened: async ({
+ url,
+ fileUpdates,
+ repositoryFullName,
+ pullRequestNumber,
+ }) => {
+ if (typeof onPrContextStateChange === 'function') {
+ onPrContextStateChange(githubAiContextState.activePrContext)
+ }
+
const activeContextSyncKey = getActivePrContextSyncKey(
githubAiContextState.activePrContext,
)
@@ -247,12 +267,74 @@ const initializeGitHubWorkflows = ({
if (shouldReconcileWorkspaceUpdatesForRepository(repositoryFullName)) {
reconcileWorkspaceTabsWithPushUpdates(fileUpdates)
}
+
+ if (typeof flushWorkspaceSave === 'function') {
+ try {
+ await flushWorkspaceSave({ preserveRecordId: true })
+ } catch {
+ /* Save failures are already surfaced through saver onError. */
+ }
+ }
+
+ const activeWorkspaceRecordId =
+ typeof getActiveWorkspaceRecordId === 'function'
+ ? getActiveWorkspaceRecordId()
+ : ''
+ if (activeWorkspaceRecordId) {
+ const activeWorkspaceRecord = await workspaceStorage.getWorkspaceById(
+ activeWorkspaceRecordId,
+ )
+ if (activeWorkspaceRecord && typeof activeWorkspaceRecord === 'object') {
+ const nextPrTitle =
+ typeof githubAiContextState.activePrContext?.prTitle === 'string' &&
+ githubAiContextState.activePrContext.prTitle.trim()
+ ? githubAiContextState.activePrContext.prTitle
+ : typeof activeWorkspaceRecord.prTitle === 'string'
+ ? activeWorkspaceRecord.prTitle
+ : ''
+ const nextPrNumber =
+ typeof pullRequestNumber === 'number' && Number.isFinite(pullRequestNumber)
+ ? pullRequestNumber
+ : typeof githubAiContextState.activePrContext?.pullRequestNumber ===
+ 'number' &&
+ Number.isFinite(githubAiContextState.activePrContext.pullRequestNumber)
+ ? githubAiContextState.activePrContext.pullRequestNumber
+ : null
+
+ const savedWorkspaceRecord = await workspaceStorage.upsertWorkspace({
+ ...activeWorkspaceRecord,
+ prContextState: 'active',
+ prNumber: nextPrNumber,
+ prTitle: nextPrTitle,
+ })
+
+ setActiveWorkspaceRecordId(savedWorkspaceRecord.id)
+ setActiveWorkspaceCreatedAt(savedWorkspaceRecord.createdAt ?? null)
+ }
+ }
+
+ await refreshLocalContextOptions()
showAppToast(message)
},
- onPullRequestCommitPushed: ({ repositoryFullName, branch, fileUpdates }) => {
+ onPullRequestCommitPushed: async ({ repositoryFullName, branch, fileUpdates }) => {
if (shouldReconcileWorkspaceUpdatesForRepository(repositoryFullName)) {
reconcileWorkspaceTabsWithPushUpdates(fileUpdates)
}
+
+ try {
+ await persistActiveWorkspaceSnapshot()
+ } catch {
+ /* Fall back to debounced saver flush below. */
+ }
+
+ if (typeof flushWorkspaceSave === 'function') {
+ try {
+ await flushWorkspaceSave({ preserveRecordId: true })
+ } catch {
+ /* Save failures are already surfaced through saver onError. */
+ }
+ }
+
const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0
const message =
fileCount > 0
@@ -264,10 +346,6 @@ const initializeGitHubWorkflows = ({
prContextUi.setActivePrContext(activeContext)
prContextUi.syncAiChatTokenVisibility(getTokenForVisibility())
- if (activeContext) {
- closeWorkspacesDrawer()
- }
-
if (typeof onPrContextStateChange === 'function') {
onPrContextStateChange(activeContext)
}
@@ -315,6 +393,9 @@ const initializeGitHubWorkflows = ({
closeButton: workspacesClose,
statusNode: workspacesStatus,
repositorySelect: workspacesRepository,
+ getActiveWorkspaceId: () => getActiveWorkspaceRecordId(),
+ initializeButton: workspacesInitialize,
+ newButton: workspacesNew,
selectInput: workspacesSelect,
openButton: workspacesOpen,
removeButton: workspacesRemove,
@@ -334,13 +415,7 @@ const initializeGitHubWorkflows = ({
return '__local__'
},
- onRepositoryFilterChange: async repositoryFilter => {
- if (repositoryFilter === '__local__') {
- clearCurrentSelectedRepository?.()
- } else {
- setCurrentSelectedRepository?.(repositoryFilter)
- }
-
+ onRepositoryFilterChange: async () => {
prDrawerController.resetStatus?.()
prDrawerController.syncRepositories()
},
@@ -348,25 +423,63 @@ const initializeGitHubWorkflows = ({
return 'right'
},
onRefreshRequested: listLocalContextRecords,
- onOpenSelected: async workspaceId => {
+ onInitializeWorkspace: async repositoryFilter => {
+ const normalizedFilter =
+ typeof repositoryFilter === 'string' ? repositoryFilter.trim() : ''
+ if (!normalizedFilter || normalizedFilter === '__local__') {
+ return false
+ }
+
+ const repositoryFullName = normalizedFilter
+
try {
- const starterRepositoryFullName = parseRepositoryStarterSelectionId(workspaceId)
- if (starterRepositoryFullName) {
- setCurrentSelectedRepository?.(starterRepositoryFullName)
- await syncActiveWorkspaceRepositoryScope?.(starterRepositoryFullName, {
- rekeyRecord: true,
- })
- await refreshLocalContextOptions()
- prDrawerController.resetStatus?.()
- prDrawerController.syncRepositories()
- return true
+ await syncActiveWorkspaceRepositoryScope?.(repositoryFullName, {
+ rekeyRecord: false,
+ })
+ setCurrentSelectedRepository?.(repositoryFullName)
+ await refreshLocalContextOptions()
+ prDrawerController.resetStatus?.()
+ prDrawerController.syncRepositories()
+ return true
+ } catch {
+ workspacesDrawerController?.setStatus('Could not initialize workspace.', 'error')
+ return false
+ }
+ },
+ onCreateWorkspace: async repositoryFilter => {
+ const normalizedFilter =
+ typeof repositoryFilter === 'string' ? repositoryFilter.trim() : ''
+ const repositoryFullName =
+ normalizedFilter && normalizedFilter !== '__local__' ? normalizedFilter : ''
+
+ try {
+ await forkWorkspaceFromCurrentState?.(repositoryFullName)
+ prDrawerController.clearSelectedRepositoryActivePrContext?.({
+ resetForm: false,
+ })
+
+ if (repositoryFullName) {
+ setCurrentSelectedRepository?.(repositoryFullName)
+ } else {
+ clearCurrentSelectedRepository?.()
}
+ await refreshLocalContextOptions()
+ prDrawerController.resetStatus?.()
+ prDrawerController.syncRepositories()
+ return true
+ } catch {
+ workspacesDrawerController?.setStatus('Could not create workspace.', 'error')
+ return false
+ }
+ },
+ onOpenSelected: async workspaceId => {
+ try {
const record = await workspaceStorage.getWorkspaceById(workspaceId)
if (!record) {
await refreshLocalContextOptions()
workspacesDrawerController?.setStatus(
- 'Stored local context no longer exists.',
+ 'Stored workspace no longer exists.',
'error',
)
return false
@@ -381,7 +494,7 @@ const initializeGitHubWorkflows = ({
return applied
} catch {
workspacesDrawerController?.setStatus(
- 'Could not load selected local context.',
+ 'Could not load selected workspace.',
'error',
)
return false
@@ -389,7 +502,7 @@ const initializeGitHubWorkflows = ({
},
onRemoveSelected: async workspaceId => {
confirmAction({
- title: 'Remove stored local context?',
+ title: 'Remove stored workspace?',
copy: 'This removes only local workspace metadata and editor content from this browser.',
confirmButtonText: 'Remove',
onConfirm: () => {
@@ -403,13 +516,13 @@ const initializeGitHubWorkflows = ({
await refreshLocalContextOptions()
workspacesDrawerController?.setStatus(
- 'Removed stored local context.',
+ 'Removed stored workspace.',
'neutral',
)
})
.catch(() => {
workspacesDrawerController?.setStatus(
- 'Could not remove stored local context.',
+ 'Could not remove stored workspace.',
'error',
)
})
@@ -474,36 +587,6 @@ const initializeGitHubWorkflows = ({
})
})
- githubPrContextDisconnect?.addEventListener('click', () => {
- if (!githubAiContextState.activePrContext) {
- return
- }
-
- const activePrReference = formatActivePrReference(
- githubAiContextState.activePrContext,
- )
- const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : ''
-
- confirmAction({
- title: 'Disconnect PR context?',
- copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`,
- confirmButtonText: 'Disconnect',
- onConfirm: () => {
- const result = prDrawerController.disconnectActivePrContext()
- const reference = result?.reference
- setStatus(
- reference
- ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.`
- : 'Disconnected PR context. Pull request remains open on GitHub.',
- 'neutral',
- )
- if (typeof onPrContextDisconnected === 'function') {
- onPrContextDisconnected(result)
- }
- },
- })
- })
-
return {
chatDrawerController,
prDrawerController,
diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js
index e193d44..3529438 100644
--- a/src/modules/app-core/workspace-context-controller.js
+++ b/src/modules/app-core/workspace-context-controller.js
@@ -7,6 +7,8 @@ const createWorkspaceContextController = ({
setActiveWorkspaceCreatedAt,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
+ cancelPendingWorkspaceSave,
setIsApplyingWorkspaceSnapshot,
ensureWorkspaceTabsShape,
githubPrBaseBranch,
@@ -27,6 +29,7 @@ const createWorkspaceContextController = ({
maybeRender,
setStatus,
toWorkspaceRecordKey,
+ beginWorkspaceLoadTransaction,
getHeadBranchValue,
}) => {
const toWorkspacePrContextState = value =>
@@ -64,12 +67,15 @@ const createWorkspaceContextController = ({
return false
}
+ if (typeof beginWorkspaceLoadTransaction === 'function') {
+ beginWorkspaceLoadTransaction()
+ }
setIsApplyingWorkspaceSnapshot(true)
+ if (typeof cancelPendingWorkspaceSave === 'function') {
+ cancelPendingWorkspaceSave()
+ }
try {
- setActiveWorkspaceRecordId(workspace.id)
- setActiveWorkspaceCreatedAt(workspace.createdAt ?? null)
-
if (typeof setWorkspacePrContextState === 'function') {
const nextPrContextState =
typeof workspace.prContextState === 'string' && workspace.prContextState.trim()
@@ -86,6 +92,15 @@ const createWorkspaceContextController = ({
setWorkspacePrNumber(nextPrNumber)
}
+ if (typeof setWorkspaceScopeMarker === 'function') {
+ const nextScope =
+ typeof workspace.workspaceScope === 'string' &&
+ workspace.workspaceScope.trim().toLowerCase() === 'repository'
+ ? 'repository'
+ : 'local'
+ setWorkspaceScopeMarker(nextScope)
+ }
+
const nextTabs = ensureWorkspaceTabsShape(workspace.tabs)
if (typeof workspace.base === 'string' && githubPrBaseBranch) {
githubPrBaseBranch.value = workspace.base
@@ -99,6 +114,9 @@ const createWorkspaceContextController = ({
githubPrTitle.value = workspace.prTitle
}
+ setActiveWorkspaceRecordId(workspace.id)
+ setActiveWorkspaceCreatedAt(workspace.createdAt ?? null)
+
workspaceTabsState.replaceTabs({
tabs: nextTabs,
activeTabId: resolveWorkspaceActiveTabId({
@@ -136,16 +154,23 @@ const createWorkspaceContextController = ({
return true
} finally {
+ await new Promise(resolve => {
+ setTimeout(resolve, 0)
+ })
setIsApplyingWorkspaceSnapshot(false)
}
}
const loadPreferredWorkspaceContext = async () => {
const selectedRepository = getCurrentSelectedRepository()
- const options = await listLocalContextRecords({
+ let options = await listLocalContextRecords({
includeAllRepositories: !selectedRepository,
})
+ if (selectedRepository && (!Array.isArray(options) || options.length === 0)) {
+ options = await listLocalContextRecords({ includeAllRepositories: true })
+ }
+
await refreshLocalContextOptions({ includeAllRepositories: true })
if (!Array.isArray(options) || options.length === 0) {
diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js
index 587ddf5..53e0ce1 100644
--- a/src/modules/app-core/workspace-controllers-setup.js
+++ b/src/modules/app-core/workspace-controllers-setup.js
@@ -17,6 +17,7 @@ const createWorkspaceControllersSetup = ({
setActiveWorkspaceCreatedAt,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
getCurrentSelectedRepository,
getActiveWorkspaceRecordId,
setIsApplyingWorkspaceSnapshot,
@@ -76,6 +77,14 @@ const createWorkspaceControllersSetup = ({
}) => {
let workspaceTabsRenderer = null
let workspaceTabMutationsController = null
+ let activeWorkspaceLoadTransactionId = 0
+
+ const beginWorkspaceLoadTransaction = () => {
+ activeWorkspaceLoadTransactionId += 1
+ return activeWorkspaceLoadTransactionId
+ }
+
+ const getActiveWorkspaceLoadTransactionId = () => activeWorkspaceLoadTransactionId
const renderWorkspaceTabs = () => workspaceTabsRenderer.renderWorkspaceTabs()
@@ -95,6 +104,7 @@ const createWorkspaceControllersSetup = ({
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt,
getHasCompletedInitialWorkspaceBootstrap,
+ getActiveWorkspaceLoadTransactionId,
})
const queueWorkspaceSave = options =>
@@ -103,6 +113,9 @@ const createWorkspaceControllersSetup = ({
const flushWorkspaceSave = async options =>
workspaceSaveController.flushWorkspaceSave(options)
+ const cancelPendingWorkspaceSave = () =>
+ workspaceSaveController.cancelPendingWorkspaceSave()
+
const workspaceTabSelectionController = createWorkspaceTabSelectionController({
toNonEmptyWorkspaceText,
workspaceTabsState,
@@ -201,6 +214,8 @@ const createWorkspaceControllersSetup = ({
setActiveWorkspaceCreatedAt,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
+ cancelPendingWorkspaceSave,
setIsApplyingWorkspaceSnapshot,
ensureWorkspaceTabsShape,
githubPrBaseBranch,
@@ -221,6 +236,7 @@ const createWorkspaceControllersSetup = ({
maybeRender: () => maybeRender(),
setStatus,
toWorkspaceRecordKey,
+ beginWorkspaceLoadTransaction,
getHeadBranchValue: () =>
typeof githubPrHeadBranch?.value === 'string'
? githubPrHeadBranch.value.trim()
@@ -248,6 +264,7 @@ const createWorkspaceControllersSetup = ({
applyWorkspaceRecord,
queueWorkspaceSave,
flushWorkspaceSave,
+ cancelPendingWorkspaceSave,
setActiveWorkspaceTab,
addWorkspaceTab,
renderWorkspaceTabs,
diff --git a/src/modules/app-core/workspace-editor-helpers.js b/src/modules/app-core/workspace-editor-helpers.js
index b59ee29..22b8935 100644
--- a/src/modules/app-core/workspace-editor-helpers.js
+++ b/src/modules/app-core/workspace-editor-helpers.js
@@ -1,3 +1,5 @@
+import { isTabEditedForDisplay } from './workspace-tab-edited-display.js'
+
const createWorkspaceEditorHelpers = ({
workspaceTabsState,
getTabKind,
@@ -59,7 +61,7 @@ const createWorkspaceEditorHelpers = ({
typeof getShouldShowEditedDesign === 'function'
? Boolean(getShouldShowEditedDesign())
: true
- const isDirty = shouldShowEditedDesign && Boolean(tab?.isDirty)
+ const isDirty = shouldShowEditedDesign && isTabEditedForDisplay(tab)
dirtyStatusLabel.hidden = !isDirty
if (isDirty) {
dirtyStatusLabel.removeAttribute('aria-hidden')
@@ -149,19 +151,29 @@ const createWorkspaceEditorHelpers = ({
if (getTabKind(tab) === 'styles') {
setLoadedStylesTabId(tab.id)
- setCssSource(nextContent)
- applyStyleLanguage(tab.language)
+ setSuppressEditorChangeSideEffects(true)
+ try {
+ setCssSource(nextContent)
+ applyStyleLanguage(tab.language)
+ } finally {
+ setSuppressEditorChangeSideEffects(false)
+ }
setVisibleEditorPanelForKind('styles')
editorPool.activate('styles')
} else {
setLoadedComponentTabId(tab.id)
- setJsxSource(nextContent)
-
- const stylesTab =
- workspaceTabsState.getTab(getLoadedStylesTabId()) ??
- getWorkspaceTabByKind('styles')
- if (stylesTab) {
- applyStyleLanguage(stylesTab.language)
+ setSuppressEditorChangeSideEffects(true)
+ try {
+ setJsxSource(nextContent)
+
+ const stylesTab =
+ workspaceTabsState.getTab(getLoadedStylesTabId()) ??
+ getWorkspaceTabByKind('styles')
+ if (stylesTab) {
+ applyStyleLanguage(stylesTab.language)
+ }
+ } finally {
+ setSuppressEditorChangeSideEffects(false)
}
setVisibleEditorPanelForKind('component')
diff --git a/src/modules/app-core/workspace-pr-session-handoff-controller.js b/src/modules/app-core/workspace-pr-session-handoff-controller.js
index 27f5956..4e55b58 100644
--- a/src/modules/app-core/workspace-pr-session-handoff-controller.js
+++ b/src/modules/app-core/workspace-pr-session-handoff-controller.js
@@ -12,6 +12,7 @@ export const createWorkspacePrSessionHandoffController = ({
getWorkspacePrNumber,
setWorkspacePrContextState,
setWorkspacePrNumber,
+ setWorkspaceScopeMarker,
getActiveWorkspaceCreatedAt,
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt,
@@ -80,6 +81,9 @@ export const createWorkspacePrSessionHandoffController = ({
setWorkspacePrContextState('inactive')
setWorkspacePrNumber(null)
+ if (typeof setWorkspaceScopeMarker === 'function') {
+ setWorkspaceScopeMarker('local')
+ }
lastKnownPrContextMeta = null
if (githubPrHeadBranch) {
@@ -134,6 +138,7 @@ export const createWorkspacePrSessionHandoffController = ({
const saved = await workspaceStorage.upsertWorkspace({
...buildWorkspaceRecordSnapshot({ recordId: localWorkspaceId }),
id: localWorkspaceId,
+ workspaceScope: 'local',
workspaceKey: toWorkspaceRecordKey({
repositoryFullName: selectedRepository,
headBranch: freshLocalHeadBranch,
@@ -288,6 +293,8 @@ export const createWorkspacePrSessionHandoffController = ({
lastModified: now,
}
+ archiveSnapshot.workspaceScope = archiveSnapshot.repo ? 'repository' : 'local'
+
archiveSnapshot.workspaceKey = toWorkspaceRecordKey({
repositoryFullName: archiveSnapshot.repo,
headBranch: archiveSnapshot.head,
diff --git a/src/modules/app-core/workspace-save-controller.js b/src/modules/app-core/workspace-save-controller.js
index 768ab81..14eb99d 100644
--- a/src/modules/app-core/workspace-save-controller.js
+++ b/src/modules/app-core/workspace-save-controller.js
@@ -11,15 +11,146 @@ const createWorkspaceSaveController = ({
setActiveWorkspaceRecordId,
setActiveWorkspaceCreatedAt,
getHasCompletedInitialWorkspaceBootstrap,
+ getActiveWorkspaceLoadTransactionId,
}) => {
+ const getCurrentWorkspaceLoadTransactionId = () =>
+ typeof getActiveWorkspaceLoadTransactionId === 'function'
+ ? getActiveWorkspaceLoadTransactionId()
+ : 0
+
+ const canPersistWorkspaceState = () => {
+ if (getIsApplyingWorkspaceSnapshot()) {
+ return false
+ }
+
+ if (
+ typeof getHasCompletedInitialWorkspaceBootstrap === 'function' &&
+ !getHasCompletedInitialWorkspaceBootstrap()
+ ) {
+ return false
+ }
+
+ return true
+ }
+
+ const isStaleSavePayload = payload => {
+ if (!payload || typeof payload !== 'object') {
+ return true
+ }
+
+ if (!canPersistWorkspaceState()) {
+ return true
+ }
+
+ const payloadTransactionId =
+ typeof payload.loadTransactionId === 'number' &&
+ Number.isFinite(payload.loadTransactionId)
+ ? payload.loadTransactionId
+ : -1
+
+ if (payloadTransactionId !== getCurrentWorkspaceLoadTransactionId()) {
+ return true
+ }
+
+ const payloadRecordId = toNonEmptyWorkspaceText(payload.id)
+ const currentActiveRecordId =
+ typeof getActiveWorkspaceRecordId === 'function'
+ ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
+ : ''
+
+ if (
+ payloadRecordId &&
+ currentActiveRecordId &&
+ payloadRecordId !== currentActiveRecordId
+ ) {
+ return true
+ }
+
+ return false
+ }
+
+ const buildSaveSnapshot = ({
+ preserveRecordId = false,
+ allowDuplicateWorkspaceKey = false,
+ } = {}) => {
+ const activeRecordId =
+ preserveRecordId && typeof getActiveWorkspaceRecordId === 'function'
+ ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
+ : ''
+ const snapshot = activeRecordId
+ ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId })
+ : buildWorkspaceRecordSnapshot()
+
+ if (allowDuplicateWorkspaceKey) {
+ snapshot.allowDuplicateWorkspaceKey = true
+ }
+
+ snapshot.loadTransactionId = getCurrentWorkspaceLoadTransactionId()
+ return snapshot
+ }
+
const workspaceSaver = createDebouncedWorkspaceSaver({
save: async payload => {
- const saved = await workspaceStorage.upsertWorkspace(payload)
+ if (isStaleSavePayload(payload)) {
+ return null
+ }
+
+ const payloadRecordId = toNonEmptyWorkspaceText(payload?.id)
+ if (payloadRecordId) {
+ const existingRecord = await workspaceStorage.getWorkspaceById(payloadRecordId)
+ if (existingRecord && typeof existingRecord === 'object') {
+ const existingWorkspaceKey = toNonEmptyWorkspaceText(
+ existingRecord.workspaceKey,
+ )
+ const payloadWorkspaceKey = toNonEmptyWorkspaceText(payload?.workspaceKey)
+ if (
+ existingWorkspaceKey &&
+ payloadWorkspaceKey &&
+ existingWorkspaceKey !== payloadWorkspaceKey
+ ) {
+ const existingWorkspaceScope =
+ toNonEmptyWorkspaceText(existingRecord.workspaceScope).toLowerCase() ||
+ 'local'
+ const payloadWorkspaceScope =
+ toNonEmptyWorkspaceText(payload?.workspaceScope).toLowerCase() || 'local'
+ const existingRepository = toNonEmptyWorkspaceText(existingRecord.repo)
+ const payloadRepository = toNonEmptyWorkspaceText(payload?.repo)
+
+ const isLocalToRepositoryRekey =
+ existingWorkspaceScope === 'local' &&
+ payloadWorkspaceScope === 'repository' &&
+ !existingRepository &&
+ Boolean(payloadRepository)
+
+ if (!isLocalToRepositoryRekey) {
+ return null
+ }
+ }
+ }
+ }
+
+ const { loadTransactionId: _loadTransactionId, ...persistablePayload } = payload
+
+ const allowDuplicateWorkspaceKey =
+ persistablePayload && typeof persistablePayload === 'object'
+ ? persistablePayload.allowDuplicateWorkspaceKey === true
+ : false
+ const saved = await workspaceStorage.upsertWorkspace(persistablePayload)
const normalizedSavedRepo = toNonEmptyWorkspaceText(saved.repo)
const normalizedSavedWorkspaceKey = toNonEmptyWorkspaceText(saved.workspaceKey)
+ const normalizedSavedPrContextState =
+ toNonEmptyWorkspaceText(saved.prContextState).toLowerCase() || 'inactive'
+ const hasSavedPrNumber =
+ typeof saved.prNumber === 'number' && Number.isFinite(saved.prNumber)
+ const isSavedInactiveWithoutPrNumber =
+ normalizedSavedPrContextState === 'inactive' && !hasSavedPrNumber
- if (normalizedSavedWorkspaceKey) {
+ if (
+ normalizedSavedWorkspaceKey &&
+ !allowDuplicateWorkspaceKey &&
+ !isSavedInactiveWithoutPrNumber
+ ) {
const siblingRecords = normalizedSavedRepo
? await workspaceStorage.listWorkspaces({ repo: normalizedSavedRepo })
: await workspaceStorage.listWorkspaces()
@@ -46,10 +177,7 @@ const createWorkspaceSaveController = ({
.filter(Boolean),
)
- const isSavedActiveContext =
- toNonEmptyWorkspaceText(saved.prContextState).toLowerCase() === 'active'
- const hasSavedPrNumber =
- typeof saved.prNumber === 'number' && Number.isFinite(saved.prNumber)
+ const isSavedActiveContext = normalizedSavedPrContextState === 'active'
if (isSavedActiveContext && hasSavedPrNumber && normalizedSavedRepo) {
for (const record of siblingRecords) {
@@ -86,20 +214,24 @@ const createWorkspaceSaveController = ({
)
}
- const supersededId = toNonEmptyWorkspaceText(payload?.supersededId)
+ const supersededId = toNonEmptyWorkspaceText(persistablePayload?.supersededId)
if (supersededId && supersededId !== toNonEmptyWorkspaceText(saved.id)) {
await workspaceStorage.removeWorkspace(supersededId)
}
+ if (isStaleSavePayload(payload)) {
+ return saved
+ }
+
const currentActiveRecordId =
typeof getActiveWorkspaceRecordId === 'function'
? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
: ''
- const payloadRecordId = toNonEmptyWorkspaceText(payload?.id)
+ const persistedPayloadRecordId = toNonEmptyWorkspaceText(persistablePayload?.id)
const savedRecordId = toNonEmptyWorkspaceText(saved.id)
const shouldAdoptSavedAsActive =
!currentActiveRecordId ||
- currentActiveRecordId === payloadRecordId ||
+ currentActiveRecordId === persistedPayloadRecordId ||
currentActiveRecordId === savedRecordId
if (shouldAdoptSavedAsActive) {
@@ -117,48 +249,28 @@ const createWorkspaceSaveController = ({
},
})
- const queueWorkspaceSave = ({ preserveRecordId = false } = {}) => {
- if (getIsApplyingWorkspaceSnapshot()) {
- return
- }
-
- if (
- typeof getHasCompletedInitialWorkspaceBootstrap === 'function' &&
- !getHasCompletedInitialWorkspaceBootstrap()
- ) {
+ const queueWorkspaceSave = ({
+ preserveRecordId = false,
+ allowDuplicateWorkspaceKey = false,
+ } = {}) => {
+ if (!canPersistWorkspaceState()) {
return
}
- const activeRecordId =
- preserveRecordId && typeof getActiveWorkspaceRecordId === 'function'
- ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
- : ''
- const snapshot = activeRecordId
- ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId })
- : buildWorkspaceRecordSnapshot()
+ const snapshot = buildSaveSnapshot({ preserveRecordId, allowDuplicateWorkspaceKey })
setActiveWorkspaceRecordId(snapshot.id)
workspaceSaver.queue(snapshot)
}
- const flushWorkspaceSave = async ({ preserveRecordId = false } = {}) => {
- if (getIsApplyingWorkspaceSnapshot()) {
- return
- }
-
- if (
- typeof getHasCompletedInitialWorkspaceBootstrap === 'function' &&
- !getHasCompletedInitialWorkspaceBootstrap()
- ) {
+ const flushWorkspaceSave = async ({
+ preserveRecordId = false,
+ allowDuplicateWorkspaceKey = false,
+ } = {}) => {
+ if (!canPersistWorkspaceState()) {
return
}
- const activeRecordId =
- preserveRecordId && typeof getActiveWorkspaceRecordId === 'function'
- ? toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
- : ''
- const snapshot = activeRecordId
- ? buildWorkspaceRecordSnapshot({ recordId: activeRecordId })
- : buildWorkspaceRecordSnapshot()
+ const snapshot = buildSaveSnapshot({ preserveRecordId, allowDuplicateWorkspaceKey })
setActiveWorkspaceRecordId(snapshot.id)
await workspaceSaver.flushNow(snapshot)
}
@@ -194,12 +306,17 @@ const createWorkspaceSaveController = ({
element.addEventListener('blur', flush)
}
+ const cancelPendingWorkspaceSave = () => {
+ workspaceSaver?.dispose()
+ }
+
const dispose = () => {
workspaceSaver?.dispose()
}
return {
bindWorkspaceMetadataPersistence,
+ cancelPendingWorkspaceSave,
dispose,
flushWorkspaceSave,
queueWorkspaceSave,
diff --git a/src/modules/app-core/workspace-scope-fork-actions.js b/src/modules/app-core/workspace-scope-fork-actions.js
new file mode 100644
index 0000000..78b69bb
--- /dev/null
+++ b/src/modules/app-core/workspace-scope-fork-actions.js
@@ -0,0 +1,183 @@
+const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local')
+
+const createForkedHeadBranchName = ({ currentHead, toNonEmptyWorkspaceText }) => {
+ const normalizedHead = toNonEmptyWorkspaceText(currentHead)
+ const baseHead = normalizedHead || `feat/component-${Date.now().toString(36)}`
+ const suffix = Math.random().toString(36).slice(2, 6)
+ return `${baseHead}-${suffix}`
+}
+
+export const createWorkspaceScopeForkActions = ({
+ toNonEmptyWorkspaceText,
+ workspaceStorage,
+ flushWorkspaceSave,
+ refreshLocalContextOptions,
+ createWorkspaceRecordId,
+ buildWorkspaceRecordSnapshot,
+ toWorkspaceRecordKey,
+ getWorkspacePrContextState,
+ setWorkspacePrContextState,
+ setWorkspacePrNumber,
+ getActiveWorkspaceRecordId,
+ setActiveWorkspaceRecordId,
+ setActiveWorkspaceCreatedAt,
+ getWorkspaceRepositoryFullName,
+ setWorkspaceRepositoryFullName,
+ setWorkspaceScopeMarker,
+ setHeadBranchValue,
+ setPrTitleValue,
+}) => {
+ const syncActiveWorkspaceRepositoryScope = async (
+ repositoryFullName,
+ { rekeyRecord = false } = {},
+ ) => {
+ if (
+ toNonEmptyWorkspaceText(getWorkspacePrContextState()).toLowerCase() !== 'inactive'
+ ) {
+ return
+ }
+
+ const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
+ if (!activeWorkspaceRecordId) {
+ return
+ }
+
+ if (rekeyRecord) {
+ await flushWorkspaceSave({ preserveRecordId: true })
+ setActiveWorkspaceRecordId('')
+ setActiveWorkspaceCreatedAt(null)
+ }
+
+ const normalizedRepositoryFullName = toNonEmptyWorkspaceText(repositoryFullName)
+ setWorkspaceRepositoryFullName(normalizedRepositoryFullName)
+ setWorkspaceScopeMarker(
+ toWorkspaceScopeMarker(normalizedRepositoryFullName ? 'repository' : 'local'),
+ )
+ await flushWorkspaceSave({ preserveRecordId: !rekeyRecord })
+ }
+
+ const setActiveWorkspaceScopeMarker = async nextScope => {
+ if (
+ toNonEmptyWorkspaceText(getWorkspacePrContextState()).toLowerCase() !== 'inactive'
+ ) {
+ return
+ }
+
+ const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
+ if (!activeWorkspaceRecordId) {
+ return
+ }
+
+ const normalizedScope = toWorkspaceScopeMarker(nextScope)
+
+ const activeRecord = await workspaceStorage.getWorkspaceById(activeWorkspaceRecordId)
+ if (!activeRecord) {
+ return
+ }
+
+ const hasPrNumber =
+ typeof activeRecord.prNumber === 'number' && Number.isFinite(activeRecord.prNumber)
+ if (hasPrNumber) {
+ return
+ }
+
+ const currentScope =
+ typeof activeRecord.workspaceScope === 'string'
+ ? activeRecord.workspaceScope.trim().toLowerCase()
+ : ''
+
+ if (currentScope === normalizedScope) {
+ return
+ }
+
+ setWorkspaceScopeMarker(normalizedScope)
+
+ await workspaceStorage.upsertWorkspace({
+ ...activeRecord,
+ workspaceScope: normalizedScope,
+ lastModified: Date.now(),
+ })
+
+ await refreshLocalContextOptions()
+ }
+
+ const forkWorkspaceFromCurrentState = async repositoryFullName => {
+ const normalizedTargetRepository = toNonEmptyWorkspaceText(repositoryFullName)
+ const activeWorkspaceRecordId = toNonEmptyWorkspaceText(getActiveWorkspaceRecordId())
+
+ if (activeWorkspaceRecordId) {
+ let sourceRepositoryFullName = toNonEmptyWorkspaceText(
+ getWorkspaceRepositoryFullName(),
+ )
+
+ try {
+ const activeWorkspaceRecord = await workspaceStorage.getWorkspaceById(
+ activeWorkspaceRecordId,
+ )
+ sourceRepositoryFullName = toNonEmptyWorkspaceText(activeWorkspaceRecord?.repo)
+ } catch {
+ /* Save path continues even if source record lookup fails. */
+ }
+
+ setWorkspaceRepositoryFullName(sourceRepositoryFullName)
+ await flushWorkspaceSave({
+ preserveRecordId: true,
+ allowDuplicateWorkspaceKey: true,
+ })
+ }
+
+ setWorkspaceRepositoryFullName(normalizedTargetRepository)
+ setWorkspaceScopeMarker(
+ toWorkspaceScopeMarker(normalizedTargetRepository ? 'repository' : 'local'),
+ )
+ setWorkspacePrContextState('inactive')
+ setWorkspacePrNumber(null)
+ if (typeof setPrTitleValue === 'function') {
+ setPrTitleValue('')
+ }
+
+ const now = Date.now()
+ const nextRecordId = createWorkspaceRecordId()
+ const snapshot = buildWorkspaceRecordSnapshot({ recordId: nextRecordId })
+ const forkedHeadBranch = createForkedHeadBranchName({
+ currentHead: snapshot.head,
+ toNonEmptyWorkspaceText,
+ })
+
+ setHeadBranchValue(forkedHeadBranch)
+
+ const saved = await workspaceStorage.upsertWorkspace({
+ ...snapshot,
+ id: nextRecordId,
+ supersededId: '',
+ workspaceScope: normalizedTargetRepository ? 'repository' : 'local',
+ workspaceKey: toWorkspaceRecordKey({
+ repositoryFullName: normalizedTargetRepository,
+ headBranch: forkedHeadBranch,
+ }),
+ repo: normalizedTargetRepository,
+ head: forkedHeadBranch,
+ prTitle: '',
+ prContextState: 'inactive',
+ prNumber: null,
+ createdAt: now,
+ lastModified: now,
+ })
+
+ const savedId = toNonEmptyWorkspaceText(saved?.id) || nextRecordId
+ const savedCreatedAt =
+ typeof saved?.createdAt === 'number' && Number.isFinite(saved.createdAt)
+ ? saved.createdAt
+ : now
+ setActiveWorkspaceRecordId(savedId)
+ setActiveWorkspaceCreatedAt(savedCreatedAt)
+
+ await refreshLocalContextOptions()
+ }
+
+ return {
+ forkWorkspaceFromCurrentState,
+ setActiveWorkspaceScopeMarker,
+ syncActiveWorkspaceRepositoryScope,
+ }
+}
diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js
index c0e1e18..aa1cce0 100644
--- a/src/modules/app-core/workspace-sync-controller.js
+++ b/src/modules/app-core/workspace-sync-controller.js
@@ -10,20 +10,28 @@ const createWorkspaceSyncController = ({
hasTabCommittedSyncState,
getJsxSource,
getCssSource,
- getWorkspaceTabByKind,
queueWorkspaceSave,
resolveWorkspaceRecordIdentity,
getWorkspaceContextSnapshot,
+ getWorkspaceScopeMarker,
getActiveWorkspaceRecordId,
getActiveWorkspaceCreatedAt,
getRenderModeValue,
normalizeRenderMode,
}) => {
+ const resolveCanonicalDirtyState = ({ tab, content }) => {
+ const syncedContent = toWorkspaceSyncedContent(tab?.syncedContent)
+ if (syncedContent !== null) {
+ return content !== syncedContent
+ }
+
+ return Boolean(tab?.isDirty)
+ }
+
const buildWorkspaceTabsSnapshot = () => {
const activeTabId = workspaceTabsState.getActiveTabId()
return workspaceTabsState.getTabs().map(tab => {
const currentPath = toNonEmptyWorkspaceText(tab.path)
- const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles'
const currentContent =
tab.id === activeTabId
@@ -35,15 +43,18 @@ const createWorkspaceSyncController = ({
: ''
const normalizedPath = normalizeWorkspacePathValue(currentPath)
- const targetPrFilePath = isPrimaryEditorTab
- ? normalizedPath || null
- : normalizedPath || getTabTargetPrFilePath(tab) || null
+ const targetPrFilePath = normalizedPath || getTabTargetPrFilePath(tab) || null
+ const canonicalDirtyState = resolveCanonicalDirtyState({
+ tab,
+ content: currentContent,
+ })
return {
...tab,
path: currentPath,
content: currentContent,
syncedContent: toWorkspaceSyncedContent(tab?.syncedContent),
+ isDirty: canonicalDirtyState,
targetPrFilePath,
isActive: activeTabId === tab.id,
lastModified: Date.now(),
@@ -75,11 +86,8 @@ const createWorkspaceSyncController = ({
let updatedTabCount = 0
const activeTabId = workspaceTabsState.getActiveTabId()
const nextTabs = workspaceTabsState.getTabs().map(tab => {
- const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles'
const normalizedPath = normalizeWorkspacePathValue(tab.path)
- const candidatePaths = isPrimaryEditorTab
- ? [normalizedPath, getTabTargetPrFilePath(tab)].filter(Boolean)
- : [normalizedPath].filter(Boolean)
+ const candidatePaths = [normalizedPath, getTabTargetPrFilePath(tab)].filter(Boolean)
const matchedPath = candidatePaths.find(path => updatesByPath.has(path))
if (!matchedPath) {
@@ -91,7 +99,7 @@ const createWorkspaceSyncController = ({
return {
...tab,
- targetPrFilePath: normalizedPath || (isPrimaryEditorTab ? matchedPath : null),
+ targetPrFilePath: normalizedPath || matchedPath,
syncedContent: typeof tab?.content === 'string' ? tab.content : '',
isDirty: false,
syncedAt: now,
@@ -121,12 +129,6 @@ const createWorkspaceSyncController = ({
const currentPaths = new Set(
snapshotTabs.map(tab => normalizeWorkspacePathValue(tab?.path)).filter(Boolean),
)
- const primaryTabPaths = new Set(
- snapshotTabs
- .filter(tab => tab?.id === 'component' || tab?.id === 'styles')
- .map(tab => normalizeWorkspacePathValue(tab?.path))
- .filter(Boolean),
- )
for (const tab of snapshotTabs) {
const shouldCommitTab = includeAllWorkspaceFiles
@@ -136,17 +138,12 @@ const createWorkspaceSyncController = ({
continue
}
- const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles'
const normalizedPath = normalizeWorkspacePathValue(tab?.path)
const path = normalizedPath || getTabTargetPrFilePath(tab) || ''
if (!path) {
continue
}
- if (!isPrimaryEditorTab && primaryTabPaths.has(path)) {
- continue
- }
-
dedupedByPath.set(path, {
path,
content: typeof tab?.content === 'string' ? tab.content : '',
@@ -163,6 +160,7 @@ const createWorkspaceSyncController = ({
previousPath !== normalizedPath
if (
+ !includeAllWorkspaceFiles &&
isCommittedRename &&
!currentPaths.has(previousPath) &&
!dedupedByPath.has(previousPath)
@@ -182,78 +180,69 @@ const createWorkspaceSyncController = ({
}
const getEditorSyncTargets = () => {
- const tabTargets = []
- const primaryTabIdByKind = {
- component: 'component',
- styles: 'styles',
- }
+ const dedupedByPath = new Map()
+ const snapshotTabs = buildWorkspaceTabsSnapshot()
- for (const kind of ['component', 'styles']) {
- const primaryTabId = primaryTabIdByKind[kind]
- const tab = workspaceTabsState.getTab(primaryTabId) ?? getWorkspaceTabByKind(kind)
+ for (const tab of snapshotTabs) {
const path =
normalizeWorkspacePathValue(tab?.path) || getTabTargetPrFilePath(tab) || ''
-
if (!path) {
continue
}
- tabTargets.push({ kind, path })
+ dedupedByPath.set(path, {
+ path,
+ kind: getTabKind(tab),
+ tabId: toNonEmptyWorkspaceText(tab?.id),
+ })
}
+ const tabTargets = [...dedupedByPath.values()]
+
return { tabTargets }
}
const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => {
- const targetsByKind = new Map()
+ const targetContentByPath = new Map()
const normalizedTargets = Array.isArray(tabTargets) ? tabTargets : []
for (const target of normalizedTargets) {
- const kind = toNonEmptyWorkspaceText(target?.kind)
const normalizedPath = normalizeWorkspacePathValue(target?.path)
- if (!kind || !normalizedPath) {
+ const content = typeof target?.content === 'string' ? target.content : null
+ if (!normalizedPath || content === null) {
continue
}
- targetsByKind.set(kind, normalizedPath)
+ targetContentByPath.set(normalizedPath, content)
}
- if (targetsByKind.size === 0) {
+ if (targetContentByPath.size === 0) {
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 isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles'
- if (!isPrimaryEditorTab) {
- return tab
- }
-
- const tabKind = getTabKind(tab)
- const expectedPath = targetsByKind.get(tabKind)
- if (!expectedPath) {
- return tab
- }
-
const candidatePaths = [
normalizeWorkspacePathValue(tab.path),
getTabTargetPrFilePath(tab),
].filter(Boolean)
- const matchedPath = candidatePaths.find(path => path === expectedPath)
+ const matchedPath = candidatePaths.find(path => targetContentByPath.has(path))
if (!matchedPath) {
return tab
}
- const syncedContent = tabKind === 'styles' ? stylesSource : componentSource
+ const syncedContent = targetContentByPath.get(matchedPath)
+ if (typeof syncedContent !== 'string') {
+ return tab
+ }
+
updatedTabCount += 1
return {
...tab,
- targetPrFilePath: expectedPath,
+ targetPrFilePath: matchedPath,
content: syncedContent,
syncedContent,
isDirty: false,
@@ -292,9 +281,19 @@ const createWorkspaceSyncController = ({
? context.prContextState.trim()
: 'inactive'
+ const requestedWorkspaceScope =
+ typeof getWorkspaceScopeMarker === 'function'
+ ? getWorkspaceScopeMarker()
+ : context.repositoryFullName
+ ? 'repository'
+ : 'local'
+ const normalizedWorkspaceScope =
+ requestedWorkspaceScope === 'repository' ? 'repository' : 'local'
+
return {
id: identity.id,
supersededId: identity.supersededId,
+ workspaceScope: normalizedWorkspaceScope,
workspaceKey: toWorkspaceRecordKey({
repositoryFullName: context.repositoryFullName,
headBranch: context.headBranch,
diff --git a/src/modules/app-core/workspace-tab-edited-display.js b/src/modules/app-core/workspace-tab-edited-display.js
new file mode 100644
index 0000000..b2e628b
--- /dev/null
+++ b/src/modules/app-core/workspace-tab-edited-display.js
@@ -0,0 +1,21 @@
+const isTabEditedForDisplay = tab => {
+ if (!tab || typeof tab !== 'object' || tab.isDirty !== true) {
+ return false
+ }
+
+ const nextContent = typeof tab.content === 'string' ? tab.content : ''
+ const syncedContent = typeof tab.syncedContent === 'string' ? tab.syncedContent : null
+
+ if (syncedContent !== null) {
+ return nextContent !== syncedContent
+ }
+
+ const hasSyncTimestamp =
+ typeof tab.syncedAt === 'number' && Number.isFinite(tab.syncedAt) && tab.syncedAt > 0
+ const hasSyncSha =
+ typeof tab.lastSyncedRemoteSha === 'string' && tab.lastSyncedRemoteSha.trim()
+
+ return Boolean(hasSyncTimestamp || hasSyncSha)
+}
+
+export { isTabEditedForDisplay }
diff --git a/src/modules/app-core/workspace-tabs-renderer.js b/src/modules/app-core/workspace-tabs-renderer.js
index 3e8d59c..bf736c1 100644
--- a/src/modules/app-core/workspace-tabs-renderer.js
+++ b/src/modules/app-core/workspace-tabs-renderer.js
@@ -1,3 +1,5 @@
+import { isTabEditedForDisplay } from './workspace-tab-edited-display.js'
+
const createWorkspaceTabsRenderer = ({
workspaceTabsStrip,
workspaceTabsState,
@@ -53,7 +55,7 @@ const createWorkspaceTabsRenderer = ({
for (const tab of tabs) {
const isActive = tab.id === activeTabId
const isRenaming = getWorkspaceTabRenameState().tabId === tab.id
- const isEdited = shouldShowEditedDesign && tab.isDirty
+ const isEdited = shouldShowEditedDesign && isTabEditedForDisplay(tab)
const editedSuffix = isEdited ? ' (Edited)' : ''
const tabContainer = document.createElement('li')
tabContainer.className = 'workspace-tab'
@@ -235,7 +237,7 @@ const createWorkspaceTabsRenderer = ({
tabContainer.append(metaBadge)
}
- if (shouldShowEditedDesign && tab.isDirty) {
+ if (shouldShowEditedDesign && isTabEditedForDisplay(tab)) {
const dirtyBadge = document.createElement('span')
dirtyBadge.className = 'workspace-tab__dirty-indicator'
dirtyBadge.setAttribute('aria-hidden', 'true')
diff --git a/src/modules/constants.js b/src/modules/constants.js
deleted file mode 100644
index 624e03a..0000000
--- a/src/modules/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export const repositoryStarterSelectionIdPrefix = '__create_repository_context__:'
diff --git a/src/modules/github/api/editor-content.js b/src/modules/github/api/editor-content.js
index add2b1e..8c053ca 100644
--- a/src/modules/github/api/editor-content.js
+++ b/src/modules/github/api/editor-content.js
@@ -1,5 +1,9 @@
import { buildRepoApiUrl, requestGitHubJson } from './core.js'
-import { createBranchReference, getBranchReferenceSha } from './repository-files.js'
+import {
+ createBranchReference,
+ getBranchReferenceSha,
+ getRepositoryFileMetadata,
+} from './repository-files.js'
import { createRepositoryPullRequest } from './pull-requests.js'
const normalizeFileUpdatePath = value =>
@@ -138,6 +142,14 @@ const createRepositoryTree = async ({
return treeSha
}
+const isBadObjectStateError = error => {
+ if (!(error instanceof Error)) {
+ return false
+ }
+
+ return error.message.toLowerCase().includes('badobjectstate')
+}
+
const createRepositoryCommit = async ({
token,
owner,
@@ -209,14 +221,75 @@ const commitFilesToExistingBranchWithGitDatabaseApi = async ({
commitSha: headCommitSha,
signal,
})
- const treeSha = await createRepositoryTree({
- token,
- owner,
- repo,
- baseTreeSha,
- files: uniqueFiles,
- signal,
- })
+ const hasDeleteEntries = uniqueFiles.some(file => file.deleted === true)
+
+ let committedFiles = uniqueFiles
+ let treeSha
+
+ if (hasDeleteEntries) {
+ const deleteCandidates = committedFiles.filter(file => file.deleted === true)
+ const existingDeletePaths = new Set(
+ (
+ await Promise.all(
+ deleteCandidates.map(async file => {
+ const existingFile = await getRepositoryFileMetadata({
+ token,
+ owner,
+ repo,
+ path: file.path,
+ ref: branch,
+ signal,
+ })
+
+ return existingFile?.sha ? file.path : null
+ }),
+ )
+ ).filter(Boolean),
+ )
+
+ committedFiles = committedFiles.filter(file => {
+ if (file.deleted !== true) {
+ return true
+ }
+
+ return existingDeletePaths.has(file.path)
+ })
+
+ if (committedFiles.length === 0) {
+ return []
+ }
+ }
+
+ try {
+ treeSha = await createRepositoryTree({
+ token,
+ owner,
+ repo,
+ baseTreeSha,
+ files: committedFiles,
+ signal,
+ })
+ } catch (error) {
+ if (!hasDeleteEntries || !isBadObjectStateError(error)) {
+ throw error
+ }
+
+ const nonDeleteFiles = committedFiles.filter(file => file.deleted !== true)
+ if (nonDeleteFiles.length === 0) {
+ throw error
+ }
+
+ committedFiles = nonDeleteFiles
+ treeSha = await createRepositoryTree({
+ token,
+ owner,
+ repo,
+ baseTreeSha,
+ files: committedFiles,
+ signal,
+ })
+ }
+
const commitSha = await createRepositoryCommit({
token,
owner,
@@ -235,7 +308,7 @@ const commitFilesToExistingBranchWithGitDatabaseApi = async ({
signal,
})
- return uniqueFiles.map(file => ({
+ return committedFiles.map(file => ({
path: file.path,
commitSha,
created: null,
@@ -260,43 +333,28 @@ const createUniqueBranchReference = async ({
headBranch,
baseSha,
signal,
- attempt = 0,
}) => {
- const candidateBranch = attempt === 0 ? headBranch : `${headBranch}-${attempt + 1}`
-
try {
await createBranchReference({
token,
owner,
repo,
- branch: candidateBranch,
+ branch: headBranch,
sha: baseSha,
signal,
})
- return candidateBranch
+ return headBranch
} catch (error) {
if (!isReferenceAlreadyExistsError(error)) {
throw error
}
- if (attempt >= 4) {
- throw new Error(
- `Branch ${headBranch} already exists. Choose another branch name and retry.`,
- {
- cause: error,
- },
- )
- }
-
- return createUniqueBranchReference({
- token,
- owner,
- repo,
- headBranch,
- baseSha,
- signal,
- attempt: attempt + 1,
- })
+ throw new Error(
+ `Branch ${headBranch} already exists. Choose another branch name and retry.`,
+ {
+ cause: error,
+ },
+ )
}
}
diff --git a/src/modules/github/pr/drawer/common.js b/src/modules/github/pr/drawer/common.js
index 4578483..3bc542c 100644
--- a/src/modules/github/pr/drawer/common.js
+++ b/src/modules/github/pr/drawer/common.js
@@ -2,7 +2,7 @@ const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop'
const supportedRenderModes = new Set(['dom', 'react'])
const supportedStyleModes = new Set(['css', 'module', 'less', 'sass'])
-const supportedPrContextStates = new Set(['inactive', 'active', 'disconnected', 'closed'])
+const supportedPrContextStates = new Set(['inactive', 'active', 'closed'])
const toSafeText = value => (typeof value === 'string' ? value.trim() : '')
@@ -26,6 +26,10 @@ const normalizeStyleMode = value => {
const normalizePrContextState = value => {
const state = toSafeText(value).toLowerCase()
+ if (state === 'disconnected') {
+ return 'inactive'
+ }
+
return supportedPrContextStates.has(state) ? state : 'inactive'
}
diff --git a/src/modules/github/pr/drawer/controller/context-sync.js b/src/modules/github/pr/drawer/controller/context-sync.js
index 8124330..285957d 100644
--- a/src/modules/github/pr/drawer/controller/context-sync.js
+++ b/src/modules/github/pr/drawer/controller/context-sync.js
@@ -51,24 +51,32 @@ export const createContextSyncHandlers = ({
const tabSyncTargets = Array.isArray(syncTargets?.tabTargets)
? syncTargets.tabTargets
: []
- const componentSyncPath = toSafeText(
- tabSyncTargets.find(target => toSafeText(target?.kind) === 'component')?.path,
- )
- const stylesSyncPath = toSafeText(
- tabSyncTargets.find(target => toSafeText(target?.kind) === 'styles')?.path,
- )
-
- if (!componentSyncPath || !stylesSyncPath) {
+ const dedupedByPath = new Map()
+
+ for (const target of tabSyncTargets) {
+ const kind = toSafeText(target?.kind)
+ const path = toSafeText(target?.path)
+ if (!path) {
+ continue
+ }
+
+ dedupedByPath.set(path, { kind, path })
+ }
+
+ const normalizedTabSyncTargets = [...dedupedByPath.values()]
+
+ if (normalizedTabSyncTargets.length === 0) {
state.lastActiveContentSyncKey = ''
abortPendingActiveContentSyncRequest()
return
}
+ normalizedTabSyncTargets.sort((left, right) => left.path.localeCompare(right.path))
+
const syncKey = [
repositoryFullName,
activeContext.headBranch,
- componentSyncPath,
- stylesSyncPath,
+ ...normalizedTabSyncTargets.map(target => target.path),
String(activeContext.pullRequestNumber ?? ''),
].join('|')
@@ -94,10 +102,7 @@ export const createContextSyncHandlers = ({
repository,
activeContext,
syncTargets: {
- tabTargets: [
- { kind: 'component', path: componentSyncPath },
- { kind: 'styles', path: stylesSyncPath },
- ],
+ tabTargets: normalizedTabSyncTargets,
},
signal: abortController.signal,
})
diff --git a/src/modules/github/pr/drawer/controller/create-controller.js b/src/modules/github/pr/drawer/controller/create-controller.js
index 698e374..5fec1ed 100644
--- a/src/modules/github/pr/drawer/controller/create-controller.js
+++ b/src/modules/github/pr/drawer/controller/create-controller.js
@@ -52,6 +52,7 @@ export const createGitHubPrDrawer = ({
getWritableRepositories,
getFileCommits,
getEditorSyncTargets,
+ persistWorkspaceMetadataOnSubmit,
getTopLevelDeclarations,
getRenderMode,
getStyleMode,
@@ -79,6 +80,7 @@ export const createGitHubPrDrawer = ({
pendingContextVerifyRequestKey: '',
pendingContextVerifyPromise: null,
lastSyncedRepositoryFullName: '',
+ lastSyncedActivePrContextKey: '',
lastActiveContentSyncKey: '',
baseBranchesByRepository: new Map(),
activePrContextByRepository: new Map(),
@@ -254,6 +256,7 @@ export const createGitHubPrDrawer = ({
prTitleInput,
includeAppWrapperToggle,
getFileCommits,
+ persistWorkspaceMetadataOnSubmit,
getTopLevelDeclarations,
confirmBeforeSubmit,
onPullRequestOpened,
@@ -334,6 +337,9 @@ export const createGitHubPrDrawer = ({
return false
}
+ contextHandlers.abortPendingContextVerifyRequest()
+ contextHandlers.abortPendingActiveContentSyncRequest()
+
setRepositoryActivePrContext({
repositoryFullName: targetRepositoryFullName,
activeContext,
diff --git a/src/modules/github/pr/drawer/controller/public-actions.js b/src/modules/github/pr/drawer/controller/public-actions.js
index 9daca47..acca1b7 100644
--- a/src/modules/github/pr/drawer/controller/public-actions.js
+++ b/src/modules/github/pr/drawer/controller/public-actions.js
@@ -44,25 +44,6 @@ export const createPublicActions = ({
}
return {
- disconnectActivePrContext: () => {
- const repository = getSelectedRepositoryObject()
- const repositoryFullName = getRepositoryFullName(repository)
- if (!repositoryFullName) {
- return { reference: '' }
- }
-
- const activeContext = getCurrentActivePrContext()
- clearSelectedRepositoryActivePrContext()
-
- return {
- reference: formatActivePrReference(activeContext),
- pullRequestNumber:
- typeof activeContext?.pullRequestNumber === 'number' &&
- Number.isFinite(activeContext.pullRequestNumber)
- ? activeContext.pullRequestNumber
- : null,
- }
- },
clearActivePrContext: () => {
clearSelectedRepositoryActivePrContext({ resetForm: true })
},
diff --git a/src/modules/github/pr/drawer/controller/repository-form.js b/src/modules/github/pr/drawer/controller/repository-form.js
index 95ae787..365f31a 100644
--- a/src/modules/github/pr/drawer/controller/repository-form.js
+++ b/src/modules/github/pr/drawer/controller/repository-form.js
@@ -244,6 +244,22 @@ export const createRepositoryFormHandlers = ({
Boolean(repositoryFullName) &&
repositoryFullName !== state.lastSyncedRepositoryFullName
const activeContext = getCurrentActivePrContext()
+ const activeContextSyncKey = activeContext
+ ? [
+ toSafeText(activeContext.baseBranch),
+ sanitizeBranchPart(activeContext.headBranch),
+ toSafeText(activeContext.prTitle),
+ String(
+ typeof activeContext.pullRequestNumber === 'number' &&
+ Number.isFinite(activeContext.pullRequestNumber)
+ ? activeContext.pullRequestNumber
+ : '',
+ ),
+ toSafeText(activeContext.pullRequestUrl),
+ ].join('|')
+ : ''
+ const activeContextChanged =
+ activeContextSyncKey !== state.lastSyncedActivePrContextKey
const baseBranch =
toSafeText(activeContext?.baseBranch) ||
@@ -257,7 +273,13 @@ export const createRepositoryFormHandlers = ({
const currentHeadBranch = toSafeText(headBranchInput.value)
if (activeHeadBranch) {
- if (resetAll || resetBranch || repositoryChanged || !currentHeadBranch) {
+ if (
+ resetAll ||
+ resetBranch ||
+ repositoryChanged ||
+ activeContextChanged ||
+ !currentHeadBranch
+ ) {
setElementValueAndPersist(headBranchInput, activeHeadBranch)
}
} else if (!currentHeadBranch) {
@@ -266,13 +288,23 @@ export const createRepositoryFormHandlers = ({
}
if (prTitleInput instanceof HTMLInputElement) {
- if (resetAll || repositoryChanged || !toSafeText(prTitleInput.value)) {
+ if (
+ resetAll ||
+ repositoryChanged ||
+ activeContextChanged ||
+ !toSafeText(prTitleInput.value)
+ ) {
setElementValueAndPersist(prTitleInput, toSafeText(activeContext?.prTitle))
}
}
if (prBodyInput instanceof HTMLTextAreaElement) {
- if (resetAll || repositoryChanged || !toSafeText(prBodyInput.value)) {
+ if (
+ resetAll ||
+ repositoryChanged ||
+ activeContextChanged ||
+ !toSafeText(prBodyInput.value)
+ ) {
prBodyInput.value =
typeof activeContext?.prBody === 'string' ? activeContext.prBody : ''
}
@@ -289,6 +321,7 @@ export const createRepositoryFormHandlers = ({
}
state.lastSyncedRepositoryFullName = repositoryFullName
+ state.lastSyncedActivePrContextKey = activeContextSyncKey
}
const refreshContextUi = () => {
diff --git a/src/modules/github/pr/drawer/controller/run-submit.js b/src/modules/github/pr/drawer/controller/run-submit.js
index 2b6f6fd..d126fa4 100644
--- a/src/modules/github/pr/drawer/controller/run-submit.js
+++ b/src/modules/github/pr/drawer/controller/run-submit.js
@@ -10,6 +10,7 @@ export const createRunSubmit = ({
prTitleInput,
includeAppWrapperToggle,
getFileCommits,
+ persistWorkspaceMetadataOnSubmit,
getTopLevelDeclarations,
confirmBeforeSubmit,
onPullRequestOpened,
@@ -188,7 +189,32 @@ export const createRunSubmit = ({
}),
)
- const submitRequest = () => {
+ const submitRequest = async () => {
+ if (typeof persistWorkspaceMetadataOnSubmit === 'function') {
+ try {
+ await persistWorkspaceMetadataOnSubmit({
+ isPushCommitMode,
+ repository: repositoryLabel,
+ baseBranch: targetBaseBranch,
+ headBranch: targetHeadBranch,
+ prTitle: targetPrTitle,
+ prBody: targetPrBody,
+ })
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Could not persist workspace metadata before submit.'
+ setStatus(
+ isPushCommitMode
+ ? `Push commit blocked: ${message}`
+ : `Open PR blocked: ${message}`,
+ 'error',
+ )
+ return
+ }
+ }
+
state.pendingAbortController?.abort()
const abortController = new AbortController()
state.pendingAbortController = abortController
@@ -223,8 +249,25 @@ export const createRunSubmit = ({
})
void Promise.resolve(runRequest)
- .then(result => {
+ .then(async result => {
if (isPushCommitMode) {
+ const committedFileUpdates = Array.isArray(result) ? result : []
+ const attemptedNonDeleteUpdates = fileUpdates.filter(
+ update =>
+ typeof update?.path === 'string' &&
+ update.path.trim().length > 0 &&
+ update?.deleted !== true,
+ )
+
+ if (
+ attemptedNonDeleteUpdates.length > 0 &&
+ committedFileUpdates.length === 0
+ ) {
+ throw new Error(
+ 'Push did not return committed file updates. Workspace sync baseline was not updated.',
+ )
+ }
+
const compactPullRequestReference = formatActivePrReference(activeContext)
const pullRequestUrl = toSafeText(activeContext?.pullRequestUrl)
const pullRequestTitle = toSafeText(activeContext?.prTitle)
@@ -239,11 +282,13 @@ export const createRunSubmit = ({
: `Commit pushed to ${targetHeadBranch}.`,
'ok',
)
- onPullRequestCommitPushed?.({
- repositoryFullName: repositoryLabel,
- branch: targetHeadBranch,
- fileUpdates: Array.isArray(result) ? result : [],
- })
+ if (typeof onPullRequestCommitPushed === 'function') {
+ await onPullRequestCommitPushed({
+ repositoryFullName: repositoryLabel,
+ branch: targetHeadBranch,
+ fileUpdates: committedFileUpdates,
+ })
+ }
setOpen(false)
return
}
@@ -270,13 +315,15 @@ export const createRunSubmit = ({
url ? `Pull request opened: ${url}` : 'Pull request opened successfully.',
'ok',
)
- onPullRequestOpened?.({
- repositoryFullName: repositoryLabel,
- url,
- pullRequestNumber: result.pullRequest.number,
- branch: targetHeadBranch,
- fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [],
- })
+ if (typeof onPullRequestOpened === 'function') {
+ await onPullRequestOpened({
+ repositoryFullName: repositoryLabel,
+ url,
+ pullRequestNumber: result.pullRequest.number,
+ branch: targetHeadBranch,
+ fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [],
+ })
+ }
setOpen(false)
})
.catch(error => {
diff --git a/src/modules/github/pr/editor-sync.js b/src/modules/github/pr/editor-sync.js
index 7487adb..b376036 100644
--- a/src/modules/github/pr/editor-sync.js
+++ b/src/modules/github/pr/editor-sync.js
@@ -2,39 +2,26 @@ import { getRepositoryFileContent } from '../api/repository-files.js'
const toSafeText = value => (typeof value === 'string' ? value.trim() : '')
-const toComponentPathFallbacks = path => {
- const normalizedPath = toSafeText(path)
- if (!normalizedPath) {
- return []
- }
-
- const separatorIndex = normalizedPath.lastIndexOf('/')
- const directory = separatorIndex >= 0 ? normalizedPath.slice(0, separatorIndex + 1) : ''
-
- const candidateFileNames = ['App.tsx', 'app.tsx', 'App.js', 'app.js']
- const fallbackPaths = candidateFileNames
- .map(candidate => `${directory}${candidate}`)
- .filter(candidate => candidate !== normalizedPath)
-
- for (const canonicalPath of ['src/components/App.tsx', 'src/components/App.js']) {
- if (canonicalPath !== normalizedPath && !fallbackPaths.includes(canonicalPath)) {
- fallbackPaths.push(canonicalPath)
+const toNormalizedTabTargetsByPath = tabTargets => {
+ const targetsByPath = new Map()
+ const sourceTargets = Array.isArray(tabTargets) ? tabTargets : []
+
+ for (const target of sourceTargets) {
+ const path = toSafeText(target?.path)
+ if (!path) {
+ continue
}
+
+ targetsByPath.set(path, {
+ path,
+ kind: toSafeText(target?.kind),
+ })
}
- return fallbackPaths
+ return [...targetsByPath.values()]
}
-export const createGitHubPrEditorSyncController = ({
- setComponentSource,
- setStylesSource,
- scheduleRender,
- shouldApplySyncResult,
-}) => {
- const setComponent =
- typeof setComponentSource === 'function' ? setComponentSource : () => {}
- const setStyles = typeof setStylesSource === 'function' ? setStylesSource : () => {}
- const schedule = typeof scheduleRender === 'function' ? scheduleRender : () => {}
+export const createGitHubPrEditorSyncController = ({ shouldApplySyncResult }) => {
const shouldApply =
typeof shouldApplySyncResult === 'function' ? shouldApplySyncResult : () => true
@@ -48,17 +35,9 @@ export const createGitHubPrEditorSyncController = ({
const owner = toSafeText(repository?.owner)
const repo = toSafeText(repository?.name)
const branch = toSafeText(activeContext?.headBranch)
- const tabTargets = Array.isArray(syncTargets?.tabTargets)
- ? syncTargets.tabTargets
- : []
- const componentTabPath = toSafeText(
- tabTargets.find(target => toSafeText(target?.kind) === 'component')?.path,
- )
- const stylesTabPath = toSafeText(
- tabTargets.find(target => toSafeText(target?.kind) === 'styles')?.path,
- )
+ const normalizedTabTargets = toNormalizedTabTargetsByPath(syncTargets?.tabTargets)
- if (!token || !owner || !repo || !branch || !componentTabPath || !stylesTabPath) {
+ if (!token || !owner || !repo || !branch || normalizedTabTargets.length === 0) {
return {
synced: false,
componentSynced: false,
@@ -71,10 +50,7 @@ export const createGitHubPrEditorSyncController = ({
repository,
activeContext,
syncTargets: {
- tabTargets: [
- { kind: 'component', path: componentTabPath },
- { kind: 'styles', path: stylesTabPath },
- ],
+ tabTargets: normalizedTabTargets,
},
})
) {
@@ -95,44 +71,15 @@ export const createGitHubPrEditorSyncController = ({
signal,
})
- let resolvedComponentTabPath = componentTabPath
- let resolvedStylesTabPath = stylesTabPath
-
- const componentRequest = (async () => {
- const primary = await requestFileContent(componentTabPath)
- if (primary) {
- return primary
- }
-
- const fallbackPaths = toComponentPathFallbacks(componentTabPath)
- const fallbackResults = await Promise.all(
- fallbackPaths.map(async path => ({
- path,
- file: await requestFileContent(path),
- })),
- )
- const fallback = fallbackResults.find(candidate => candidate.file)
- if (fallback?.file) {
- resolvedComponentTabPath = fallback.path
- return fallback.file
- }
-
- return null
- })()
-
- const stylesRequest =
- stylesTabPath === componentTabPath
- ? componentRequest
- : requestFileContent(stylesTabPath)
-
- const [componentFile, stylesFile] = await Promise.all([
- componentRequest,
- stylesRequest,
- ])
-
- if (stylesTabPath === componentTabPath) {
- resolvedStylesTabPath = resolvedComponentTabPath
- }
+ const requestedTargets = await Promise.all(
+ normalizedTabTargets.map(async target => {
+ const file = await requestFileContent(target.path)
+ return {
+ ...target,
+ content: typeof file?.content === 'string' ? file.content : null,
+ }
+ }),
+ )
if (signal?.aborted) {
return {
@@ -147,10 +94,7 @@ export const createGitHubPrEditorSyncController = ({
repository,
activeContext,
syncTargets: {
- tabTargets: [
- { kind: 'component', path: resolvedComponentTabPath },
- { kind: 'styles', path: resolvedStylesTabPath },
- ],
+ tabTargets: requestedTargets,
},
})
) {
@@ -161,39 +105,47 @@ export const createGitHubPrEditorSyncController = ({
}
}
- let updated = false
- let componentSynced = false
- let stylesSynced = false
-
- if (componentFile && typeof componentFile.content === 'string') {
- setComponent(componentFile.content)
- updated = true
- componentSynced = true
- }
-
- if (stylesFile && typeof stylesFile.content === 'string') {
- setStyles(stylesFile.content)
- updated = true
- stylesSynced = true
- }
-
- if (stylesTabPath === componentTabPath) {
- stylesSynced = componentSynced
- }
+ const syncedTabTargets = requestedTargets
+ .filter(target => typeof target.content === 'string')
+ .map(target => ({
+ kind: target.kind,
+ path: target.path,
+ content: target.content,
+ }))
- if (updated) {
- schedule()
+ const componentTargets = requestedTargets.filter(
+ target => target.kind === 'component',
+ )
+ const stylesTargets = requestedTargets.filter(target => target.kind === 'styles')
+ const componentSynced =
+ componentTargets.length > 0 &&
+ componentTargets.every(target => typeof target.content === 'string')
+ const stylesSynced =
+ stylesTargets.length > 0 &&
+ stylesTargets.every(target => typeof target.content === 'string')
+ const allTargetsSynced = syncedTabTargets.length === normalizedTabTargets.length
+
+ if (!allTargetsSynced) {
+ return {
+ synced: false,
+ componentSynced,
+ stylesSynced,
+ syncedTabCount: syncedTabTargets.length,
+ totalTabCount: normalizedTabTargets.length,
+ syncTargets: {
+ tabTargets: normalizedTabTargets,
+ },
+ }
}
return {
- synced: componentSynced && stylesSynced,
+ synced: true,
componentSynced,
stylesSynced,
+ syncedTabCount: syncedTabTargets.length,
+ totalTabCount: normalizedTabTargets.length,
syncTargets: {
- tabTargets: [
- { kind: 'component', path: resolvedComponentTabPath },
- { kind: 'styles', path: resolvedStylesTabPath },
- ],
+ tabTargets: syncedTabTargets,
},
}
}
diff --git a/src/modules/workspace/workspace-storage.js b/src/modules/workspace/workspace-storage.js
index c5bb73a..2df6e74 100644
--- a/src/modules/workspace/workspace-storage.js
+++ b/src/modules/workspace/workspace-storage.js
@@ -7,6 +7,15 @@ const workspaceStoreName = 'prWorkspaces'
const workspaceByRepoIndexName = 'byRepo'
const workspaceByLastModifiedIndexName = 'byLastModified'
+const toWorkspaceScope = value => {
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
+ if (normalized === 'repository') {
+ return 'repository'
+ }
+
+ return 'local'
+}
+
const toTabRole = value => {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''
return normalized === 'entry' ? 'entry' : 'module'
@@ -74,6 +83,12 @@ const normalizeWorkspaceRecord = record => {
const normalizedRepo = typeof record.repo === 'string' ? record.repo : ''
const normalizedHead = typeof record.head === 'string' ? record.head : ''
+ const normalizedWorkspaceScope =
+ typeof record.workspaceScope === 'string' && record.workspaceScope.trim().length > 0
+ ? toWorkspaceScope(record.workspaceScope)
+ : normalizedRepo
+ ? 'repository'
+ : 'local'
const normalizedWorkspaceKey = toWorkspaceRecordKey({
repositoryFullName: normalizedRepo,
@@ -82,6 +97,7 @@ const normalizeWorkspaceRecord = record => {
return {
id: record.id,
+ workspaceScope: normalizedWorkspaceScope,
workspaceKey: normalizedWorkspaceKey,
repo: normalizedRepo,
base: typeof record.base === 'string' ? record.base : '',
diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js
index 03c219e..6f98951 100644
--- a/src/modules/workspace/workspaces-drawer/drawer.js
+++ b/src/modules/workspace/workspaces-drawer/drawer.js
@@ -1,47 +1,47 @@
-import { repositoryStarterSelectionIdPrefix } from '../../constants.js'
-
const toSafeText = value => (typeof value === 'string' ? value.trim() : '')
const localRepositoryFilterValue = '__local__'
-
-const toRepositoryStarterSelectionId = repositoryFullName => {
- const repository = toSafeText(repositoryFullName)
- if (!repository || repository === localRepositoryFilterValue) {
- return ''
- }
-
- return `${repositoryStarterSelectionIdPrefix}${repository}`
+const localWorkspaceScopeValue = 'local'
+const repositoryWorkspaceScopeValue = 'repository'
+
+const drawerUiState = {
+ localEmpty: 'local-empty',
+ localWithWorkspaces: 'local-with-workspaces',
+ repositoryEmpty: 'repository-empty',
+ repositoryWithWorkspaces: 'repository-with-workspaces',
}
-const isRepositoryStarterSelectionId = value =>
- toSafeText(value).startsWith(repositoryStarterSelectionIdPrefix)
+const toSafeWorkspaceScope = workspace => {
+ const scope = toSafeText(workspace?.workspaceScope).toLowerCase()
+ if (scope === repositoryWorkspaceScopeValue) {
+ return repositoryWorkspaceScopeValue
+ }
-const isLocalWorkspaceEntry = workspace => {
- const repository = toSafeText(workspace?.repo)
- return !repository
-}
+ if (scope === localWorkspaceScopeValue) {
+ return localWorkspaceScopeValue
+ }
-const isLocalOnlyInactiveWorkspace = workspace => {
- const state = toSafeText(workspace?.prContextState).toLowerCase()
- const hasPrNumber = Number.isFinite(workspace?.prNumber)
- return state === 'inactive' && !hasPrNumber
+ return toSafeText(workspace?.repo)
+ ? repositoryWorkspaceScopeValue
+ : localWorkspaceScopeValue
}
-const toWorkspaceLabel = workspace => {
- const isLocalOnlyInactive = isLocalOnlyInactiveWorkspace(workspace)
+const toWorkspaceLabel = (workspace, { forceLocalPrefix = false } = {}) => {
+ const isLocalScoped =
+ forceLocalPrefix || toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue
const hasTitle = toSafeText(workspace?.prTitle)
if (hasTitle) {
- return isLocalOnlyInactive ? `local:${hasTitle}` : hasTitle
+ return isLocalScoped ? `local:${hasTitle}` : hasTitle
}
const hasHead = toSafeText(workspace?.head)
if (hasHead) {
- return isLocalOnlyInactive ? `local:${hasHead}` : hasHead
+ return isLocalScoped ? `local:${hasHead}` : hasHead
}
const fallbackLabel = toSafeText(workspace?.id) || 'workspace'
- return isLocalOnlyInactive ? `local:${fallbackLabel}` : fallbackLabel
+ return isLocalScoped ? `local:${fallbackLabel}` : fallbackLabel
}
export const createWorkspacesDrawer = ({
@@ -50,6 +50,9 @@ export const createWorkspacesDrawer = ({
closeButton,
statusNode,
repositorySelect,
+ getActiveWorkspaceId,
+ initializeButton,
+ newButton,
selectInput,
openButton,
removeButton,
@@ -58,6 +61,8 @@ export const createWorkspacesDrawer = ({
getSelectedRepositoryFilter,
onRepositoryFilterChange,
onRefreshRequested,
+ onInitializeWorkspace,
+ onCreateWorkspace,
onOpenSelected,
onRemoveSelected,
} = {}) => {
@@ -66,20 +71,42 @@ export const createWorkspacesDrawer = ({
let selectedId = ''
let selectedRepositoryFilter = localRepositoryFilterValue
let hasUserSelectedRepositoryFilter = false
+ let currentUiState = drawerUiState.localEmpty
const getNormalizedRepositoryFilter = value => {
const normalized = toSafeText(value)
return normalized || localRepositoryFilterValue
}
+ const isInactiveWithoutPrNumber = workspace => {
+ const state = toSafeText(workspace?.prContextState).toLowerCase()
+ const hasPrNumber =
+ typeof workspace?.prNumber === 'number' && Number.isFinite(workspace.prNumber)
+ return state === 'inactive' && !hasPrNumber
+ }
+
+ const shouldRenderAsLocalEntry = workspace => {
+ if (toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue) {
+ return true
+ }
+
+ const activeWorkspaceId =
+ typeof getActiveWorkspaceId === 'function'
+ ? toSafeText(getActiveWorkspaceId())
+ : toSafeText(selectedId)
+
+ return (
+ toSafeText(workspace?.id) === activeWorkspaceId &&
+ isInactiveWithoutPrNumber(workspace)
+ )
+ }
+
const getFilteredEntriesByRepository = () => {
const normalizedRepositoryFilter = getNormalizedRepositoryFilter(
selectedRepositoryFilter,
)
if (normalizedRepositoryFilter === localRepositoryFilterValue) {
- return entries.filter(
- entry => isLocalWorkspaceEntry(entry) || isLocalOnlyInactiveWorkspace(entry),
- )
+ return entries.filter(entry => shouldRenderAsLocalEntry(entry))
}
return entries.filter(entry => {
@@ -87,14 +114,27 @@ export const createWorkspacesDrawer = ({
return false
}
- if (isLocalWorkspaceEntry(entry)) {
+ if (toSafeWorkspaceScope(entry) !== repositoryWorkspaceScopeValue) {
return false
}
- return !isLocalOnlyInactiveWorkspace(entry)
+ return true
})
}
+ const getUiState = ({ repositoryFilter, hasStoredWorkspaces }) => {
+ const isLocalScope = repositoryFilter === localRepositoryFilterValue
+ if (isLocalScope) {
+ return hasStoredWorkspaces
+ ? drawerUiState.localWithWorkspaces
+ : drawerUiState.localEmpty
+ }
+
+ return hasStoredWorkspaces
+ ? drawerUiState.repositoryWithWorkspaces
+ : drawerUiState.repositoryEmpty
+ }
+
const setStatus = (text, level = 'neutral') => {
if (!(statusNode instanceof HTMLElement)) {
return
@@ -105,16 +145,47 @@ export const createWorkspacesDrawer = ({
}
const updateActions = () => {
- const normalizedSelectedId = toSafeText(selectedId)
+ const normalizedSelectedId =
+ selectInput instanceof HTMLSelectElement
+ ? toSafeText(selectInput.value)
+ : toSafeText(selectedId)
const hasSelection = normalizedSelectedId.length > 0
- const isStarterSelection = isRepositoryStarterSelectionId(normalizedSelectedId)
+ const canCreateWorkspace = typeof onCreateWorkspace === 'function'
+ const canInitializeWorkspace = typeof onInitializeWorkspace === 'function'
+ const hasStoredWorkspaces =
+ currentUiState === drawerUiState.localWithWorkspaces ||
+ currentUiState === drawerUiState.repositoryWithWorkspaces
+ const showInitialize = currentUiState === drawerUiState.repositoryEmpty
+ const showNewWorkspace = !showInitialize
+
+ const workspaceField = selectInput?.closest('label')
+ if (workspaceField instanceof HTMLElement) {
+ workspaceField.toggleAttribute('hidden', !hasStoredWorkspaces)
+ }
+
+ const actionsRow =
+ openButton?.closest('.workspaces-drawer__actions') ??
+ removeButton?.closest('.workspaces-drawer__actions')
+ if (actionsRow instanceof HTMLElement) {
+ actionsRow.toggleAttribute('hidden', !hasStoredWorkspaces)
+ }
+
+ if (initializeButton instanceof HTMLButtonElement) {
+ initializeButton.toggleAttribute('hidden', !showInitialize)
+ initializeButton.disabled = !canInitializeWorkspace
+ }
+
+ if (newButton instanceof HTMLButtonElement) {
+ newButton.toggleAttribute('hidden', !showNewWorkspace)
+ newButton.disabled = !canCreateWorkspace
+ }
if (openButton instanceof HTMLButtonElement) {
openButton.disabled = !hasSelection
}
if (removeButton instanceof HTMLButtonElement) {
- removeButton.disabled = !hasSelection || isStarterSelection
+ removeButton.disabled = !hasSelection
}
}
@@ -125,41 +196,39 @@ export const createWorkspacesDrawer = ({
const repositoryFilteredEntries = getFilteredEntriesByRepository()
const filteredEntries = repositoryFilteredEntries
+ const hasStoredWorkspaces = filteredEntries.length > 0
const normalizedRepositoryFilter = getNormalizedRepositoryFilter(
selectedRepositoryFilter,
)
- const starterSelectionId =
- filteredEntries.length === 0
- ? toRepositoryStarterSelectionId(normalizedRepositoryFilter)
- : ''
- const hasStarterSelection = Boolean(starterSelectionId)
+
+ currentUiState = getUiState({
+ repositoryFilter: normalizedRepositoryFilter,
+ hasStoredWorkspaces,
+ })
+
+ if (!hasStoredWorkspaces) {
+ updateActions()
+ return
+ }
selectInput.replaceChildren()
const placeholder = document.createElement('option')
placeholder.value = ''
- placeholder.textContent =
- repositoryFilteredEntries.length === 0
- ? hasStarterSelection
- ? 'Select to start a new local context'
- : 'No saved local contexts'
- : 'Select a stored local context'
- placeholder.disabled = filteredEntries.length > 0 || hasStarterSelection
+ placeholder.textContent = 'Select a stored workspace'
+ placeholder.disabled = true
placeholder.selected = !filteredEntries.some(entry => entry.id === selectedId)
selectInput.append(placeholder)
- if (hasStarterSelection) {
- const starterOption = document.createElement('option')
- starterOption.value = starterSelectionId
- starterOption.textContent = `Start new context for ${normalizedRepositoryFilter}`
- starterOption.selected = selectedId === starterSelectionId
- selectInput.append(starterOption)
- }
-
for (const entry of filteredEntries) {
const option = document.createElement('option')
option.value = toSafeText(entry.id)
- option.textContent = toWorkspaceLabel(entry)
+ const shouldPrefixAsLocal =
+ normalizedRepositoryFilter === localRepositoryFilterValue &&
+ shouldRenderAsLocalEntry(entry)
+ option.textContent = toWorkspaceLabel(entry, {
+ forceLocalPrefix: shouldPrefixAsLocal,
+ })
option.selected = option.value === selectedId
selectInput.append(option)
}
@@ -167,12 +236,9 @@ export const createWorkspacesDrawer = ({
const hasSelectedFilteredEntry = filteredEntries.some(
entry => entry.id === selectedId,
)
- const hasSelectedStarterEntry =
- hasStarterSelection && selectedId === starterSelectionId
- if (!hasSelectedFilteredEntry && !hasSelectedStarterEntry) {
- selectedId = hasStarterSelection ? starterSelectionId : ''
- selectInput.value = selectedId
+ if (!hasSelectedFilteredEntry) {
+ selectInput.value = ''
}
updateActions()
@@ -247,7 +313,7 @@ export const createWorkspacesDrawer = ({
} catch {
entries = []
selectedId = ''
- setStatus('Could not refresh stored local contexts.', 'error')
+ setStatus('Could not refresh stored workspaces.', 'error')
renderOptions()
return entries
}
@@ -294,11 +360,39 @@ export const createWorkspacesDrawer = ({
open = false
toggleButton.setAttribute('aria-expanded', 'false')
drawer.toggleAttribute('hidden', true)
- setStatus('Could not open local workspaces drawer.', 'error')
+ setStatus('Could not open workspaces drawer.', 'error')
+ return
+ }
+
+ updateActions()
+
+ const workspaceField = selectInput?.closest('label')
+ if (workspaceField instanceof HTMLElement && !workspaceField.hasAttribute('hidden')) {
+ selectInput?.focus()
return
}
- selectInput?.focus()
+ if (
+ initializeButton instanceof HTMLButtonElement &&
+ !initializeButton.hasAttribute('hidden')
+ ) {
+ initializeButton.focus()
+ return
+ }
+
+ newButton?.focus()
+ }
+
+ const closeDrawer = () => {
+ open = false
+
+ if (toggleButton instanceof HTMLButtonElement) {
+ toggleButton.setAttribute('aria-expanded', 'false')
+ }
+
+ if (drawer instanceof HTMLElement) {
+ drawer.toggleAttribute('hidden', true)
+ }
}
toggleButton?.addEventListener('click', () => {
@@ -325,6 +419,55 @@ export const createWorkspacesDrawer = ({
updateActions()
})
+ initializeButton?.addEventListener('click', async () => {
+ if (typeof onInitializeWorkspace !== 'function') {
+ return
+ }
+
+ let initialized = false
+ try {
+ initialized = await onInitializeWorkspace(
+ getNormalizedRepositoryFilter(selectedRepositoryFilter),
+ )
+ } catch {
+ setStatus('Could not initialize workspace.', 'error')
+ return
+ }
+
+ if (!initialized) {
+ return
+ }
+
+ closeDrawer()
+ selectedId = ''
+ setStatus('Initialized workspace.', 'neutral')
+ })
+
+ newButton?.addEventListener('click', async () => {
+ if (typeof onCreateWorkspace !== 'function') {
+ return
+ }
+
+ let created = false
+ try {
+ created = await onCreateWorkspace(
+ getNormalizedRepositoryFilter(selectedRepositoryFilter),
+ )
+ } catch {
+ setStatus('Could not create workspace.', 'error')
+ return
+ }
+
+ if (!created) {
+ return
+ }
+
+ selectedId =
+ typeof getActiveWorkspaceId === 'function' ? toSafeText(getActiveWorkspaceId()) : ''
+ setStatus('Created workspace.', 'neutral')
+ await refresh({ preserveSelection: Boolean(selectedId) })
+ })
+
openButton?.addEventListener('click', async () => {
const id = toSafeText(selectedId)
if (!id || typeof onOpenSelected !== 'function') {
@@ -337,7 +480,7 @@ export const createWorkspacesDrawer = ({
try {
opened = await onOpenSelected(id)
} catch {
- setStatus('Could not load selected local context.', 'error')
+ setStatus('Could not load selected workspace.', 'error')
return
}
@@ -345,8 +488,8 @@ export const createWorkspacesDrawer = ({
return
}
- setStatus('Loaded local workspace context.', 'neutral')
- void refresh({ preserveSelection: true })
+ closeDrawer()
+ setStatus('Loaded workspace.', 'neutral')
})
removeButton?.addEventListener('click', async () => {
@@ -359,7 +502,7 @@ export const createWorkspacesDrawer = ({
try {
removed = await onRemoveSelected(id)
} catch {
- setStatus('Could not remove selected local context.', 'error')
+ setStatus('Could not remove selected workspace.', 'error')
return
}
@@ -368,7 +511,7 @@ export const createWorkspacesDrawer = ({
}
selectedId = ''
- setStatus('Removed stored local context.', 'neutral')
+ setStatus('Removed stored workspace.', 'neutral')
await refresh({ preserveSelection: false })
})
diff --git a/src/styles/ai-controls.css b/src/styles/ai-controls.css
index 09d3d15..8ab6459 100644
--- a/src/styles/ai-controls.css
+++ b/src/styles/ai-controls.css
@@ -430,61 +430,6 @@
display: none;
}
-.diagnostics-toggle.github-pr-context-disconnect {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- --github-pr-disconnect-icon-color: color-mix(
- in srgb,
- var(--accent) 78%,
- var(--panel-text)
- );
- --github-pr-disconnect-text-color: var(--shell-text);
- --github-pr-disconnect-text-color-hover: color-mix(
- in srgb,
- var(--accent) 78%,
- var(--panel-text)
- );
-
- color: var(--github-pr-disconnect-text-color);
-}
-
-:root[data-theme='light'] .diagnostics-toggle.github-pr-context-disconnect {
- --github-pr-disconnect-icon-color: color-mix(in srgb, #6d28d9 84%, var(--panel-text));
- --github-pr-disconnect-text-color-hover: color-mix(
- in srgb,
- #6d28d9 88%,
- var(--panel-text)
- );
-}
-
-.github-pr-context-disconnect__icon {
- width: 14px;
- height: 14px;
- fill: var(--github-pr-disconnect-icon-color);
-}
-
-.github-pr-context-disconnect__icon path {
- fill: var(--github-pr-disconnect-icon-color);
-}
-
-.diagnostics-toggle.github-pr-context-disconnect:hover:not(:disabled) {
- color: var(--github-pr-disconnect-text-color-hover);
-}
-
-.github-pr-context-disconnect__label {
- color: inherit;
-}
-
-.github-pr-context-disconnect:focus-visible:not(:disabled) {
- outline: 2px solid var(--focus-ring);
- outline-offset: 1px;
-}
-
-.github-pr-context-disconnect[hidden] {
- display: none;
-}
-
.ai-chat-drawer {
position: fixed;
top: 82px;
@@ -623,12 +568,41 @@
padding: 2px;
}
+.workspaces-drawer__repository-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: end;
+}
+
+.workspaces-drawer__repository-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.workspaces-drawer__repository-row .github-pr-field--full {
+ grid-column: auto;
+}
+
+.workspaces-drawer__repository-row .render-button {
+ height: 36px;
+ width: auto;
+ min-width: fit-content;
+ padding-inline: 12px;
+ white-space: nowrap;
+}
+
.workspaces-drawer__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
+.workspaces-drawer__actions[hidden] {
+ display: none;
+}
+
.github-pr-drawer__header {
display: flex;
align-items: center;