diff --git a/README.md b/README.md index dde61aa..bde783e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ browser acts as the runtime host for render, lint, and typecheck flows. `@knighted/develop` lets you: - write component code in the browser +- manage dynamic workspace tabs with add, rename, remove, and entry-role protection - switch render mode between DOM and React - switch style mode between native CSS, CSS Modules, Less, and Sass - run in-browser lint and type diagnostics diff --git a/docs/next-steps.md b/docs/next-steps.md index feee14b..a9d10e1 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -19,31 +19,7 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run." -4. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** - - Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App. - - Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable. - - Preserve existing runtime behavior and editor content semantics while adding tab switching, active tab indication, and predictable persistence/reset behavior consistent with current app patterns. - - Ensure assistant/editor integration remains compatible with this model (edits should target one of the fixed tabs) without expanding to dynamic tab metadata yet. - - Suggested implementation prompt: - - "Implement Phase 2 UX/UI tab support in @knighted/develop with a fixed first-pass tab model: Component, Styles, and App only (no arbitrary tab names yet). Add a clear tab UI for switching editor panes, preserve existing editor behavior/content wiring, and keep render/lint/typecheck/diagnostics flows working with the selected tab context where relevant. Keep AI chat feature-flag behavior unchanged while keeping PR/BYOT controls available by default, maintain CDN-first runtime constraints, and do not add dependencies. Add targeted Playwright coverage for tab switching, default/active tab behavior, and interactions with existing render/style-mode flows. Validate with npm run lint and targeted Playwright tests." - -5. **Document implicit App strict-flow behavior (auto render)** - - Add a short behavior matrix in docs that explains when implicit App wrapping is allowed versus when users must define `App` explicitly. - - Include concrete Component editor examples for each case so reviewer/user expectations are clear. - - Suggested example cases to document: - - Allowed implicit wrap (standalone top-level JSX, no imports/declarations), for example: - - `() as any` - - Requires explicit `App` (top-level JSX with declarations/imports), for example: - - `const label = 'Hello'` - - `const Button = () => ` - - `(` - - `const App = () => '].join( - '\n', - ), - ) - - await page.getByRole('button', { name: 'Typecheck' }).click() - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - - await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toHaveClass( - /status--error/, - ) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - const dialog = page.getByRole('dialog') - await ensureDiagnosticsDrawerClosed(page) - await page.getByLabel('Clear styles source').click() - await expect(dialog).toHaveAttribute('open', '') - await dialog.getByRole('button', { name: 'Clear' }).click() - - await expect(page.getByText('Styles cleared', { exact: true })).toHaveClass( - /status--neutral/, - ) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) -}) - test('clear component diagnostics removes type errors and restores rendered status', async ({ page, }) => { @@ -124,46 +64,6 @@ test('clear component diagnostics removes type errors and restores rendered stat await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) }) -test('clear all diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('Style mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('Style compilation failed.')).toBeVisible() - - await page.getByRole('button', { name: 'Reset all' }).click() - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) - await expect(diagnosticsToggle).toHaveText('Diagnostics') - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) -}) - -test('clear styles diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('Style mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('Style compilation failed.')).toBeVisible() - - await page.getByRole('button', { name: 'Reset styles' }).click() - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) - await expect(diagnosticsToggle).toHaveText('Diagnostics') - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) -}) - test('typecheck success reports ok diagnostics state in button and drawer', async ({ page, }) => { @@ -446,36 +346,3 @@ test('component lint with unresolved issues enters pending diagnostics state whi await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'false') }) - -test('changing css dialect resets diagnostics after lint and typecheck runs', async ({ - page, -}) => { - await waitForInitialRender(page) - await ensurePanelToolsVisible(page, 'styles') - - await setComponentEditorSource( - page, - [ - "const broken: number = 'oops'", - 'const unusedValue = 1', - 'const App = () => ', - ].join('\n'), - ) - - await runTypecheck(page) - await runComponentLint(page) - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) - - await page.getByLabel('Style mode').selectOption('less') - - await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) - await expect(diagnosticsToggle).toHaveText('Diagnostics') - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) -}) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index ec00e48..d2f7fb7 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -389,20 +389,22 @@ test('AI chat proposals can be confirmed, applied, and undone for component and page.getByRole('button', { name: 'Undo last Component apply' }), ).toBeVisible() await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).toBeVisible() - await expect(page.locator('.component-panel .cm-content').first()).toContainText( - 'Updated', - ) - await expect(page.locator('.styles-panel .cm-content').first()).toContainText( - 'rgb(10 20 30)', - ) + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Updated') + await expect( + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('rgb(10 20 30)') await page.getByRole('button', { name: 'Undo last Component apply' }).click() - await expect(page.locator('.component-panel .cm-content').first()).toContainText( - 'Before', - ) + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Before') await page.getByRole('button', { name: 'Undo last Styles apply' }).click() - await expect(page.locator('.styles-panel .cm-content').first()).toContainText('red') + await expect( + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('red') }) test('AI chat shows a single apply action when both editor proposals are available', async ({ diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index bf31180..c68c9d6 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -1798,23 +1798,6 @@ test('Open PR drawer allows dotted file segments that are not traversal', async ).not.toContainText('File path cannot include parent directory traversal.') }) -test('Open PR drawer rejects trailing slash file paths', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.getByLabel('Component filename').fill('src/components/') - await page.getByLabel('PR title').fill('Reject trailing slash path') - await clickOpenPrDrawerSubmit(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText( - 'Component path: File path must include a filename (no trailing slash).', - ) - await expect(page.getByRole('dialog')).toBeHidden() -}) - test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({ page, }) => { diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 14cb08f..665d038 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -57,6 +57,53 @@ export const waitForAppReady = async (page: Page, path = appEntryPath) => { .toBe(true) } +export const resetWorkbenchStorage = async (page: Page) => { + await page.goto(appEntryPath) + await page.evaluate(async () => { + try { + localStorage.clear() + } catch { + /* noop */ + } + + try { + sessionStorage.clear() + } catch { + /* noop */ + } + + const deleteIndexedDbByName = async (name: string) => { + await new Promise(resolve => { + if (!name) { + resolve() + return + } + + const request = indexedDB.deleteDatabase(name) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + }) + } + + if (typeof indexedDB === 'undefined') { + return + } + + if (typeof indexedDB.databases === 'function') { + const databases = await indexedDB.databases() + const databaseNames = (databases || []) + .map(entry => entry?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + + await Promise.all(databaseNames.map(name => deleteIndexedDbByName(name))) + return + } + + await deleteIndexedDbByName('knighted-develop-workspaces') + }) +} + export const waitForInitialRender = async (page: Page) => { await waitForAppReady(page) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') @@ -70,19 +117,75 @@ export const expectPreviewHasRenderedContent = async (page: Page) => { .toBeGreaterThan(0) } +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const getPreviewFrame = (page: Page) => page.frameLocator('#preview-host iframe') + +export const addWorkspaceTab = async ( + page: Page, + { kind = 'component' }: { kind?: 'component' | 'styles' } = {}, +) => { + await page.getByRole('button', { name: 'Add tab options' }).click() + if (kind === 'styles') { + await page.getByRole('menuitem', { name: 'styles' }).click() + return + } + + await page.getByRole('menuitem', { name: 'module' }).click() +} + +export const openWorkspaceTab = async (page: Page, fileName: string) => { + const pattern = new RegExp(`^Open tab ${escapeRegex(fileName)}$`) + await page.getByRole('tab', { name: pattern }).click() +} + +export const setWorkspaceTabSource = async ( + page: Page, + { + fileName, + source, + kind = 'component', + }: { + fileName: string + source: string + kind?: 'component' | 'styles' + }, +) => { + await openWorkspaceTab(page, fileName) + const editorContent = page + .locator(`.editor-panel[data-editor-kind="${kind}"] .cm-content`) + .first() + await editorContent.fill(source) + await editorContent.press('End') + await editorContent.type(' ') + await editorContent.press('Backspace') +} + export const setComponentEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.component-panel .cm-content').first() + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() + const editorContent = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() await editorContent.fill(source) + await editorContent.press('End') + await editorContent.type(' ') + await editorContent.press('Backspace') } export const setStylesEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.styles-panel .cm-content').first() + await page.getByRole('tab', { name: 'Open tab app.css' }).click() + const editorContent = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() await editorContent.fill(source) + await editorContent.press('End') + await editorContent.type(' ') + await editorContent.press('Backspace') } export const getActiveComponentEditorLineNumber = async (page: Page) => { return page - .locator('#component-panel .cm-activeLineGutter') + .locator('#editor-panel-component .cm-activeLineGutter') .first() .innerText() .then(text => text.trim()) @@ -105,7 +208,7 @@ export const runStylesLint = async (page: Page) => { export const getActiveStylesEditorLineNumber = async (page: Page) => { return page - .locator('#styles-panel .cm-activeLineGutter') + .locator('#editor-panel-styles .cm-activeLineGutter') .first() .innerText() .then(text => text.trim()) @@ -123,6 +226,12 @@ export const ensurePanelToolsVisible = async ( page: Page, panelName: 'component' | 'styles', ) => { + if (panelName === 'styles') { + await page.getByRole('tab', { name: 'Open tab app.css' }).click() + } else { + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() + } + const button = getToolsButton(page, panelName) const isPressed = await button.getAttribute('aria-pressed') if (isPressed !== 'true') { diff --git a/playwright/layout-panels.spec.ts b/playwright/layout-panels.spec.ts index 5030d8d..fdadf99 100644 --- a/playwright/layout-panels.spec.ts +++ b/playwright/layout-panels.spec.ts @@ -10,17 +10,13 @@ import { test('renders default playground preview', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('ShadowRoot').uncheck() await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') await expectPreviewHasRenderedContent(page) }) -test('supports layout and theme toggles', async ({ page }) => { +test('supports theme toggles', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await page.getByLabel('Use light theme').click() await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') @@ -33,14 +29,11 @@ test('supports layout and theme toggles', async ({ page }) => { expect(previewBackgroundColor).toBe('rgb(36, 86, 168)') }) -test('side layout keeps preview panel height within editor stack height', async ({ +test('fixed layout keeps preview panel height within editor stack height', async ({ page, }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - const metrics = await page.evaluate(() => { const stack = document.querySelector('.panels-stack--editors') const previewPanel = document.getElementById('preview-panel') @@ -56,13 +49,9 @@ test('side layout keeps preview panel height within editor stack height', async expect(metrics.previewOverflowY).toBe('hidden') }) -test('side layout config keeps preview scrolling inside preview host', async ({ - page, -}) => { +test('fixed layout keeps preview scrolling inside preview host', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - const scrollConfig = await page.evaluate(() => { const previewPanel = document.getElementById('preview-panel') const previewHost = document.getElementById('preview-host') @@ -87,85 +76,61 @@ test('side layout config keeps preview scrolling inside preview host', async ({ expect(scrollConfig?.minHeight).toBe('0px') }) -test('expanded component and styles can shrink consistently in side layouts', async ({ +test('expanded component and styles can shrink consistently in fixed layout', async ({ page, }) => { await waitForInitialRender(page) - for (const layoutLabel of ['Use side preview layout', 'Use left preview layout']) { - await page.getByLabel(layoutLabel).click() - - const minHeights = await page.evaluate(() => { - const component = document.getElementById('component-panel') - const styles = document.getElementById('styles-panel') - return { - component: component - ? Number.parseFloat(getComputedStyle(component).minHeight) - : 0, - styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0, - } - }) - - expect(minHeights.component).toBeGreaterThanOrEqual(0) - expect(minHeights.styles).toBeGreaterThanOrEqual(0) - expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1) - } + const minHeights = await page.evaluate(() => { + const component = document.getElementById('editor-panel-component') + const styles = document.getElementById('editor-panel-styles') + return { + component: component ? Number.parseFloat(getComputedStyle(component).minHeight) : 0, + styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0, + } + }) + + expect(minHeights.component).toBeGreaterThanOrEqual(0) + expect(minHeights.styles).toBeGreaterThanOrEqual(0) + expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1) }) -test('panel collapse axis and direction adapt to active layout', async ({ page }) => { +test('panel collapse axis and direction match fixed layout', async ({ page }) => { await waitForInitialRender(page) - await expect(page.getByRole('main')).toHaveClass(/app-grid/) + await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', - collapsed: false, - }) - await expectCollapseButtonState(page, 'styles', { - axis: 'horizontal', - direction: 'right', - collapsed: false, - }) - await expectCollapseButtonState(page, 'preview', { axis: 'vertical', direction: 'none', collapsed: false, }) - - await page.getByLabel('Use side preview layout').click() - await expectCollapseButtonState(page, 'preview', { - axis: 'horizontal', - direction: 'right', - collapsed: false, - }) - await expectCollapseButtonState(page, 'component', { + await expectCollapseButtonState(page, 'styles', { axis: 'vertical', direction: 'none', collapsed: false, }) - - await page.getByLabel('Use left preview layout').click() await expectCollapseButtonState(page, 'preview', { axis: 'horizontal', - direction: 'left', + direction: 'right', collapsed: false, }) }) test('prevents collapsing all three panels at once', async ({ page }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) - const stylesPanel = page.getByRole('region', { name: 'Styles' }) + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') await getCollapseButton(page, 'component').click() + await page.getByRole('tab', { name: 'Open tab app.css' }).click() await getCollapseButton(page, 'styles').click() - await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) - await expect(stylesPanel).toHaveClass(/panel--collapsed-horizontal/) + await expect(componentPanel).toHaveClass(/panel--collapsed-vertical/) + await expect(stylesPanel).toHaveClass(/panel--collapsed-vertical/) await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', + axis: 'horizontal', + direction: 'right', collapsed: false, disabled: true, }) @@ -174,10 +139,11 @@ test('prevents collapsing all three panels at once', async ({ page }) => { 'At least one panel must remain expanded.', ) + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() await getCollapseButton(page, 'component').click() await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', + axis: 'horizontal', + direction: 'right', collapsed: false, disabled: false, }) @@ -185,13 +151,13 @@ test('prevents collapsing all three panels at once', async ({ page }) => { test('does not persist panel collapse state across reload', async ({ page }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) + const componentPanel = page.locator('#editor-panel-component') await getCollapseButton(page, 'component').click() - await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) + await expect(componentPanel).toHaveClass(/panel--collapsed-vertical/) await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', + axis: 'vertical', + direction: 'none', collapsed: true, }) @@ -202,8 +168,8 @@ test('does not persist panel collapse state across reload', async ({ page }) => /panel--collapsed-horizontal|panel--collapsed-vertical/, ) await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', + axis: 'vertical', + direction: 'none', collapsed: false, }) }) @@ -213,8 +179,8 @@ test('gear tools toggles default inactive and switch active/inactive per panel', }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) - const stylesPanel = page.getByRole('region', { name: 'Styles' }) + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') const componentTools = getToolsButton(page, 'component') const stylesTools = getToolsButton(page, 'styles') @@ -233,8 +199,24 @@ test('gear tools toggles default inactive and switch active/inactive per panel', await expect(componentTools).toHaveAttribute('aria-pressed', 'false') await expect(componentTools).toHaveAttribute('title', 'Show component tools') + await page.getByRole('tab', { name: 'Open tab app.css' }).click() await stylesTools.click() await expect(stylesPanel).not.toHaveClass(/panel--tools-hidden/) await expect(stylesTools).toHaveAttribute('aria-pressed', 'true') await expect(stylesTools).toHaveAttribute('title', 'Hide styles tools') }) + +test('fixed layout keeps inactive editor panel hidden', async ({ page }) => { + await waitForInitialRender(page) + + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') + + const assertEntryPanelVisible = async () => { + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() + await expect(componentPanel).toBeVisible() + await expect(stylesPanel).toBeHidden() + } + + await assertEntryPanelVisible() +}) diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index 2e237b9..57e4b08 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -1,22 +1,47 @@ import { expect, test } from '@playwright/test' import { + addWorkspaceTab, ensureDiagnosticsDrawerOpen, ensurePanelToolsVisible, expectPreviewHasRenderedContent, + getPreviewFrame, + openWorkspaceTab, + resetWorkbenchStorage, runTypecheck, setComponentEditorSource, - setStylesEditorSource, + setWorkspaceTabSource, waitForInitialRender, } from './helpers/app-test-helpers.js' +const renameWorkspaceTab = async ( + page: import('@playwright/test').Page, + { + from, + to, + }: { + from: string + to: string + }, +) => { + await page.getByRole('button', { name: `Rename tab ${from}` }).click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') +} + +test.beforeEach(async ({ page }) => { + await resetWorkbenchStorage(page) +}) + test('renders in react mode with css modules', async ({ page }) => { await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') await ensurePanelToolsVisible(page, 'styles') - await page.getByLabel('ShadowRoot').uncheck() + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await page.getByRole('tab', { name: 'Open tab app.css' }).click() await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') await expectPreviewHasRenderedContent(page) @@ -25,7 +50,6 @@ test('renders in react mode with css modules', async ({ page }) => { test('transpiles TypeScript annotations in component source', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, [ @@ -35,9 +59,7 @@ test('transpiles TypeScript annotations in component source', async ({ page }) = ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('typed') + await expect(getPreviewFrame(page).getByRole('button')).toContainText('typed') }) test('dom mode supports type-only imports without runtime export syntax errors', async ({ @@ -46,7 +68,6 @@ test('dom mode supports type-only imports without runtime export syntax errors', await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await page.getByRole('combobox', { name: 'Render mode' }).selectOption('dom') await setComponentEditorSource( @@ -70,9 +91,9 @@ test('dom mode supports type-only imports without runtime export syntax errors', await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') await expect(page.locator('#preview-host pre')).toHaveCount(0) - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('typed children import') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'typed children import', + ) }) test('react mode typecheck loads types without malformed URL fetches', async ({ @@ -90,6 +111,14 @@ test('react mode typecheck loads types without malformed URL fetches', async ({ } }) + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'const App = () => ', + ].join('\n'), + ) + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') await page.getByRole('button', { name: 'Typecheck' }).click() @@ -146,7 +175,6 @@ test('react mode executes default React import without TDZ runtime failure', asy await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') await setComponentEditorSource( page, @@ -157,12 +185,48 @@ test('react mode executes default React import without TDZ runtime failure', asy ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('react default import works') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'react default import works', + ) await expect(page.locator('#preview-host pre')).toHaveCount(0) }) +test('react mode mounts into internal non-div host to avoid div selector bleed', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + await openWorkspaceTab(page, 'App.tsx') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['div { border: 1px dotted green; }'].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'export const App = () => (', + ' <>', + '
inner
', + ' ', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await expect(getPreviewFrame(page).locator('body > knighted-preview-root')).toHaveCount( + 1, + ) +}) + test('clearing component source reports clear action without error status', async ({ page, }) => { @@ -173,9 +237,7 @@ test('clearing component source reports clear action without error status', asyn await expect(dialog).toHaveAttribute('open', '') await dialog.getByRole('button', { name: 'Clear' }).click() - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(0) + await expect(getPreviewFrame(page).getByRole('button')).toHaveCount(0) await expect(page.locator('#preview-host pre')).toHaveCount(0) await expect(page.getByRole('status', { name: 'App status' })).toHaveText( 'Component cleared', @@ -205,6 +267,146 @@ test('jsx syntax errors affect status but not diagnostics toggle severity', asyn await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) }) +test('high-signal runtime errors surface as runtime diagnostics without uncaught page errors', async ({ + page, +}) => { + await waitForInitialRender(page) + + const pageErrors: string[] = [] + page.on('pageerror', error => { + pageErrors.push(error.message) + }) + + await setComponentEditorSource( + page, + ["const App = () => { throw new TypeError('intentional runtime failure') }"].join( + '\n', + ), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('[runtime]') + await expect(page.locator('#preview-host pre')).toContainText( + 'intentional runtime failure', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'Entry: @knighted/workspace/', + ) + await expect(page.locator('#preview-host pre')).toContainText('Source:') + + expect(pageErrors).toEqual([]) +}) + +test('editing-transient missing reference runtime errors are suppressed', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import { useState, useCallback } from 'react'", + '', + 'const App = () => {', + ' const [count, setCount] = useState(0)', + ' const handleOnClick = useCallback(() => {', + ' setCount(count + 1)', + ' }, [count])', + ' co', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(page.getByRole('status', { name: 'App status' })).not.toHaveText('Error') +}) + +test('post-render runtime exceptions from iframe are reported in preview panel', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'export const App = () => (', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await getPreviewFrame(page).getByRole('button', { name: 'click boom' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('[runtime]') + await expect(page.locator('#preview-host pre')).toContainText('clicked boom') +}) + +test('post-render runtime errors fully recover after source fix', async ({ page }) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import React, { useState } from 'react'", + 'export const App = () => {', + ' const [count, setCount] = useState(0)', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await getPreviewFrame(page).getByRole('button', { name: 'click boom' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('clicked boom') + + await setComponentEditorSource( + page, + [ + "import React, { useState } from 'react'", + 'export const App = () => {', + ' const [count, setCount] = useState(0)', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await getPreviewFrame(page).getByRole('button', { name: 'safe click 0' }).click() + await expect( + getPreviewFrame(page).getByRole('button', { name: 'safe click 1' }), + ).toBeVisible() + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + test('requires render button when auto render is disabled', async ({ page }) => { await waitForInitialRender(page) @@ -213,12 +415,14 @@ test('requires render button when auto render is disabled', async ({ page }) => const autoRenderToggle = page.getByLabel('Auto render') const renderButton = page.getByRole('button', { name: 'Render' }) - const styleMode = page.getByRole('combobox', { name: 'Style mode' }) + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() await autoRenderToggle.uncheck() await expect(renderButton).toBeVisible() - await styleMode.selectOption('module') + await page.getByRole('tab', { name: 'Open tab app.css' }).click() + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() await renderButton.click() await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') @@ -231,16 +435,15 @@ test('clears preview when auto render is toggled', async ({ page }) => { await ensurePanelToolsVisible(page, 'component') const autoRenderToggle = page.getByLabel('Auto render') + const previewHost = page.locator('#preview-host') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(1) + await expect + .poll(() => previewHost.evaluate(node => node.childElementCount)) + .toBeGreaterThan(0) await autoRenderToggle.uncheck() - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(0) + await expect.poll(() => previewHost.evaluate(node => node.childElementCount)).toBe(0) await expect(page.locator('#preview-host pre')).toHaveCount(0) }) @@ -274,7 +477,6 @@ test('auto render implicitly wraps source with App in dom and react modes', asyn await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, @@ -282,9 +484,9 @@ test('auto render implicitly wraps source with App in dom and react modes', asyn ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app dom') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'implicit app dom', + ) await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') await setComponentEditorSource( @@ -293,9 +495,9 @@ test('auto render implicitly wraps source with App in dom and react modes', asyn ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app react') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'implicit app react', + ) }) test('auto render implicit App includes multiple component declarations', async ({ @@ -304,7 +506,6 @@ test('auto render implicit App includes multiple component declarations', async await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, @@ -315,12 +516,8 @@ test('auto render implicit App includes multiple component declarations', async ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(2) - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText(['bar', 'foo']) + await expect(getPreviewFrame(page).getByRole('button')).toHaveCount(2) + await expect(getPreviewFrame(page).getByRole('button')).toContainText(['bar', 'foo']) }) test('auto render does not treat lowercase helpers as implicit components', async ({ @@ -329,7 +526,6 @@ test('auto render does not treat lowercase helpers as implicit components', asyn await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, @@ -351,7 +547,6 @@ test('auto render wraps standalone JSX with trailing semicolon and comment', asy await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, @@ -359,9 +554,9 @@ test('auto render wraps standalone JSX with trailing semicolon and comment', asy ) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app from jsx expression') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'implicit app from jsx expression', + ) }) test('auto render requires explicit App for declarations plus top-level JSX expression', async ({ @@ -370,7 +565,6 @@ test('auto render requires explicit App for declarations plus top-level JSX expr await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() await setComponentEditorSource( page, @@ -387,158 +581,359 @@ test('auto render requires explicit App for declarations plus top-level JSX expr ) }) -test('renders export default arrow component when auto render is disabled', async ({ - page, -}) => { +test('persists theme across reload with fixed layout', async ({ page }) => { + await waitForInitialRender(page) + + await page.getByLabel('Use light theme').click() + await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + + await page.reload() + await waitForInitialRender(page) + + await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') +}) + +test('persists render mode across reload', async ({ page }) => { await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - await page.getByLabel('Auto render').uncheck() + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await setComponentEditorSource( - page, - 'export default () => ', - ) + await page.reload() + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + + await expect(page.getByRole('combobox', { name: 'Render mode' })).toHaveValue('react') +}) - await page.getByRole('button', { name: 'Render' }).click() +test('renders with less style mode', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('less') await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('default export arrow') + await expectPreviewHasRenderedContent(page) +}) + +test('renders with sass style mode', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expectPreviewHasRenderedContent(page) }) -test('renders export default class component in react mode', async ({ page }) => { +test('workspace tabs isolate duplicate exported identifiers in iframe module scope', async ({ + page, +}) => { await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await page.getByLabel('Auto render').uncheck() + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: 'export const Button = () => ', + }) + + await openWorkspaceTab(page, 'App.tsx') await setComponentEditorSource( page, [ - "import React from 'react'", - 'export default class extends React.Component {', - ' render() {', - ' return ', - ' }', - '}', + "import { Button as WorkspaceButton } from './module'", + 'const Button = () => ', + 'export const App = () => (', + ' <>', + ' ', - '}', - 'export default App', + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await setWorkspaceTabSource(page, { + fileName: 'module-2.tsx', + source: "export const label = 'extensionless import ok'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + "import { label } from './module-2'", + 'export const Button = () => ', ].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { Button } from './module'", + 'export const App = () => ', + ].join('\n'), + }) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), - ).toContainText('export default App') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'js specifier to tsx fallback', + ) }) -test('auto render supports export default named component without App redeclaration', async ({ +test('workspace graph errors are deterministic for ambiguous extension compatibility matches', async ({ page, }) => { await waitForInitialRender(page) await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'module.ts', + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const label = 'from tsx'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.ts', + source: "export const label = 'from ts'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { label } from './module.js'", + 'export const App = () => ', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references ambiguous workspace module: ./module.js', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'src/components/module.ts', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'src/components/module.tsx', + ) +}) + +test('workspace graph errors for missing modules remain deterministic', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') await setComponentEditorSource( page, [ - 'const Button = () => ', - 'export default Button', + "import { MissingThing } from './does-not-exist'", + 'export const App = () => ', ].join('\n'), ) - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), - ).toContainText('export default Button') - await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references missing workspace module: ./does-not-exist', + ) }) -test('persists layout and theme across reload', async ({ page }) => { +test('workspace graph errors for circular imports remain deterministic', async ({ + page, +}) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - await page.getByLabel('Use light theme').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await ensurePanelToolsVisible(page, 'component') - await page.reload() - await waitForInitialRender(page) + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: ["import { App } from './App'", 'export const ping = () => App'].join('\n'), + }) - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { ping } from './module'", + 'export const App = () => ', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry contains circular workspace import:', + ) + await expect(page.locator('#preview-host pre')).toContainText('Import chain: ./module') }) -test('renders with less style mode', async ({ page }) => { +test('children runtime errors recover after module fix and mode switches', async ({ + page, +}) => { await waitForInitialRender(page) - await ensurePanelToolsVisible(page, 'styles') + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page) - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('less') - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) -}) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + 'export const ItemWrap = ({ children: string }) => {', + ' return {children}', + '}', + ].join('\n'), + }) -test('renders with sass style mode', async ({ page }) => { - await waitForInitialRender(page) + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { ItemWrap } from './module.tsx'", + 'export const App = () => (', + '
', + ' hello children', + '
', + ')', + ].join('\n'), + }) - await ensurePanelToolsVisible(page, 'styles') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + /\[runtime\]\s+(children is not defined|Can't find variable: children)/, + ) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + 'export const ItemWrap = ({ children }: { children: string }) => {', + ' return {children}', + '}', + ].join('\n'), + }) - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('dom') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() }) -test('style compilation errors populate styles diagnostics scope', async ({ page }) => { +test('auto-render skips unrelated component tab edits outside entry dependency graph', async ({ + page, +}) => { await waitForInitialRender(page) - await ensurePanelToolsVisible(page, 'styles') + await ensurePanelToolsVisible(page, 'component') - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const value = 'first'", + }) - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') - await expect(page.getByRole('button', { name: 'Diagnostics' })).toHaveClass( - /diagnostics-toggle--error/, - ) + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: "export const App = () => ", + }) - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-styles')).toContainText( - 'Style compilation failed.', - ) - await expect(page.locator('#diagnostics-styles')).toContainText('Undefined variable') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + const pendingWatcher = page.evaluate(() => { + const status = document.getElementById('status') + + return new Promise(resolve => { + if (!status) { + resolve(false) + return + } + + let sawPending = false + const observer = new MutationObserver(() => { + if (status.textContent?.trim() === 'Rendering…') { + sawPending = true + } + }) + + observer.observe(status, { + childList: true, + subtree: true, + characterData: true, + }) + + setTimeout(() => { + observer.disconnect() + resolve(sawPending) + }, 700) + }) + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const value = 'second'", + }) + + await expect(pendingWatcher).resolves.toBe(false) }) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts new file mode 100644 index 0000000..1a1b5f6 --- /dev/null +++ b/playwright/workspace-tabs.spec.ts @@ -0,0 +1,209 @@ +import { expect, test } from '@playwright/test' +import { + addWorkspaceTab, + setWorkspaceTabSource, + waitForInitialRender, +} from './helpers/app-test-helpers.js' + +const confirmRemoveDialog = async (page: import('@playwright/test').Page) => { + 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() + } + }) +} + +const renameWorkspaceTab = async ( + page: import('@playwright/test').Page, + { + from, + to, + }: { + from: string + to: string + }, +) => { + await page.getByRole('button', { name: `Rename tab ${from}` }).click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') +} + +test('removing active tab selects deterministic adjacent tab', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('tab', { name: 'Open tab module-2.tsx' }).click() + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click() + await confirmRemoveDialog(page) + + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveCount(0) + await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) +}) + +test('removing non-active tab does not change active tab', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('tab', { name: 'Open tab module-3.tsx' }).click() + await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click() + await confirmRemoveDialog(page) + + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveCount(0) + await expect(page.getByRole('tab', { name: 'Open tab module-3.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) +}) + +test('renaming module tab keeps name and path synchronized', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'card-item.tsx', + }) + + const tab = page.getByRole('tab', { name: 'Open tab card-item.tsx' }) + await expect(tab).toHaveAttribute('title', 'src/components/card-item.tsx') + await expect(page.getByRole('tab', { name: 'Open tab module.tsx' })).toHaveCount(0) +}) + +test('renaming module tab preserves source content', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: 'export const Value = () =>

Kept

', + kind: 'component', + }) + + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'value-card.tsx', + }) + + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() + await page.getByRole('tab', { name: 'Open tab value-card.tsx' }).click() + + const editorContent = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(editorContent).toContainText('export const Value = () =>

Kept

') +}) + +test('active tab remains source of truth for visible editor panel', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') + + await page.getByRole('tab', { name: 'Open tab app.css' }).click() + await expect(page.getByRole('tab', { name: 'Open tab app.css' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(stylesPanel).not.toHaveAttribute('hidden', '') + await expect(componentPanel).toHaveAttribute('hidden', '') + + await page.getByRole('tab', { name: 'Open tab module-2.tsx' }).click() + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(componentPanel).not.toHaveAttribute('hidden', '') + await expect(stylesPanel).toHaveAttribute('hidden', '') + + await page.locator('#collapse-component').click() + await page.getByRole('tab', { name: 'Open tab app.css' }).click() + + await expect(page.getByRole('tab', { name: 'Open tab app.css' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(stylesPanel).not.toHaveAttribute('hidden', '') + await expect(componentPanel).toHaveAttribute('hidden', '') +}) + +test('startup restores last active workspace tab after reload', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('tab', { name: 'Open tab module-2.tsx' }).click() + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + await page.reload() + await waitForInitialRender(page) + + await expect(page.getByRole('tab', { name: 'Open tab module-2.tsx' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(page.locator('#editor-panel-component')).not.toHaveAttribute('hidden', '') + await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '') +}) + +test('add menu can create styles tab while component tab is active', async ({ page }) => { + await waitForInitialRender(page) + + await page.getByRole('tab', { name: 'Open tab App.tsx' }).click() + await addWorkspaceTab(page, { kind: 'styles' }) + + await expect(page.getByRole('tab', { name: 'Open tab module.css' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(page.locator('#editor-panel-styles')).not.toHaveAttribute('hidden', '') + await expect(page.locator('#editor-panel-component')).toHaveAttribute('hidden', '') + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + 'Added style tab.', + ) +}) + +test('add menu stays closed until triggered and closes on outside click', async ({ + page, +}) => { + await waitForInitialRender(page) + + const addButton = page.getByRole('button', { name: 'Add tab options' }) + const addMenu = page.getByRole('menu', { name: 'Add tab type' }) + + await expect(addMenu).toBeHidden() + await addButton.click() + await expect(addMenu).toBeVisible() + + await page.getByRole('status', { name: 'App status' }).click() + await expect(addMenu).toBeHidden() +}) diff --git a/src/app.js b/src/app.js index 81d6ef8..741ddfe 100644 --- a/src/app.js +++ b/src/app.js @@ -5,7 +5,7 @@ import { importFromCdnWithFallback, } from './modules/cdn.js' import { createCodeMirrorEditor } from './modules/editor-codemirror.js' -import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' +import { defaultCss, defaultJsx } from './modules/defaults.js' import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github-chat-drawer/drawer.js' import { createGitHubByotControls } from './modules/github-byot-controls.js' @@ -22,6 +22,12 @@ import { createRenderRuntimeController } from './modules/render-runtime.js' import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' import { collectTopLevelDeclarations } from './modules/jsx-top-level-declarations.js' import { ensureJsxTransformSource } from './modules/jsx-transform-runtime.js' +import { createEditorPoolManager } from './modules/editor-pool-manager.js' +import { createWorkspaceTabsState } from './modules/workspace-tabs-state.js' +import { + createDebouncedWorkspaceSaver, + createWorkspaceStorageAdapter, +} from './modules/workspace-storage.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -61,20 +67,32 @@ const githubPrTitle = document.getElementById('github-pr-title') const githubPrBody = document.getElementById('github-pr-body') const githubPrCommitMessage = document.getElementById('github-pr-commit-message') const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app-wrapper') +const githubPrLocalContextSelect = document.getElementById( + 'github-pr-local-context-select', +) +const githubPrLocalContextRemove = document.getElementById( + 'github-pr-local-context-remove', +) const githubPrSubmit = document.getElementById('github-pr-submit') const componentPrSyncIcon = document.getElementById('component-pr-sync-icon') const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path') const stylesPrSyncIcon = document.getElementById('styles-pr-sync-icon') const stylesPrSyncIconPath = document.getElementById('styles-pr-sync-icon-path') -const viewControlsToggle = document.getElementById('view-controls-toggle') -const viewControlsDrawer = document.getElementById('view-controls-drawer') +const componentEditorHeaderLabel = document.querySelector('#editor-header-component span') +const stylesEditorHeaderLabel = document.querySelector('#editor-header-styles span') const aiControlsToggle = document.getElementById('ai-controls-toggle') -const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') +const workspaceTabsShell = document.getElementById('workspace-tabs-shell') +const workspaceTabsStrip = document.getElementById('workspace-tabs-strip') +const workspaceTabAddWrap = document.getElementById('workspace-tab-add-wrap') +const workspaceTabAddButton = document.getElementById('workspace-tab-add') +const workspaceTabAddMenu = document.getElementById('workspace-tab-add-menu') +const workspaceTabAddModule = document.getElementById('workspace-tab-add-module') +const workspaceTabAddStyles = document.getElementById('workspace-tab-add-styles') const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') const panelCollapseButtons = document.querySelectorAll('[data-panel-collapse]') -const componentPanel = document.getElementById('component-panel') -const stylesPanel = document.getElementById('styles-panel') +const componentEditorPanel = document.getElementById('editor-panel-component') +const stylesEditorPanel = document.getElementById('editor-panel-styles') const previewPanel = document.getElementById('preview-panel') const renderMode = document.getElementById('render-mode') const autoRenderToggle = document.getElementById('auto-render') @@ -87,7 +105,6 @@ const clearComponentButton = document.getElementById('clear-component') const styleMode = document.getElementById('style-mode') const copyStylesButton = document.getElementById('copy-styles') const clearStylesButton = document.getElementById('clear-styles') -const shadowToggle = document.getElementById('shadow-toggle') const jsxEditor = document.getElementById('jsx-editor') const cssEditor = document.getElementById('css-editor') const diagnosticsToggle = document.getElementById('diagnostics-toggle') @@ -106,6 +123,26 @@ const clearConfirmTitle = document.getElementById('clear-confirm-title') const clearConfirmCopy = document.getElementById('clear-confirm-copy') const clearConfirmButton = clearConfirmDialog?.querySelector('button[value="confirm"]') +const defaultComponentTabPath = 'src/components/App.tsx' +const defaultStylesTabPath = 'src/styles/app.css' +const defaultComponentTabName = 'App.tsx' +const defaultStylesTabName = 'app.css' +const defaultEntryTabDirectory = 'src/components' +const allowedEntryTabFileNames = new Set(['app.tsx', 'app.js']) +const editorKinds = ['component', 'styles'] +const editorPanelsByKind = { + component: componentEditorPanel, + styles: stylesEditorPanel, +} +const editorHeaderLabelByKind = { + component: componentEditorHeaderLabel, + styles: stylesEditorHeaderLabel, +} +const defaultTabNameByKind = { + component: defaultComponentTabName, + styles: defaultStylesTabName, +} + jsxEditor.value = defaultJsx cssEditor.value = defaultCss @@ -117,8 +154,43 @@ let getCssSource = () => cssEditor.value let renderRuntime = null let pendingClearAction = null let suppressEditorChangeSideEffects = false -let hasAppliedReactModeDefault = false let appToastDismissTimer = null +const workspaceStorage = createWorkspaceStorageAdapter() +let workspaceSaver = null +let activeWorkspaceRecordId = '' +let activeWorkspaceCreatedAt = null +let isApplyingWorkspaceSnapshot = false +let hasCompletedInitialWorkspaceBootstrap = false +const workspaceTabsState = createWorkspaceTabsState({ + tabs: [ + { + id: 'component', + name: defaultComponentTabName, + path: defaultComponentTabPath, + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: defaultJsx, + }, + { + id: 'styles', + name: defaultStylesTabName, + path: defaultStylesTabPath, + language: 'css', + role: 'module', + isActive: false, + content: defaultCss, + }, + ], + activeTabId: 'component', +}) +const editorPool = createEditorPoolManager({ maxMounted: 2 }) +let workspaceTabRenameState = { + tabId: '', +} +let workspaceTabAddMenuOpen = false +let isRenderingWorkspaceTabs = false +let hasPendingWorkspaceTabsRender = false const clipboardSupported = Boolean(navigator.clipboard?.writeText) const githubPrOpenIcon = { viewBox: '0 0 16 16', @@ -157,50 +229,17 @@ const previewBackground = createPreviewBackgroundController({ }) const layoutTheme = createLayoutThemeController({ - appGrid, - appGridLayoutButtons, appThemeButtons, syncPreviewBackgroundPickerFromTheme: () => previewBackground.syncPreviewBackgroundPickerFromTheme(), }) -const { applyAppGridLayout, applyTheme, getInitialAppGridLayout, getInitialTheme } = - layoutTheme +const { applyTheme, getInitialTheme } = layoutTheme const compactViewportMediaQuery = window.matchMedia('(max-width: 900px)') -const stackedRailMediaQuery = window.matchMedia('(max-width: 1090px)') -let stackedRailViewControlsOpen = false let compactAiControlsOpen = false let githubTokenInfoOpen = false -const isStackedRailViewport = () => stackedRailMediaQuery.matches - -const setStackedRailViewControlsOpen = isOpen => { - if (!(viewControlsToggle instanceof HTMLButtonElement) || !viewControlsDrawer) { - return - } - - if (!isStackedRailViewport()) { - stackedRailViewControlsOpen = false - viewControlsToggle.setAttribute('aria-expanded', 'false') - viewControlsDrawer.removeAttribute('hidden') - return - } - - stackedRailViewControlsOpen = Boolean(isOpen) - viewControlsToggle.setAttribute( - 'aria-expanded', - stackedRailViewControlsOpen ? 'true' : 'false', - ) - - if (stackedRailViewControlsOpen) { - viewControlsDrawer.removeAttribute('hidden') - return - } - - viewControlsDrawer.setAttribute('hidden', '') -} - const setGitHubTokenInfoOpen = isOpen => { if (!(githubTokenInfo instanceof HTMLButtonElement) || !githubTokenInfoPanel) { return @@ -242,18 +281,6 @@ const setCompactAiControlsOpen = isOpen => { } } -const getCurrentLayout = () => { - if (appGrid.classList.contains('app-grid--preview-right')) { - return 'preview-right' - } - - if (appGrid.classList.contains('app-grid--preview-left')) { - return 'preview-left' - } - - return 'default' -} - const isCompactViewport = () => compactViewportMediaQuery.matches const getPanelCollapseAxis = panelName => { @@ -261,14 +288,12 @@ const getPanelCollapseAxis = panelName => { return 'vertical' } - const layout = getCurrentLayout() - if (panelName === 'preview') { - return layout === 'default' ? 'vertical' : 'horizontal' + return 'horizontal' } if (panelName === 'component' || panelName === 'styles') { - return layout === 'default' ? 'horizontal' : 'vertical' + return 'vertical' } return 'vertical' @@ -280,10 +305,8 @@ const getPanelCollapseDirection = panelName => { return 'none' } - const layout = getCurrentLayout() - if (panelName === 'preview') { - return layout === 'preview-left' ? 'left' : 'right' + return 'right' } if (panelName === 'component') { @@ -309,8 +332,12 @@ const panelToolsState = { } const applyEditorToolsVisibility = () => { - componentPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.component) - stylesPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.styles) + for (const editorKind of editorKinds) { + editorPanelsByKind[editorKind]?.classList.toggle( + 'panel--tools-hidden', + !panelToolsState[editorKind], + ) + } for (const button of editorToolsButtons) { const panelName = button.dataset.editorToolsToggle @@ -376,25 +403,25 @@ const applyPanelCollapseState = () => { const componentAxis = getPanelCollapseAxis('component') const stylesAxis = getPanelCollapseAxis('styles') - if (componentPanel) { + if (componentEditorPanel) { const isCollapsed = panelCollapseState.component - componentPanel.classList.toggle( + componentEditorPanel.classList.toggle( 'panel--collapsed-vertical', isCollapsed && componentAxis === 'vertical', ) - componentPanel.classList.toggle( + componentEditorPanel.classList.toggle( 'panel--collapsed-horizontal', isCollapsed && componentAxis === 'horizontal', ) } - if (stylesPanel) { + if (stylesEditorPanel) { const isCollapsed = panelCollapseState.styles - stylesPanel.classList.toggle( + stylesEditorPanel.classList.toggle( 'panel--collapsed-vertical', isCollapsed && stylesAxis === 'vertical', ) - stylesPanel.classList.toggle( + stylesEditorPanel.classList.toggle( 'panel--collapsed-horizontal', isCollapsed && stylesAxis === 'horizontal', ) @@ -581,145 +608,1238 @@ const setGitHubPrToggleVisual = mode => { return } - const isPushCommitMode = mode === 'push-commit' - const label = isPushCommitMode ? 'Push' : 'Open PR' - const title = isPushCommitMode - ? 'Push commit to active pull request branch' - : 'Open pull request' - const icon = isPushCommitMode ? githubPrPushCommitIcon : githubPrOpenIcon + const isPushCommitMode = mode === 'push-commit' + const label = isPushCommitMode ? 'Push' : 'Open PR' + const title = isPushCommitMode + ? 'Push commit to active pull request branch' + : 'Open pull request' + const icon = isPushCommitMode ? githubPrPushCommitIcon : githubPrOpenIcon + + githubPrToggleLabel.textContent = label + githubPrToggle.title = title + githubPrToggle.setAttribute('aria-label', title) + githubPrToggleIcon.setAttribute('viewBox', icon.viewBox) + githubPrToggleIconPath.setAttribute('d', icon.path) +} + +const syncEditorPrContextIndicators = shouldShow => { + const iconNodes = [componentPrSyncIcon, stylesPrSyncIcon] + const iconPathNodes = [componentPrSyncIconPath, stylesPrSyncIconPath] + + for (const iconPath of iconPathNodes) { + if (iconPath instanceof SVGPathElement) { + iconPath.setAttribute('d', githubPrOpenIcon.path) + } + } + + for (const icon of iconNodes) { + if (!(icon instanceof SVGElement)) { + continue + } + + icon.setAttribute('viewBox', githubPrOpenIcon.viewBox) + icon.dataset.visible = shouldShow ? 'true' : 'false' + icon.toggleAttribute('hidden', !shouldShow) + } +} + +const syncActivePrContextUi = activeContext => { + githubAiContextState.activePrContext = activeContext ?? null + const nextSyncKey = getActivePrContextSyncKey(activeContext) + + if (!nextSyncKey) { + githubAiContextState.activePrEditorSyncKey = '' + githubAiContextState.hasSyncedActivePrEditorContent = false + } else if (githubAiContextState.activePrEditorSyncKey !== nextSyncKey) { + githubAiContextState.activePrEditorSyncKey = nextSyncKey + githubAiContextState.hasSyncedActivePrEditorContent = false + } + + const hasActiveContext = Boolean(activeContext?.prTitle) + const shouldShowEditorSyncIndicators = + hasActiveContext && githubAiContextState.hasSyncedActivePrEditorContent + + setGitHubPrToggleVisual(hasActiveContext ? 'push-commit' : 'open-pr') + syncEditorPrContextIndicators(shouldShowEditorSyncIndicators) + + if (!hasActiveContext) { + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') + return + } + + githubPrContextClose?.removeAttribute('hidden') + githubPrContextDisconnect?.removeAttribute('hidden') +} + +const syncAiChatTokenVisibility = token => { + const hasToken = typeof token === 'string' && token.trim().length > 0 + + if (hasToken) { + aiChatToggle?.removeAttribute('hidden') + + githubPrToggle?.removeAttribute('hidden') + + if (githubAiContextState.activePrContext) { + githubPrContextClose?.removeAttribute('hidden') + githubPrContextDisconnect?.removeAttribute('hidden') + } else { + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') + } + return + } + + aiChatToggle?.setAttribute('hidden', '') + aiChatToggle?.setAttribute('aria-expanded', 'false') + githubAiContextState.activePrContext = null + githubAiContextState.activePrEditorSyncKey = '' + githubAiContextState.hasSyncedActivePrEditorContent = false + syncEditorPrContextIndicators(false) + setGitHubPrToggleVisual('open-pr') + githubPrToggle?.setAttribute('hidden', '') + githubPrToggle?.setAttribute('aria-expanded', 'false') + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') + chatDrawerController.setOpen(false) + prDrawerController.setOpen(false) +} + +const byotControls = createGitHubByotControls({ + controlsRoot: githubAiControls, + tokenInput: githubTokenInput, + tokenInfoButton: githubTokenInfo, + tokenAddButton: githubTokenAdd, + tokenDeleteButton: githubTokenDelete, + onRepositoryChange: repository => { + githubAiContextState.selectedRepository = repository + chatDrawerController.setSelectedRepository(repository) + prDrawerController.setSelectedRepository(repository) + + activeWorkspaceRecordId = '' + activeWorkspaceCreatedAt = null + void loadPreferredWorkspaceContext().catch(() => { + /* noop */ + }) + }, + onWritableRepositoriesChange: ({ repositories }) => { + githubAiContextState.writableRepositories = Array.isArray(repositories) + ? [...repositories] + : [] + prDrawerController.syncRepositories() + }, + onTokenDeleteRequest: onConfirm => { + confirmAction({ + title: 'Remove saved GitHub token?', + copy: 'This action removes the token from browser storage. You can add another token at any time.', + confirmButtonText: 'Remove', + onConfirm, + }) + }, + onTokenChange: token => { + githubAiContextState.token = token + syncAiChatTokenVisibility(token) + chatDrawerController.setToken(token) + prDrawerController.setToken(token) + }, + setStatus, +}) + +githubAiContextState.selectedRepository = byotControls.getSelectedRepository() +githubAiContextState.token = byotControls.getToken() +githubAiContextState.writableRepositories = byotControls.getWritableRepositories() + +const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.getToken() + +const getCurrentSelectedRepository = () => + githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() + +const toWorkspaceIdentitySegment = value => { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '' + + if (!normalized) { + return '' + } + + return normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') +} + +const toWorkspaceRecordId = ({ repositoryFullName, headBranch }) => { + const repoSegment = toWorkspaceIdentitySegment(repositoryFullName) + const headSegment = toWorkspaceIdentitySegment(headBranch) || 'draft' + + if (repoSegment) { + return `repo_${repoSegment}_${headSegment}` + } + + return `workspace_${headSegment}` +} + +const getWorkspaceContextSnapshot = () => { + return { + repositoryFullName: getCurrentSelectedRepository(), + baseBranch: + typeof githubPrBaseBranch?.value === 'string' + ? githubPrBaseBranch.value.trim() + : '', + headBranch: + typeof githubPrHeadBranch?.value === 'string' + ? githubPrHeadBranch.value.trim() + : '', + prTitle: typeof githubPrTitle?.value === 'string' ? githubPrTitle.value.trim() : '', + } +} + +const styleTabLanguages = new Set(['css', 'less', 'sass', 'module']) +let loadedComponentTabId = 'component' +let loadedStylesTabId = 'styles' + +const toNonEmptyWorkspaceText = value => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : '' + +const isStyleTabLanguage = language => + styleTabLanguages.has(toNonEmptyWorkspaceText(language)) + +const getTabKind = tab => (isStyleTabLanguage(tab?.language) ? 'styles' : 'component') + +const getWorkspaceTabByKind = kind => { + const tabs = workspaceTabsState.getTabs() + const normalizedKind = kind === 'styles' ? 'styles' : 'component' + return ( + tabs.find( + tab => + getTabKind(tab) === normalizedKind && + tab.id === workspaceTabsState.getActiveTabId(), + ) ?? + tabs.find(tab => getTabKind(tab) === normalizedKind) ?? + null + ) +} + +const getActiveWorkspaceTab = () => + workspaceTabsState.getTab(workspaceTabsState.getActiveTabId()) + +const toStyleModeForTabLanguage = language => { + const normalized = toNonEmptyWorkspaceText(language) + if (normalized === 'less') { + return 'less' + } + + if (normalized === 'sass') { + return 'sass' + } + + if (normalized === 'module') { + return 'module' + } + + return 'css' +} + +const syncHeaderLabels = () => { + for (const editorKind of editorKinds) { + const tab = + editorKind === 'styles' + ? (workspaceTabsState.getTab(loadedStylesTabId) ?? + getWorkspaceTabByKind('styles')) + : (workspaceTabsState.getTab(loadedComponentTabId) ?? + getWorkspaceTabByKind('component')) + const headerLabel = editorHeaderLabelByKind[editorKind] + + if (headerLabel) { + headerLabel.textContent = + toNonEmptyWorkspaceText(tab?.name) || defaultTabNameByKind[editorKind] + } + } +} + +const persistActiveTabEditorContent = () => { + const activeTab = getActiveWorkspaceTab() + + if (!activeTab) { + return + } + + const nextContent = getTabKind(activeTab) === 'styles' ? getCssSource() : getJsxSource() + + if (nextContent === activeTab.content) { + return + } + + workspaceTabsState.upsertTab( + { + ...activeTab, + content: nextContent, + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'tabContentSync' }, + ) +} + +const loadWorkspaceTabIntoEditor = tab => { + if (!tab || typeof tab !== 'object') { + return + } + + const nextContent = typeof tab.content === 'string' ? tab.content : '' + + if (getTabKind(tab) === 'styles') { + loadedStylesTabId = tab.id + setCssSource(nextContent) + const nextStyleMode = toStyleModeForTabLanguage(tab.language) + if (styleMode.value !== nextStyleMode) { + styleMode.value = nextStyleMode + } + if (cssCodeEditor) { + suppressEditorChangeSideEffects = true + try { + cssCodeEditor.setLanguage(getStyleEditorLanguage(nextStyleMode)) + } finally { + suppressEditorChangeSideEffects = false + } + } + setVisibleEditorPanelForKind('styles') + editorPool.activate('styles') + } else { + loadedComponentTabId = tab.id + setJsxSource(nextContent) + setVisibleEditorPanelForKind('component') + editorPool.activate('component') + } + + syncHeaderLabels() +} + +const createWorkspaceTabId = prefix => { + const seed = Math.random().toString(36).slice(2, 8) + return `${prefix}-${Date.now().toString(36)}-${seed}` +} + +const splitWorkspacePath = value => { + const normalized = toNonEmptyWorkspaceText(value) + if (!normalized) { + return [] + } + + return normalized.split(/[\\/]+/).filter(Boolean) +} + +const getPathFileName = path => { + const segments = splitWorkspacePath(path) + return segments.length > 0 ? segments[segments.length - 1] : '' +} + +const getPathDirectory = path => { + const segments = splitWorkspacePath(path) + if (segments.length <= 1) { + return defaultEntryTabDirectory + } + + return segments.slice(0, -1).join('/') +} + +const normalizeEntryTabName = value => { + const normalized = toNonEmptyWorkspaceText(value) + if (allowedEntryTabFileNames.has(normalized.toLowerCase())) { + return normalized + } + + return defaultComponentTabName +} + +const getWorkspaceTabDisplay = tab => { + const fullPath = + toNonEmptyWorkspaceText(tab?.path) || toNonEmptyWorkspaceText(tab?.name) + const explicitName = toNonEmptyWorkspaceText(tab?.name) + const explicitFileName = getPathFileName(explicitName) + return { + fileName: explicitFileName || explicitName || getPathFileName(fullPath), + fullPath, + } +} + +const normalizeEntryTabPath = (path, { preferredFileName = '' } = {}) => { + const normalizedPath = toNonEmptyWorkspaceText(path) + const directory = getPathDirectory(normalizedPath || defaultComponentTabPath) + const requestedFileName = + toNonEmptyWorkspaceText(preferredFileName) || + getPathFileName(normalizedPath || defaultComponentTabPath) + const fileName = normalizeEntryTabName(requestedFileName) + + return `${directory}/${fileName}` +} + +const normalizeModuleTabPathForRename = (path, nextName) => { + const currentPath = toNonEmptyWorkspaceText(path) + const normalizedNextName = toNonEmptyWorkspaceText(nextName) + const nextFileName = getPathFileName(normalizedNextName) || normalizedNextName + + if (!nextFileName) { + return currentPath + } + + if (!currentPath) { + return nextFileName + } + + const directory = getPathDirectory(currentPath) + return `${directory}/${nextFileName}` +} + +const setVisibleEditorPanelForKind = kind => { + const nextVisibleKind = kind === 'styles' ? 'styles' : 'component' + + for (const editorKind of editorKinds) { + const panel = editorPanelsByKind[editorKind] + if (!panel) { + continue + } + + if (editorKind === nextVisibleKind) { + panel.removeAttribute('hidden') + continue + } + + panel.setAttribute('hidden', '') + } +} + +const makeUniqueTabPath = ({ basePath, suffix = '' }) => { + const existingPaths = new Set( + workspaceTabsState + .getTabs() + .map(tab => toNonEmptyWorkspaceText(tab.path)) + .filter(Boolean), + ) + + if (!existingPaths.has(basePath)) { + return basePath + } + + let attempt = 2 + while (attempt < 500) { + const candidate = basePath.replace(/(\.[^./]+)$/u, `${suffix || ''}-${attempt}$1`) + if (!existingPaths.has(candidate)) { + return candidate + } + attempt += 1 + } + + return `${basePath}-${Date.now().toString(36)}` +} + +const ensureWorkspaceTabsShape = tabs => { + const inputTabs = Array.isArray(tabs) ? tabs : [] + const hasComponent = inputTabs.some(tab => tab?.id === 'component') + const hasStyles = inputTabs.some(tab => tab?.id === 'styles') + const nextTabs = [...inputTabs] + + if (!hasComponent) { + nextTabs.unshift({ + id: 'component', + name: defaultComponentTabName, + path: defaultComponentTabPath, + language: 'javascript-jsx', + role: 'entry', + content: defaultJsx, + isActive: true, + }) + } + + if (!hasStyles) { + nextTabs.push({ + id: 'styles', + name: defaultStylesTabName, + path: defaultStylesTabPath, + language: 'css', + role: 'module', + content: defaultCss, + isActive: false, + }) + } + + return nextTabs.map(tab => { + if (tab?.id === 'component') { + const normalizedEntryPath = normalizeEntryTabPath(tab.path, { + preferredFileName: tab.name, + }) + return { + ...tab, + role: 'entry', + language: 'javascript-jsx', + path: normalizedEntryPath, + name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, + } + } + + if (tab?.id === 'styles') { + const normalizedStylesPath = + toNonEmptyWorkspaceText(tab.path) || defaultStylesTabPath + const normalizedStylesNameInput = toNonEmptyWorkspaceText(tab.name) + return { + ...tab, + language: isStyleTabLanguage(tab.language) ? tab.language : 'css', + role: 'module', + path: normalizedStylesPath, + name: + !normalizedStylesNameInput || + normalizedStylesNameInput.toLowerCase() === 'styles' + ? getPathFileName(normalizedStylesPath) || defaultStylesTabName + : normalizedStylesNameInput, + } + } + + const nextPath = toNonEmptyWorkspaceText(tab?.path) + return { + ...tab, + role: 'module', + language: isStyleTabLanguage(tab?.language) ? tab.language : 'javascript-jsx', + path: nextPath, + name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, + } + }) +} + +const resolveWorkspaceActiveTabId = ({ tabs, requestedActiveTabId }) => { + const nextTabs = Array.isArray(tabs) ? tabs : [] + const requestedId = toNonEmptyWorkspaceText(requestedActiveTabId) + + if (requestedId && nextTabs.some(tab => tab?.id === requestedId)) { + return requestedId + } + + if (nextTabs.some(tab => tab?.id === 'component')) { + return 'component' + } + + return toNonEmptyWorkspaceText(nextTabs[0]?.id) +} + +const buildWorkspaceTabsSnapshot = () => { + const activeTabId = workspaceTabsState.getActiveTabId() + return workspaceTabsState.getTabs().map(tab => { + const isComponentTab = tab.id === 'component' + const isStylesTab = tab.id === 'styles' + const currentPath = isComponentTab + ? typeof githubPrComponentPath?.value === 'string' && + githubPrComponentPath.value.trim() + ? githubPrComponentPath.value.trim() + : tab.path + : isStylesTab + ? typeof githubPrStylesPath?.value === 'string' && githubPrStylesPath.value.trim() + ? githubPrStylesPath.value.trim() + : tab.path + : tab.path + + const currentContent = + tab.id === activeTabId + ? getTabKind(tab) === 'styles' + ? getCssSource() + : getJsxSource() + : typeof tab.content === 'string' + ? tab.content + : '' + + return { + ...tab, + path: currentPath, + content: currentContent, + isActive: activeTabId === tab.id, + lastModified: Date.now(), + } + }) +} + +const getPreviewStylesSource = () => { + const loadedStylesTab = workspaceTabsState.getTab(loadedStylesTabId) + + if (!loadedStylesTab || getTabKind(loadedStylesTab) !== 'styles') { + return getCssSource() + } + + if (workspaceTabsState.getActiveTabId() === loadedStylesTab.id) { + return getCssSource() + } + + return typeof loadedStylesTab.content === 'string' + ? loadedStylesTab.content + : getCssSource() +} + +const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { + const context = getWorkspaceContextSnapshot() + const id = + recordId || + activeWorkspaceRecordId || + toWorkspaceRecordId({ + repositoryFullName: context.repositoryFullName, + headBranch: context.headBranch, + }) + + return { + id, + repo: context.repositoryFullName || '', + base: context.baseBranch || '', + head: context.headBranch || '', + prNumber: null, + prTitle: context.prTitle || '', + renderMode: normalizeRenderMode(renderMode.value), + tabs: buildWorkspaceTabsSnapshot(), + activeTabId: workspaceTabsState.getActiveTabId(), + createdAt: activeWorkspaceCreatedAt ?? Date.now(), + lastModified: Date.now(), + } +} + +const updateLocalContextActions = () => { + if (!(githubPrLocalContextRemove instanceof HTMLButtonElement)) { + return + } + + const hasSelection = + typeof githubPrLocalContextSelect?.value === 'string' && + githubPrLocalContextSelect.value.length > 0 + githubPrLocalContextRemove.disabled = !hasSelection +} + +const formatWorkspaceOptionLabel = workspace => { + const contextLabel = 'Local' + const hasTitle = typeof workspace.prTitle === 'string' && workspace.prTitle.trim() + const hasHead = typeof workspace.head === 'string' && workspace.head.trim() + + if (hasTitle) { + return `${contextLabel}: ${workspace.prTitle}` + } + + if (hasHead) { + return `${contextLabel}: ${workspace.head}` + } + + return `${contextLabel}: ${workspace.id}` +} + +const refreshLocalContextOptions = async () => { + if (!(githubPrLocalContextSelect instanceof HTMLSelectElement)) { + return [] + } + + const selectedRepository = getCurrentSelectedRepository() + const options = await workspaceStorage.listWorkspaces({ + repo: selectedRepository || '', + }) + + githubPrLocalContextSelect.replaceChildren() + + const placeholder = document.createElement('option') + placeholder.value = '' + placeholder.textContent = + options.length > 0 ? 'Select a stored local context' : 'No saved local contexts' + placeholder.selected = activeWorkspaceRecordId.length === 0 + githubPrLocalContextSelect.append(placeholder) + + for (const workspace of options) { + const option = document.createElement('option') + option.value = workspace.id + option.textContent = formatWorkspaceOptionLabel(workspace) + option.selected = workspace.id === activeWorkspaceRecordId + githubPrLocalContextSelect.append(option) + } + + if ( + activeWorkspaceRecordId && + !options.some(workspace => workspace.id === activeWorkspaceRecordId) + ) { + activeWorkspaceRecordId = '' + activeWorkspaceCreatedAt = null + githubPrLocalContextSelect.value = '' + } + + updateLocalContextActions() + return options +} + +const applyWorkspaceRecord = async (workspace, { silent = false } = {}) => { + if (!workspace || typeof workspace !== 'object') { + return false + } + + isApplyingWorkspaceSnapshot = true + + try { + activeWorkspaceRecordId = workspace.id + activeWorkspaceCreatedAt = workspace.createdAt ?? null + + const nextTabs = ensureWorkspaceTabsShape(workspace.tabs) + const componentTab = nextTabs.find(tab => tab.id === 'component') + const stylesTab = nextTabs.find(tab => tab.id === 'styles') + + if (typeof workspace.base === 'string' && githubPrBaseBranch) { + githubPrBaseBranch.value = workspace.base + } + + if (typeof workspace.head === 'string' && githubPrHeadBranch) { + githubPrHeadBranch.value = workspace.head + } + + if (typeof workspace.prTitle === 'string' && githubPrTitle) { + githubPrTitle.value = workspace.prTitle + } + + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId: resolveWorkspaceActiveTabId({ + tabs: nextTabs, + requestedActiveTabId: workspace.activeTabId, + }), + }) + + const nextRenderMode = normalizeRenderMode(workspace.renderMode) + if (renderMode.value !== nextRenderMode) { + renderMode.value = nextRenderMode + } + + if (typeof componentTab?.path === 'string' && githubPrComponentPath) { + githubPrComponentPath.value = componentTab.path + } + + if (typeof stylesTab?.path === 'string' && githubPrStylesPath) { + githubPrStylesPath.value = stylesTab.path + } + + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } + + if (stylesTab && typeof stylesTab.content === 'string') { + setCssSource(stylesTab.content) + } + + renderWorkspaceTabs() + + if (hasCompletedInitialWorkspaceBootstrap) { + maybeRender() + } + await refreshLocalContextOptions() + if (!silent) { + setStatus('Loaded local workspace context.', 'neutral') + } + + return true + } finally { + isApplyingWorkspaceSnapshot = false + } +} + +workspaceSaver = createDebouncedWorkspaceSaver({ + save: async payload => { + const saved = await workspaceStorage.upsertWorkspace(payload) + activeWorkspaceRecordId = saved.id + activeWorkspaceCreatedAt = saved.createdAt ?? activeWorkspaceCreatedAt + await refreshLocalContextOptions() + return saved + }, + onError: error => { + const message = + error instanceof Error ? error.message : 'Could not save local workspace context.' + setStatus(`Local save failed: ${message}`, 'error') + }, +}) + +const queueWorkspaceSave = () => { + if (isApplyingWorkspaceSnapshot || !workspaceSaver) { + return + } + + const snapshot = buildWorkspaceRecordSnapshot() + activeWorkspaceRecordId = snapshot.id + workspaceSaver.queue(snapshot) +} + +const flushWorkspaceSave = async () => { + if (isApplyingWorkspaceSnapshot || !workspaceSaver) { + return + } + + const snapshot = buildWorkspaceRecordSnapshot() + activeWorkspaceRecordId = snapshot.id + await workspaceSaver.flushNow(snapshot) +} + +const setActiveWorkspaceTab = tabId => { + const normalizedTabId = toNonEmptyWorkspaceText(tabId) + if (!normalizedTabId) { + return + } + + const currentActiveTabId = workspaceTabsState.getActiveTabId() + const targetTab = workspaceTabsState.getTab(normalizedTabId) + if (!targetTab) { + return + } + + if (targetTab.id === currentActiveTabId) { + loadWorkspaceTabIntoEditor(targetTab) + renderWorkspaceTabs() + return + } + + persistActiveTabEditorContent() + + const changed = workspaceTabsState.setActiveTab(targetTab.id) + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } + + renderWorkspaceTabs() + + if (!changed) { + return + } + + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) +} + +const syncEditorFromActiveWorkspaceTab = () => { + const activeTab = getActiveWorkspaceTab() + if (!activeTab) { + return + } + + loadWorkspaceTabIntoEditor(activeTab) +} + +const beginWorkspaceTabRename = tabId => { + setWorkspaceTabAddMenuOpen(false) + workspaceTabRenameState = { + tabId: toNonEmptyWorkspaceText(tabId), + } + renderWorkspaceTabs() +} + +const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => { + const normalizedTabId = toNonEmptyWorkspaceText(tabId) + const tab = workspaceTabsState.getTab(normalizedTabId) + + workspaceTabRenameState = { + tabId: '', + } + + if (!tab || cancelled) { + renderWorkspaceTabs() + return + } + + const normalizedNameInput = toNonEmptyWorkspaceText(nextName) + const normalizedName = getPathFileName(normalizedNameInput) || normalizedNameInput + if (!normalizedName) { + setStatus('Tab name cannot be empty.', 'error') + renderWorkspaceTabs() + return + } + + if ( + tab.role === 'entry' && + !allowedEntryTabFileNames.has(normalizedName.toLowerCase()) + ) { + setStatus('Entry tab name must be App.tsx or App.js.', 'error') + renderWorkspaceTabs() + return + } + + const normalizedEntryPath = + tab.role === 'entry' + ? normalizeEntryTabPath(tab.path, { preferredFileName: normalizedName }) + : normalizeModuleTabPathForRename(tab.path, normalizedName) + const normalizedTabName = + tab.role === 'entry' + ? getPathFileName(normalizedEntryPath) || defaultComponentTabName + : getPathFileName(normalizedEntryPath) || normalizedName + + workspaceTabsState.upsertTab({ + ...tab, + name: normalizedTabName, + path: normalizedEntryPath, + lastModified: Date.now(), + }) + + if (tab.role === 'entry' && githubPrComponentPath instanceof HTMLInputElement) { + githubPrComponentPath.value = normalizedEntryPath + } + + syncHeaderLabels() + renderWorkspaceTabs() + queueWorkspaceSave() +} + +const removeWorkspaceTab = tabId => { + setWorkspaceTabAddMenuOpen(false) + const tab = workspaceTabsState.getTab(tabId) + if (!tab) { + return + } + + if (tab.role === 'entry') { + setStatus('The entry tab cannot be removed.', 'neutral') + return + } + + confirmAction({ + title: `Remove tab ${tab.name}?`, + copy: 'This removes the tab and its local source content from this workspace context.', + confirmButtonText: 'Remove tab', + onConfirm: () => { + const removedKind = getTabKind(tab) + persistActiveTabEditorContent() + const removed = workspaceTabsState.removeTab(tab.id) + if (!removed) { + return + } + + if (loadedComponentTabId === tab.id) { + loadedComponentTabId = + workspaceTabsState.getTabs().find(entry => getTabKind(entry) === 'component') + ?.id || 'component' + } + + if (loadedStylesTabId === tab.id) { + loadedStylesTabId = + workspaceTabsState.getTabs().find(entry => getTabKind(entry) === 'styles') + ?.id || 'styles' + } + + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } else { + const fallbackTab = + getWorkspaceTabByKind(removedKind === 'styles' ? 'component' : 'styles') || + workspaceTabsState.getTabs()[0] || + null + if (fallbackTab) { + setActiveWorkspaceTab(fallbackTab.id) + } + } + + renderWorkspaceTabs() + queueWorkspaceSave() + maybeRender() + }, + }) +} + +const addWorkspaceTab = kind => { + const normalizedKind = + kind === 'styles' ? 'styles' : kind === 'component' ? 'component' : '' + if (!normalizedKind) { + setStatus('Choose a tab type before adding a tab.', 'neutral') + return + } + + const basePath = + normalizedKind === 'styles' ? 'src/styles/module.css' : 'src/components/module.tsx' + const language = normalizedKind === 'styles' ? 'css' : 'javascript-jsx' + const path = makeUniqueTabPath({ basePath }) + const tabId = createWorkspaceTabId(normalizedKind === 'styles' ? 'style' : 'module') + const name = getPathFileName(path) || `${normalizedKind}-tab` + + persistActiveTabEditorContent() + + workspaceTabsState.upsertTab({ + id: tabId, + name, + path, + language, + role: 'module', + isActive: false, + content: '', + lastModified: Date.now(), + }) + + setWorkspaceTabAddMenuOpen(false) + setActiveWorkspaceTab(tabId) + + if (normalizedKind === 'styles') { + setStatus('Added style tab.', 'neutral') + } else { + setStatus('Added JavaScript tab.', 'neutral') + } +} + +const setWorkspaceTabAddMenuOpen = isOpen => { + const nextOpen = Boolean(isOpen) + if (workspaceTabAddMenuOpen === nextOpen) { + return + } + + workspaceTabAddMenuOpen = nextOpen + if (workspaceTabAddButton instanceof HTMLButtonElement) { + workspaceTabAddButton.setAttribute('aria-expanded', nextOpen ? 'true' : 'false') + } + + if (workspaceTabAddMenu instanceof HTMLElement) { + workspaceTabAddMenu.hidden = !nextOpen + } +} + +const renderWorkspaceTabs = () => { + if (!(workspaceTabsStrip instanceof HTMLElement)) { + return + } + + if (isRenderingWorkspaceTabs) { + hasPendingWorkspaceTabsRender = true + return + } + + isRenderingWorkspaceTabs = true + + try { + const tabs = workspaceTabsState.getTabs() + const activeTabId = workspaceTabsState.getActiveTabId() + + workspaceTabsStrip.replaceChildren() + + for (const tab of tabs) { + const isActive = tab.id === activeTabId + const tabContainer = document.createElement('div') + tabContainer.className = 'workspace-tab' + tabContainer.setAttribute('role', 'presentation') + tabContainer.dataset.tabId = tab.id + tabContainer.setAttribute('aria-selected', isActive ? 'true' : 'false') + tabContainer.addEventListener('click', event => { + const clickTarget = event.target + if (!(clickTarget instanceof Element)) { + return + } + + if ( + clickTarget.closest('.workspace-tab__rename, .workspace-tab__remove, input') + ) { + return + } + + setActiveWorkspaceTab(tab.id) + }) + + const isRenaming = workspaceTabRenameState.tabId === tab.id + if (isRenaming) { + const renameInput = document.createElement('input') + renameInput.className = 'workspace-tab__name-input' + renameInput.value = tab.name + renameInput.setAttribute('aria-label', `Rename ${tab.name}`) + + let renameResolved = false + const resolveRename = ({ cancelled = false } = {}) => { + if (renameResolved) { + return + } + + renameResolved = true + finishWorkspaceTabRename({ + tabId: tab.id, + nextName: renameInput.value, + cancelled, + }) + } + + renameInput.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault() + resolveRename() + } + + if (event.key === 'Escape') { + event.preventDefault() + resolveRename({ cancelled: true }) + } + }) + renameInput.addEventListener('blur', () => { + resolveRename() + }) + tabContainer.append(renameInput) + workspaceTabsStrip.append(tabContainer) + + queueMicrotask(() => { + renameInput.focus() + renameInput.select() + }) + continue + } + + const selectButton = document.createElement('button') + selectButton.className = 'workspace-tab__select' + selectButton.type = 'button' + const tabDisplay = getWorkspaceTabDisplay(tab) + if (tabDisplay.fullPath) { + selectButton.title = tabDisplay.fullPath + } - githubPrToggleLabel.textContent = label - githubPrToggle.title = title - githubPrToggle.setAttribute('aria-label', title) - githubPrToggleIcon.setAttribute('viewBox', icon.viewBox) - githubPrToggleIconPath.setAttribute('d', icon.path) -} + const fileNameNode = document.createElement('span') + fileNameNode.className = 'workspace-tab__path-file' + fileNameNode.textContent = tabDisplay.fileName || tab.name + selectButton.append(fileNameNode) + + selectButton.setAttribute('role', 'tab') + selectButton.setAttribute('aria-selected', isActive ? 'true' : 'false') + selectButton.setAttribute('aria-label', `Open tab ${tab.name}`) + selectButton.addEventListener('click', event => { + event.stopPropagation() + setActiveWorkspaceTab(tab.id) + }) + selectButton.addEventListener('dblclick', () => { + beginWorkspaceTabRename(tab.id) + }) + tabContainer.append(selectButton) -const syncEditorPrContextIndicators = shouldShow => { - const iconNodes = [componentPrSyncIcon, stylesPrSyncIcon] - const iconPathNodes = [componentPrSyncIconPath, stylesPrSyncIconPath] + if (tab.role === 'entry') { + const metaBadge = document.createElement('span') + metaBadge.className = 'workspace-tab__meta' + metaBadge.textContent = 'Entry' + tabContainer.append(metaBadge) + } - for (const iconPath of iconPathNodes) { - if (iconPath instanceof SVGPathElement) { - iconPath.setAttribute('d', githubPrOpenIcon.path) + const renameButton = document.createElement('button') + renameButton.className = 'workspace-tab__rename' + renameButton.type = 'button' + renameButton.setAttribute('aria-label', `Rename tab ${tab.name}`) + renameButton.title = `Rename ${tab.name}` + const renameIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + renameIcon.setAttribute('viewBox', '0 0 24 24') + renameIcon.setAttribute('aria-hidden', 'true') + const renamePath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + renamePath.setAttribute( + 'd', + 'M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z', + ) + renameIcon.append(renamePath) + renameButton.append(renameIcon) + renameButton.addEventListener('click', () => { + beginWorkspaceTabRename(tab.id) + }) + tabContainer.append(renameButton) + + if (tab.role !== 'entry') { + const removeButton = document.createElement('button') + removeButton.className = 'workspace-tab__remove' + removeButton.type = 'button' + removeButton.textContent = 'Ă—' + removeButton.setAttribute('aria-label', `Remove tab ${tab.name}`) + removeButton.title = `Remove ${tab.name}` + removeButton.addEventListener('click', () => { + removeWorkspaceTab(tab.id) + }) + tabContainer.append(removeButton) + } + + workspaceTabsStrip.append(tabContainer) } - } - for (const icon of iconNodes) { - if (!(icon instanceof SVGElement)) { - continue + if ( + workspaceTabAddWrap instanceof HTMLElement && + workspaceTabsShell instanceof HTMLElement + ) { + workspaceTabsShell.append(workspaceTabAddWrap) } + } finally { + isRenderingWorkspaceTabs = false + } - icon.setAttribute('viewBox', githubPrOpenIcon.viewBox) - icon.dataset.visible = shouldShow ? 'true' : 'false' - icon.toggleAttribute('hidden', !shouldShow) + if (hasPendingWorkspaceTabsRender) { + hasPendingWorkspaceTabsRender = false + renderWorkspaceTabs() + return } + + syncEditorFromActiveWorkspaceTab() } -const syncActivePrContextUi = activeContext => { - githubAiContextState.activePrContext = activeContext ?? null - const nextSyncKey = getActivePrContextSyncKey(activeContext) +const loadPreferredWorkspaceContext = async () => { + const options = await refreshLocalContextOptions() - if (!nextSyncKey) { - githubAiContextState.activePrEditorSyncKey = '' - githubAiContextState.hasSyncedActivePrEditorContent = false - } else if (githubAiContextState.activePrEditorSyncKey !== nextSyncKey) { - githubAiContextState.activePrEditorSyncKey = nextSyncKey - githubAiContextState.hasSyncedActivePrEditorContent = false + if (!Array.isArray(options) || options.length === 0) { + return } - const hasActiveContext = Boolean(activeContext?.prTitle) - const shouldShowEditorSyncIndicators = - hasActiveContext && githubAiContextState.hasSyncedActivePrEditorContent + const preferredId = + activeWorkspaceRecordId || + toWorkspaceRecordId({ + repositoryFullName: getCurrentSelectedRepository(), + headBranch: + typeof githubPrHeadBranch?.value === 'string' + ? githubPrHeadBranch.value.trim() + : '', + }) - setGitHubPrToggleVisual(hasActiveContext ? 'push-commit' : 'open-pr') - syncEditorPrContextIndicators(shouldShowEditorSyncIndicators) + const preferred = options.find(workspace => workspace.id === preferredId) + const next = preferred ?? options[0] - if (!hasActiveContext) { - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') + if (!next) { return } - githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') + await applyWorkspaceRecord(next, { silent: true }) } -const syncAiChatTokenVisibility = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 - - if (hasToken) { - aiChatToggle?.removeAttribute('hidden') +const bindWorkspaceMetadataPersistence = element => { + if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) { + return + } - githubPrToggle?.removeAttribute('hidden') + const queue = () => { + queueWorkspaceSave() + } - if (githubAiContextState.activePrContext) { - githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') - } else { - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - } - return + const flush = () => { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) } - aiChatToggle?.setAttribute('hidden', '') - aiChatToggle?.setAttribute('aria-expanded', 'false') - githubAiContextState.activePrContext = null - githubAiContextState.activePrEditorSyncKey = '' - githubAiContextState.hasSyncedActivePrEditorContent = false - syncEditorPrContextIndicators(false) - setGitHubPrToggleVisual('open-pr') - githubPrToggle?.setAttribute('hidden', '') - githubPrToggle?.setAttribute('aria-expanded', 'false') - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - chatDrawerController.setOpen(false) - prDrawerController.setOpen(false) + element.addEventListener('input', queue) + element.addEventListener('change', queue) + element.addEventListener('blur', flush) } -const byotControls = createGitHubByotControls({ - controlsRoot: githubAiControls, - tokenInput: githubTokenInput, - tokenInfoButton: githubTokenInfo, - tokenAddButton: githubTokenAdd, - tokenDeleteButton: githubTokenDelete, - onRepositoryChange: repository => { - githubAiContextState.selectedRepository = repository - chatDrawerController.setSelectedRepository(repository) - prDrawerController.setSelectedRepository(repository) - }, - onWritableRepositoriesChange: ({ repositories }) => { - githubAiContextState.writableRepositories = Array.isArray(repositories) - ? [...repositories] - : [] - prDrawerController.syncRepositories() - }, - onTokenDeleteRequest: onConfirm => { - confirmAction({ - title: 'Remove saved GitHub token?', - copy: 'This action removes the token from browser storage. You can add another token at any time.', - confirmButtonText: 'Remove', - onConfirm, - }) - }, - onTokenChange: token => { - githubAiContextState.token = token - syncAiChatTokenVisibility(token) - chatDrawerController.setToken(token) - prDrawerController.setToken(token) - }, - setStatus, -}) +const syncTabPathsFromInputs = () => { + const requestedComponentPath = + typeof githubPrComponentPath?.value === 'string' && githubPrComponentPath.value.trim() + ? githubPrComponentPath.value.trim() + : defaultComponentTabPath + const componentPath = normalizeEntryTabPath(requestedComponentPath) + const stylesPath = + typeof githubPrStylesPath?.value === 'string' && githubPrStylesPath.value.trim() + ? githubPrStylesPath.value.trim() + : defaultStylesTabPath -githubAiContextState.selectedRepository = byotControls.getSelectedRepository() -githubAiContextState.token = byotControls.getToken() -githubAiContextState.writableRepositories = byotControls.getWritableRepositories() + if (githubPrComponentPath instanceof HTMLInputElement) { + githubPrComponentPath.value = componentPath + } -const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.getToken() + workspaceTabsState.upsertTab({ + id: 'component', + path: componentPath, + name: getPathFileName(componentPath) || defaultComponentTabName, + language: 'javascript-jsx', + role: 'entry', + isActive: workspaceTabsState.getActiveTabId() === 'component', + }) + workspaceTabsState.upsertTab({ + id: 'styles', + path: stylesPath, + name: getPathFileName(stylesPath) || defaultStylesTabName, + language: 'css', + role: 'module', + isActive: workspaceTabsState.getActiveTabId() === 'styles', + }) -const getCurrentSelectedRepository = () => - githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() + syncHeaderLabels() + renderWorkspaceTabs() +} const getCurrentWritableRepositories = () => githubAiContextState.writableRepositories.length > 0 @@ -783,8 +1903,7 @@ chatDrawerController = createGitHubChatDrawer({ getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, getDrawerSide: () => { - const layout = getCurrentLayout() - return layout === 'preview-left' ? 'left' : 'right' + return 'right' }, }) @@ -814,8 +1933,7 @@ prDrawerController = createGitHubPrDrawer({ getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, getDrawerSide: () => { - const layout = getCurrentLayout() - return layout === 'preview-left' ? 'left' : 'right' + return 'right' }, confirmBeforeSubmit: options => { confirmAction(options) @@ -952,6 +2070,8 @@ const getStyleEditorLanguage = mode => { return 'css' } +const normalizeRenderMode = mode => (mode === 'react' ? 'react' : 'dom') + const normalizeStyleMode = mode => { if (mode === 'module') return 'module' if (mode === 'less') return 'less' @@ -974,7 +2094,7 @@ const initializeCodeEditors = async () => { const [nextJsxEditor, nextCssEditor] = await Promise.all([ createCodeMirrorEditor({ parent: jsxHost, - value: defaultJsx, + value: getJsxSource(), language: 'javascript-jsx', contentAttributes: { 'aria-label': 'Component source editor', @@ -984,14 +2104,27 @@ const initializeCodeEditors = async () => { if (suppressEditorChangeSideEffects) { return } - maybeRender() + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'component') { + workspaceTabsState.upsertTab( + { + ...activeTab, + content: getJsxSource(), + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'componentEditorChange' }, + ) + } + queueWorkspaceSave() + maybeRenderFromComponentEditorChange() markTypeDiagnosticsStale() markComponentLintDiagnosticsStale() }, }), createCodeMirrorEditor({ parent: cssHost, - value: defaultCss, + value: getCssSource(), language: getStyleEditorLanguage(styleMode.value), contentAttributes: { 'aria-label': 'Styles source editor', @@ -1001,17 +2134,80 @@ const initializeCodeEditors = async () => { if (suppressEditorChangeSideEffects) { return } + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'styles') { + workspaceTabsState.upsertTab( + { + ...activeTab, + content: getCssSource(), + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'stylesEditorChange' }, + ) + } + queueWorkspaceSave() maybeRender() markStylesLintDiagnosticsStale() }, }), ]) + jsxHost.addEventListener('focusout', event => { + if ( + !(event.relatedTarget instanceof Node) || + !jsxHost.contains(event.relatedTarget) + ) { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + }) + + cssHost.addEventListener('focusout', event => { + if ( + !(event.relatedTarget instanceof Node) || + !cssHost.contains(event.relatedTarget) + ) { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + }) + jsxCodeEditor = nextJsxEditor cssCodeEditor = nextCssEditor getJsxSource = () => jsxCodeEditor.getValue() getCssSource = () => cssCodeEditor.getValue() + editorPool.register('component', { + isMounted: () => + componentEditorPanel instanceof HTMLElement && + !componentEditorPanel.hasAttribute('hidden'), + mount: () => { + componentEditorPanel?.removeAttribute('hidden') + }, + unmount: () => { + componentEditorPanel?.setAttribute('hidden', '') + }, + }) + editorPool.register('styles', { + isMounted: () => + stylesEditorPanel instanceof HTMLElement && + !stylesEditorPanel.hasAttribute('hidden'), + mount: () => { + stylesEditorPanel?.removeAttribute('hidden') + }, + unmount: () => { + stylesEditorPanel?.setAttribute('hidden', '') + }, + }) + + const activeWorkspaceTab = getActiveWorkspaceTab() + if (activeWorkspaceTab) { + loadWorkspaceTabIntoEditor(activeWorkspaceTab) + } + jsxEditor.classList.add('source-textarea--hidden') cssEditor.classList.add('source-textarea--hidden') } catch (error) { @@ -1290,21 +2486,32 @@ const maybeRender = () => { } } +const maybeRenderFromComponentEditorChange = () => { + if (!autoRenderToggle.checked) { + return + } + + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'component') { + const shouldRender = renderRuntime.shouldAutoRenderForTabChange(activeTab.id) + if (!shouldRender) { + return + } + } + + renderRuntime.scheduleRender() +} + renderRuntime = createRenderRuntimeController({ cdnImports, importFromCdnWithFallback, renderMode, styleMode, - shadowToggle, isAutoRenderEnabled: () => autoRenderToggle.checked, - getCssSource: () => getCssSource(), + getCssSource: () => getPreviewStylesSource(), getJsxSource: () => getJsxSource(), + getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(), getPreviewHost: () => previewHost, - setPreviewHost: nextHost => { - previewHost = nextHost - }, - applyPreviewBackgroundColor: color => - previewBackground.applyPreviewBackgroundColor(color), getPreviewBackgroundColor: () => previewBackground.getPreviewBackgroundColor(), clearStyleDiagnostics: () => clearDiagnosticsScope('styles'), setStyleDiagnosticsDetails, @@ -1345,6 +2552,7 @@ const clearComponentSource = () => { clearComponentLintDiagnosticsState() setStatus('Component cleared', 'neutral') renderRuntime.clearPreview() + queueWorkspaceSave() } const clearStylesSource = () => { @@ -1353,6 +2561,7 @@ const clearStylesSource = () => { clearStylesLintDiagnosticsState() setStatus('Styles cleared', 'neutral') maybeRender() + queueWorkspaceSave() } const confirmAction = ({ title, copy, confirmButtonText = 'Clear', onConfirm }) => { @@ -1445,29 +2654,19 @@ const updateRenderButtonVisibility = () => { renderButton.hidden = autoRenderToggle.checked } -function applyRenderMode({ mode, fromActivePrContext = false }) { - const nextMode = mode === 'react' ? 'react' : 'dom' +function applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext = false }) { + const nextMode = normalizeRenderMode(mode) if (renderMode.value !== nextMode) { renderMode.value = nextMode } - if (fromActivePrContext === true && nextMode === 'react') { - hasAppliedReactModeDefault = true - } - resetDiagnosticsFlow() - if ( - nextMode === 'react' && - !hasAppliedReactModeDefault && - fromActivePrContext !== true - ) { - hasAppliedReactModeDefault = true - setJsxSource(defaultReactJsx) - } - maybeRender() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) } function applyStyleMode({ mode }) { @@ -1497,7 +2696,6 @@ renderMode.addEventListener('change', () => { styleMode.addEventListener('change', () => { applyStyleMode({ mode: styleMode.value }) }) -shadowToggle.addEventListener('change', maybeRender) autoRenderToggle.addEventListener('change', () => { renderRuntime.clearPreview() updateRenderButtonVisibility() @@ -1591,52 +2789,115 @@ clearStylesButton.addEventListener('click', () => { onConfirm: clearStylesSource, }) }) -jsxEditor.addEventListener('input', maybeRender) + +jsxEditor.addEventListener('input', maybeRenderFromComponentEditorChange) jsxEditor.addEventListener('input', markTypeDiagnosticsStale) jsxEditor.addEventListener('input', markComponentLintDiagnosticsStale) +jsxEditor.addEventListener('input', queueWorkspaceSave) +jsxEditor.addEventListener('blur', () => { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) +}) cssEditor.addEventListener('input', maybeRender) cssEditor.addEventListener('input', markStylesLintDiagnosticsStale) +cssEditor.addEventListener('input', queueWorkspaceSave) +cssEditor.addEventListener('blur', () => { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) +}) -for (const button of appGridLayoutButtons) { - button.addEventListener('click', () => { - const nextLayout = button.dataset.appGridLayout - if (!nextLayout) { +if (githubPrLocalContextSelect instanceof HTMLSelectElement) { + githubPrLocalContextSelect.addEventListener('change', () => { + const selectedId = githubPrLocalContextSelect.value + updateLocalContextActions() + + if (!selectedId) { return } - applyAppGridLayout(nextLayout) - applyPanelCollapseState() - if (isStackedRailViewport()) { - setStackedRailViewControlsOpen(false) - } + void workspaceStorage + .getWorkspaceById(selectedId) + .then(record => { + if (!record) { + return refreshLocalContextOptions() + } + + return applyWorkspaceRecord(record, { silent: false }) + }) + .catch(() => { + setStatus('Could not load selected local context.', 'error') + }) }) } -for (const button of appThemeButtons) { - button.addEventListener('click', () => { - const nextTheme = button.dataset.appTheme - if (!nextTheme) { +for (const element of [ + githubPrBaseBranch, + githubPrHeadBranch, + githubPrComponentPath, + githubPrStylesPath, + githubPrTitle, +]) { + bindWorkspaceMetadataPersistence(element) +} + +for (const element of [githubPrComponentPath, githubPrStylesPath]) { + if (!(element instanceof HTMLInputElement)) { + continue + } + + const handler = () => { + syncTabPathsFromInputs() + } + + element.addEventListener('input', handler) + element.addEventListener('change', handler) + element.addEventListener('blur', handler) +} + +if (githubPrLocalContextRemove instanceof HTMLButtonElement) { + githubPrLocalContextRemove.addEventListener('click', () => { + const selectedId = + githubPrLocalContextSelect instanceof HTMLSelectElement + ? githubPrLocalContextSelect.value + : '' + + if (!selectedId) { return } - applyTheme(nextTheme) - if (isStackedRailViewport()) { - setStackedRailViewControlsOpen(false) - } + confirmAction({ + title: 'Remove stored local context?', + copy: 'This removes only local workspace metadata and editor content from this browser.', + confirmButtonText: 'Remove', + onConfirm: () => { + void workspaceStorage + .removeWorkspace(selectedId) + .then(async () => { + if (activeWorkspaceRecordId === selectedId) { + activeWorkspaceRecordId = '' + activeWorkspaceCreatedAt = null + } + + await refreshLocalContextOptions() + setStatus('Removed stored local context.', 'neutral') + }) + .catch(() => { + setStatus('Could not remove stored local context.', 'error') + }) + }, + }) }) } -if (viewControlsToggle instanceof HTMLButtonElement) { - viewControlsToggle.addEventListener('click', () => { - if (!isStackedRailViewport()) { +for (const button of appThemeButtons) { + button.addEventListener('click', () => { + const nextTheme = button.dataset.appTheme + if (!nextTheme) { return } - - if (isCompactViewport()) { - setCompactAiControlsOpen(false) - } - - setStackedRailViewControlsOpen(!stackedRailViewControlsOpen) + applyTheme(nextTheme) }) } @@ -1646,7 +2907,6 @@ if (aiControlsToggle instanceof HTMLButtonElement) { return } - setStackedRailViewControlsOpen(false) setCompactAiControlsOpen(!compactAiControlsOpen) }) } @@ -1664,15 +2924,6 @@ document.addEventListener('click', event => { return } - if (isStackedRailViewport() && stackedRailViewControlsOpen) { - if ( - !viewControlsDrawer?.contains(clickTarget) && - !viewControlsToggle?.contains(clickTarget) - ) { - setStackedRailViewControlsOpen(false) - } - } - if (isCompactViewport() && compactAiControlsOpen) { if ( !githubAiControls.contains(clickTarget) && @@ -1697,7 +2948,6 @@ document.addEventListener('keydown', event => { return } - setStackedRailViewControlsOpen(false) setCompactAiControlsOpen(false) setGitHubTokenInfoOpen(false) }) @@ -1730,22 +2980,12 @@ const handleCompactViewportChange = () => { setCompactAiControlsOpen(false) } -const handleStackedRailViewportChange = () => { - setStackedRailViewControlsOpen(false) -} - if (typeof compactViewportMediaQuery.addEventListener === 'function') { compactViewportMediaQuery.addEventListener('change', handleCompactViewportChange) } else { compactViewportMediaQuery.onchange = handleCompactViewportChange } -if (typeof stackedRailMediaQuery.addEventListener === 'function') { - stackedRailMediaQuery.addEventListener('change', handleStackedRailViewportChange) -} else { - stackedRailMediaQuery.onchange = handleStackedRailViewportChange -} - window.addEventListener('beforeunload', () => { if (appToastDismissTimer) { clearTimeout(appToastDismissTimer) @@ -1754,15 +2994,70 @@ window.addEventListener('beforeunload', () => { clearComponentLintRecheckTimer() clearStylesLintRecheckTimer() lintDiagnostics.dispose() + void flushWorkspaceSave().catch(() => { + /* noop */ + }) + workspaceSaver?.dispose() + void workspaceStorage.close() chatDrawerController.dispose() prDrawerController.dispose() }) -applyAppGridLayout(getInitialAppGridLayout(), { persist: false }) +document.addEventListener('pointerdown', event => { + if (!workspaceTabAddMenuOpen) { + return + } + + const target = event.target + if (target instanceof Element && target.closest('#workspace-tab-add-wrap')) { + return + } + + setWorkspaceTabAddMenuOpen(false) +}) + +document.addEventListener('keydown', event => { + if (!workspaceTabAddMenuOpen || event.key !== 'Escape') { + return + } + + event.preventDefault() + setWorkspaceTabAddMenuOpen(false) +}) + +if (workspaceTabAddButton instanceof HTMLButtonElement) { + workspaceTabAddButton.addEventListener('click', event => { + event.stopPropagation() + setWorkspaceTabAddMenuOpen(!workspaceTabAddMenuOpen) + }) + + workspaceTabAddButton.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setWorkspaceTabAddMenuOpen(true) + } + }) +} + +if (workspaceTabAddModule instanceof HTMLButtonElement) { + workspaceTabAddModule.addEventListener('click', event => { + event.stopPropagation() + addWorkspaceTab('component') + }) +} + +if (workspaceTabAddStyles instanceof HTMLButtonElement) { + workspaceTabAddStyles.addEventListener('click', event => { + event.stopPropagation() + addWorkspaceTab('styles') + }) +} + applyTheme(getInitialTheme(), { persist: false }) applyEditorToolsVisibility() applyPanelCollapseState() -setStackedRailViewControlsOpen(false) +syncHeaderLabels() +renderWorkspaceTabs() setCompactAiControlsOpen(false) setGitHubTokenInfoOpen(false) syncAiChatTokenVisibility(githubAiContextState.token) @@ -1777,5 +3072,23 @@ setTypeDiagnosticsDetails({ headline: '' }) renderRuntime.setStyleCompiling(false) setCdnLoading(true) initializePreviewBackgroundPicker() -void initializeCodeEditors() -renderPreview() +const workspaceRestoreReady = loadPreferredWorkspaceContext().catch(() => { + setStatus('Could not restore local workspace context.', 'neutral') +}) +void initializeCodeEditors().then(async () => { + await workspaceRestoreReady + + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + setActiveWorkspaceTab(activeTab.id) + } + + const stylesTab = + workspaceTabsState.getTab(loadedStylesTabId) ?? getWorkspaceTabByKind('styles') + if (stylesTab && typeof stylesTab.content === 'string') { + setCssSource(stylesTab.content) + } + + hasCompletedInitialWorkspaceBootstrap = true + await renderPreview() +}) diff --git a/src/index.html b/src/index.html index e4cd022..c6c568b 100644 --- a/src/index.html +++ b/src/index.html @@ -31,8 +31,8 @@

Idle
-
-
+
+
- - -
- - -
-
+
-
-

- Component - -

-
- - +
+
+ + -
-
- -
+
+
+ +
+
+
+
+

+ Component + +

+
+ - + - +
+
+
+ + + + + +
-
-
- -
-
+
+ +
+ -
-
-

- Styles - -

-
- - - - -
-
-
+

+
+ + + - +
+
+
+ + +
- -
- -
- +
+ +
+ +
Preview Background - - -
-
-
+