diff --git a/docs/next-steps.md b/docs/next-steps.md index dea46ea..36e6edc 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -26,15 +26,7 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Evaluate and optionally optimize @knighted/develop GitHub file upsert behavior. Compare metadata-first preflight GET+PUT against optimistic PUT with retry-on-missing-sha for existing files. Keep current reliability guarantees and avoid reintroducing noisy false-positive failures. If implementing a hybrid/configurable strategy, keep defaults conservative, update docs, and validate with npm run lint plus targeted Playwright PR drawer flows." -5. **Remove pre-multitab component/styles compatibility paths** - - Delete code paths that preserve or translate legacy single-component/single-styles storage and sync behavior from before the multitab update. - - Remove backward-compatibility shims, fallback field reads, and migration glue tied to old `componentFilePath`/`stylesFilePath`-style assumptions when equivalent tab-derived data exists. - - Favor one canonical tab-first data contract across local storage, IndexedDB workspace records, PR sync metadata, and commit target derivation. - - Accept breaking changes for old locally stored app state to simplify maintenance and reduce branching logic. - - Suggested implementation prompt: - - "Remove backwards-compatibility code in @knighted/develop that supports pre-multitab component/styles storage/sync behavior. Standardize on the current tab-derived schema only, delete legacy field fallbacks and migration helpers, and update tests/docs to match the simplified contract. Validate with npm run lint and targeted Playwright suites for workspace tabs + PR drawer flows." - -6. **Promise handling conventions (consistency of intent)** +5. **Promise handling conventions (consistency of intent)** - Define a project default: use `async`/`await` with `try`/`catch` for most async control flow. - Keep Promise chains where they better express intent (for example, fire-and-forget paths with explicit `.catch()` to avoid unhandled rejections, or concise pass-through composition). - Document this as an intent-first rule so mixed syntax is acceptable only when deliberate and easy to reason about. diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts index bfbb9ef..1ccb617 100644 --- a/playwright/diagnostics.spec.ts +++ b/playwright/diagnostics.spec.ts @@ -52,7 +52,11 @@ test('clear component diagnostics removes type errors and restores rendered stat ), ) - await page.getByRole('button', { name: 'Typecheck' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText("const count: number = 'oops'") + + await runTypecheck(page) const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toBeVisible() diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index d2f7fb7..1b7da00 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github-api.js' +import { defaultGitHubChatModel } from '../src/modules/github/github-api.js' import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 5cf9fbd..45f896b 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -968,8 +968,10 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({ localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1141,8 +1143,10 @@ test('Active PR context updates controls and can be closed from AI controls', as localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1231,8 +1235,10 @@ test('Active PR context is disabled on load when pull request is closed', async localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1330,8 +1336,10 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/css', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'css/rehydrate-test', @@ -1441,8 +1449,10 @@ test('Active PR context deactivates after token remove and re-add when PR is clo localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/css', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'css/rehydrate-test', @@ -1555,8 +1565,10 @@ test('Active PR context recovers when saved head branch is missing but PR metada localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: '', @@ -1699,8 +1711,10 @@ test('Active PR context uses Push commit flow without creating a new pull reques localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -1936,8 +1950,10 @@ test('Active PR context push commit uses Git Database API atomic path by default localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -2098,8 +2114,10 @@ test('Reloaded active PR context from URL metadata keeps Push mode and status re localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', baseBranch: 'main', headBranch: 'develop/open-pr-test', @@ -2241,8 +2259,10 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', styleMode: 'sass', baseBranch: 'main', @@ -2326,8 +2346,10 @@ test('Reloaded active PR context falls back to css style mode for unsupported va localStorage.setItem( 'knighted:develop:github-pr-config:knightedcodemonkey/develop', JSON.stringify({ - syncComponentFilePath: 'src/components/App.tsx', - syncStylesFilePath: 'src/styles/app.css', + syncTabTargets: [ + { kind: 'component', path: 'src/components/App.tsx' }, + { kind: 'styles', path: 'src/styles/app.css' }, + ], renderMode: 'react', styleMode: 'scss', baseBranch: 'main', diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index 5bbfd81..a992894 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -195,21 +195,11 @@ 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() + await runTypecheck(page) await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-component')).toContainText( - 'No TypeScript errors found.', - ) + await expect(page.locator('#diagnostics-component')).not.toContainText('Type checking…') const diagnosticsText = await page.locator('#diagnostics-component').innerText() expect(diagnosticsText).not.toContain("Cannot find type definition file for 'react'") diff --git a/src/app.js b/src/app.js index 55f5ff8..22ce793 100644 --- a/src/app.js +++ b/src/app.js @@ -4,31 +4,87 @@ import { getTypeScriptLibUrls, importFromCdnWithFallback, } from './modules/cdn.js' -import { createCodeMirrorEditor } from './modules/editor-codemirror.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' +import { createCodeMirrorEditor } from './modules/editor/editor-codemirror.js' +import { createCompactAiControlsUiController } from './modules/app-core/compact-ai-controls-ui.js' +import { bindAppEventsAndStart } from './modules/app-core/app-bindings-startup.js' +import { + createEditorBootstrapOptions, + createRuntimeCoreOptions, +} from './modules/app-core/app-composition-options.js' +import { createDiagnosticsFlowController } from './modules/app-core/diagnostics-flow-controller.js' +import { createEditorBootstrapController } from './modules/app-core/editor-bootstrap-controller.js' +import { + getInitialRenderMode as getInitialRenderModeValue, + getStyleEditorLanguage, + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode as persistRenderModeValue, + setCssSourceValue, + setJsxSourceValue, + updateRenderModeEditability as updateRenderModeEditabilityValue, +} from './modules/app-core/runtime-editor-utils.js' +import { createRuntimeCoreSetup } from './modules/app-core/runtime-core-setup.js' +import { + createWorkspaceContextSnapshotGetter, + toStyleModeForTabLanguage, +} from './modules/app-core/workspace-local-helpers.js' +import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js' +import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' +import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' +import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' +import { defaultCss, defaultJsx } from './modules/app-core/defaults.js' +import { createGitHubPrContextUiController } from './modules/app-core/github-pr-context-ui.js' +import { createGitHubTokenInfoUiController } from './modules/app-core/github-token-info-ui.js' +import { createWorkspaceSyncController } from './modules/app-core/workspace-sync-controller.js' +import { createWorkspaceTabAddMenuUiController } from './modules/app-core/workspace-tab-add-menu-ui.js' +import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' +import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' +import { createGitHubByotControls } from './modules/github/github-byot-controls.js' import { formatActivePrReference, getActivePrContextSyncKey, -} from './modules/github-pr-context.js' -import { createGitHubPrEditorSyncController } from './modules/github-pr-editor-sync.js' -import { createGitHubPrDrawer } from './modules/github-pr-drawer.js' -import { createLayoutThemeController } from './modules/layout-theme.js' -import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' -import { createPreviewBackgroundController } from './modules/preview-background.js' -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 { createWorkspacesDrawer } from './modules/workspaces-drawer/drawer.js' +} from './modules/github/github-pr-context.js' +import { createGitHubPrEditorSyncController } from './modules/github/github-pr-editor-sync.js' +import { createGitHubPrDrawer } from './modules/github/github-pr-drawer.js' +import { createLayoutThemeController } from './modules/ui/layout-theme.js' +import { createLintDiagnosticsController } from './modules/diagnostics/lint-diagnostics.js' +import { createPreviewBackgroundController } from './modules/preview/preview-background.js' +import { createRenderRuntimeController } from './modules/preview/render-runtime.js' +import { createTypeDiagnosticsController } from './modules/diagnostics/type-diagnostics.js' +import { collectTopLevelDeclarations } from './modules/preview/jsx-top-level-declarations.js' +import { ensureJsxTransformSource } from './modules/preview/jsx-transform-runtime.js' +import { createEditorPoolManager } from './modules/editor/editor-pool-manager.js' +import { createWorkspaceTabsState } from './modules/workspace/workspace-tabs-state.js' +import { createWorkspacesDrawer } from './modules/workspace/workspaces-drawer/drawer.js' import { createDebouncedWorkspaceSaver, createWorkspaceStorageAdapter, -} from './modules/workspace-storage.js' +} from './modules/workspace/workspace-storage.js' +import { + createWorkspaceTabId as createWorkspaceTabIdFactory, + makeUniqueTabPath as makeUniqueTabPathFactory, +} from './modules/workspace/workspace-tab-factory.js' +import { createEnsureWorkspaceTabsShape } from './modules/workspace/workspace-tab-shape.js' +import { + getDirtyStateForTabChange, + getPathFileName, + getTabKind, + getTabTargetPrFilePath, + getWorkspaceTabDisplay, + hasTabCommittedSyncState, + isStyleTabLanguage, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + normalizeWorkspacePathValue, + resolveSyncedBaselineContent, + resolveWorkspaceActiveTabId, + resolveWorkspaceRecordIdentity, + toNonEmptyWorkspaceText, + toWorkspaceRecordId, + toWorkspaceSyncSha, + toWorkspaceSyncedContent, + toWorkspaceSyncTimestamp, +} from './modules/workspace/workspace-tab-helpers.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -116,7 +172,6 @@ const diagnosticsClearStyles = document.getElementById('diagnostics-clear-styles const diagnosticsClearAll = document.getElementById('diagnostics-clear-all') const diagnosticsComponent = document.getElementById('diagnostics-component') const diagnosticsStyles = document.getElementById('diagnostics-styles') -const cdnLoading = document.getElementById('cdn-loading') const appToast = document.getElementById('app-toast') const previewBgColorInput = document.getElementById('preview-bg-color') const clearConfirmDialog = document.getElementById('clear-confirm-dialog') @@ -128,7 +183,6 @@ 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 renderModeStorageKey = 'knighted-develop:render-mode' const editorKinds = ['component', 'styles'] @@ -158,7 +212,6 @@ let pendingClearAction = null let suppressEditorChangeSideEffects = false let appToastDismissTimer = null const workspaceStorage = createWorkspaceStorageAdapter() -let workspaceSaver = null let activeWorkspaceRecordId = '' let activeWorkspaceCreatedAt = null let workspacesDrawerController = null @@ -191,7 +244,6 @@ const editorPool = createEditorPoolManager({ maxMounted: 2 }) let workspaceTabRenameState = { tabId: '', } -let workspaceTabAddMenuOpen = false let isRenderingWorkspaceTabs = false let hasPendingWorkspaceTabsRender = false let draggedWorkspaceTabId = '' @@ -261,319 +313,47 @@ const layoutTheme = createLayoutThemeController({ const { applyTheme, getInitialTheme } = layoutTheme -const compactViewportMediaQuery = window.matchMedia('(max-width: 900px)') -let compactAiControlsOpen = false -let githubTokenInfoOpen = false - -const setGitHubTokenInfoOpen = isOpen => { - if (!(githubTokenInfo instanceof HTMLButtonElement) || !githubTokenInfoPanel) { - return - } - - githubTokenInfoOpen = Boolean(isOpen) - githubTokenInfo.setAttribute('aria-expanded', githubTokenInfoOpen ? 'true' : 'false') - - if (githubTokenInfoOpen) { - githubTokenInfoPanel.removeAttribute('hidden') - return - } - - githubTokenInfoPanel.setAttribute('hidden', '') -} - -const setCompactAiControlsOpen = isOpen => { - if (!(aiControlsToggle instanceof HTMLButtonElement) || !githubAiControls) { - return - } - - aiControlsToggle.removeAttribute('hidden') - - if (!isCompactViewport()) { - compactAiControlsOpen = false - setGitHubTokenInfoOpen(false) - aiControlsToggle.setAttribute('aria-expanded', 'false') - githubAiControls.removeAttribute('data-compact-open') - githubAiControls.removeAttribute('hidden') - return - } - - compactAiControlsOpen = Boolean(isOpen) - aiControlsToggle.setAttribute('aria-expanded', compactAiControlsOpen ? 'true' : 'false') - githubAiControls.dataset.compactOpen = compactAiControlsOpen ? 'true' : 'false' - - if (!compactAiControlsOpen) { - setGitHubTokenInfoOpen(false) - } -} - -const isCompactViewport = () => compactViewportMediaQuery.matches - -const getPanelCollapseAxis = panelName => { - if (isCompactViewport()) { - return 'vertical' - } - - if (panelName === 'preview') { - return 'horizontal' - } - - if (panelName === 'component' || panelName === 'styles') { - return 'vertical' - } - - return 'vertical' -} - -const getPanelCollapseDirection = panelName => { - const axis = getPanelCollapseAxis(panelName) - if (axis !== 'horizontal') { - return 'none' - } - - if (panelName === 'preview') { - return 'right' - } - - if (panelName === 'component') { - return 'left' - } - - if (panelName === 'styles') { - return 'right' - } - - return 'right' -} - -const panelCollapseState = { - component: false, - styles: false, - preview: false, -} - -const panelToolsState = { - component: false, - styles: false, -} - -const applyEditorToolsVisibility = () => { - for (const editorKind of editorKinds) { - editorPanelsByKind[editorKind]?.classList.toggle( - 'panel--tools-hidden', - !panelToolsState[editorKind], - ) - } - - for (const button of editorToolsButtons) { - const panelName = button.dataset.editorToolsToggle - if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { - continue - } - - const isVisible = panelToolsState[panelName] - button.setAttribute('aria-pressed', isVisible ? 'true' : 'false') - button.setAttribute('aria-label', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) - button.setAttribute('title', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) - } -} - -const normalizePanelCollapseState = () => { - const collapsedPanels = Object.entries(panelCollapseState) - .filter(([, isCollapsed]) => isCollapsed) - .map(([panelName]) => panelName) - - if (collapsedPanels.length === Object.keys(panelCollapseState).length) { - panelCollapseState.preview = false - } -} - -const syncPanelCollapseButtons = () => { - const collapsedCount = Object.values(panelCollapseState).filter(Boolean).length - - for (const button of panelCollapseButtons) { - const panelName = button.dataset.panelCollapse - if (!panelName || !Object.hasOwn(panelCollapseState, panelName)) { - continue - } - - const axis = getPanelCollapseAxis(panelName) - const direction = getPanelCollapseDirection(panelName) - const isCollapsed = panelCollapseState[panelName] === true - const panelTitle = `${panelName.charAt(0).toUpperCase()}${panelName.slice(1)}` - const canCollapse = isCollapsed || collapsedCount < 2 - - button.dataset.collapseAxis = axis - button.dataset.collapseDirection = direction - button.dataset.collapsed = isCollapsed ? 'true' : 'false' - button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true') - button.disabled = !canCollapse - button.setAttribute('aria-disabled', canCollapse ? 'false' : 'true') - button.setAttribute( - 'aria-label', - `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel`, - ) - button.setAttribute( - 'title', - canCollapse - ? `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel` - : 'At least one panel must remain expanded.', - ) - } -} - -const applyPanelCollapseState = () => { - normalizePanelCollapseState() - - const previewAxis = getPanelCollapseAxis('preview') - const componentAxis = getPanelCollapseAxis('component') - const stylesAxis = getPanelCollapseAxis('styles') - - if (componentEditorPanel) { - const isCollapsed = panelCollapseState.component - componentEditorPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && componentAxis === 'vertical', - ) - componentEditorPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && componentAxis === 'horizontal', - ) - } - - if (stylesEditorPanel) { - const isCollapsed = panelCollapseState.styles - stylesEditorPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && stylesAxis === 'vertical', - ) - stylesEditorPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && stylesAxis === 'horizontal', - ) - } - - if (previewPanel) { - const isCollapsed = panelCollapseState.preview - previewPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && previewAxis === 'vertical', - ) - previewPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && previewAxis === 'horizontal', - ) - } - - appGrid.classList.toggle( - 'app-grid--preview-collapsed-horizontal', - panelCollapseState.preview && previewAxis === 'horizontal', - ) - appGrid.classList.toggle('app-grid--preview-collapsed', panelCollapseState.preview) - appGrid.classList.toggle('app-grid--component-collapsed', panelCollapseState.component) - appGrid.classList.toggle('app-grid--styles-collapsed', panelCollapseState.styles) - appGrid.classList.toggle( - 'app-grid--component-collapsed-horizontal', - panelCollapseState.component && componentAxis === 'horizontal', - ) - appGrid.classList.toggle( - 'app-grid--styles-collapsed-horizontal', - panelCollapseState.styles && stylesAxis === 'horizontal', - ) - - syncPanelCollapseButtons() -} - -const togglePanelCollapse = panelName => { - if (!Object.hasOwn(panelCollapseState, panelName)) { - return - } - - panelCollapseState[panelName] = !panelCollapseState[panelName] - applyPanelCollapseState() -} - -const toTextareaOffset = (source, line, column = 1) => { - if (typeof source !== 'string' || source.length === 0) { - return 0 - } - - const targetLine = Number.isFinite(line) ? Math.max(1, Number(line)) : 1 - const targetColumn = Number.isFinite(column) ? Math.max(1, Number(column)) : 1 - - let currentLine = 1 - let lineStartOffset = 0 - - for (let index = 0; index < source.length; index += 1) { - if (currentLine === targetLine) { - lineStartOffset = index - break - } - - if (source[index] === '\n') { - currentLine += 1 - lineStartOffset = index + 1 - } - } - - const nextNewlineOffset = source.indexOf('\n', lineStartOffset) - const lineEndOffset = nextNewlineOffset === -1 ? source.length : nextNewlineOffset - return Math.min(lineStartOffset + targetColumn - 1, lineEndOffset) -} - -const navigateToComponentDiagnostic = ({ line, column }) => { - if (jsxCodeEditor && typeof jsxCodeEditor.revealPosition === 'function') { - jsxCodeEditor.revealPosition({ line, column }) - return - } - - if (!(jsxEditor instanceof HTMLTextAreaElement)) { - return - } - - const source = jsxEditor.value - const offset = toTextareaOffset(source, line, column) - jsxEditor.focus() - jsxEditor.setSelectionRange(offset, offset) -} - -const navigateToStylesDiagnostic = ({ line, column }) => { - if (cssCodeEditor && typeof cssCodeEditor.revealPosition === 'function') { - cssCodeEditor.revealPosition({ line, column }) - return - } - - if (!(cssEditor instanceof HTMLTextAreaElement)) { - return - } - - const source = cssEditor.value - const offset = toTextareaOffset(source, line, column) - cssEditor.focus() - cssEditor.setSelectionRange(offset, offset) -} +const githubTokenInfoUi = createGitHubTokenInfoUiController({ + tokenInfoButton: githubTokenInfo, + tokenInfoPanel: githubTokenInfoPanel, +}) +const compactAiControlsUi = createCompactAiControlsUiController({ + toggleButton: aiControlsToggle, + controlsRoot: githubAiControls, + closeTokenInfo: () => githubTokenInfoUi.close(), +}) +const workspaceTabAddMenuUi = createWorkspaceTabAddMenuUiController({ + addButton: workspaceTabAddButton, + addMenu: workspaceTabAddMenu, + addModuleButton: workspaceTabAddModule, +}) -const diagnosticsUi = createDiagnosticsUiController({ +const { + panelToolsState, + applyEditorToolsVisibility, + applyPanelCollapseState, + togglePanelCollapse, + diagnosticsUi, +} = createLayoutDiagnosticsSetup({ + compactAiControlsUi, + appGrid, + previewPanel, + componentEditorPanel, + stylesEditorPanel, + panelCollapseButtons, + editorKinds, + editorPanelsByKind, + editorToolsButtons, + createDiagnosticsUiController, diagnosticsToggle, diagnosticsDrawer, diagnosticsComponent, diagnosticsStyles, statusNode, - onNavigateDiagnostic: diagnostic => { - if (diagnostic?.scope === 'component') { - navigateToComponentDiagnostic({ - line: diagnostic.line, - column: diagnostic.column, - }) - return - } - - if (diagnostic?.scope === 'styles') { - navigateToStylesDiagnostic({ - line: diagnostic.line, - column: diagnostic.column, - }) - } - }, + getJsxCodeEditor: () => jsxCodeEditor, + getCssCodeEditor: () => cssCodeEditor, + jsxEditor, + cssEditor, }) const { @@ -585,15 +365,12 @@ const { getDiagnosticsDrawerOpen, incrementLintDiagnosticsRuns, incrementTypeDiagnosticsRuns, - renderDiagnosticsScope, setDiagnosticsDrawerOpen, setLintDiagnosticsPending, setTypeDiagnosticsPending, setStatus, setStyleDiagnosticsDetails, setTypeDiagnosticsDetails, - updateDiagnosticsToggleLabel, - updateUiIssueIndicators, } = diagnosticsUi const githubAiContextState = { @@ -623,126 +400,31 @@ let prDrawerController = { dispose: () => {}, } -const setGitHubPrToggleVisual = mode => { - if ( - !(githubPrToggle instanceof HTMLButtonElement) || - !(githubPrToggleLabel instanceof HTMLElement) || - !(githubPrToggleIcon instanceof SVGElement) || - !(githubPrToggleIconPath instanceof SVGPathElement) - ) { - 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 - - 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) { - if (workspacesToggle instanceof HTMLButtonElement) { - workspacesToggle.disabled = false - } - - aiChatToggle?.removeAttribute('hidden') - - githubPrToggle?.removeAttribute('hidden') - if (!githubAiContextState.activePrContext) { - workspacesToggle?.removeAttribute('hidden') - } - - if (githubAiContextState.activePrContext) { - githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') - workspacesToggle?.setAttribute('hidden', '') - } else { - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - } - return - } - - aiChatToggle?.setAttribute('hidden', '') - aiChatToggle?.setAttribute('aria-expanded', 'false') - if (workspacesToggle instanceof HTMLButtonElement) { - workspacesToggle.disabled = true - } - githubAiContextState.activePrContext = null - githubAiContextState.activePrEditorSyncKey = '' - githubAiContextState.hasSyncedActivePrEditorContent = false - syncEditorPrContextIndicators(false) - setGitHubPrToggleVisual('open-pr') - githubPrToggle?.setAttribute('hidden', '') - githubPrToggle?.setAttribute('aria-expanded', 'false') - workspacesToggle?.setAttribute('hidden', '') - workspacesToggle?.setAttribute('aria-expanded', 'false') - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - chatDrawerController.setOpen(false) - prDrawerController.setOpen(false) - void workspacesDrawerController?.setOpen(false) -} +const prContextUi = createGitHubPrContextUiController({ + contextState: githubAiContextState, + getActivePrContextSyncKey, + githubPrToggle, + githubPrToggleLabel, + githubPrToggleIcon, + githubPrToggleIconPath, + componentPrSyncIcon, + componentPrSyncIconPath, + stylesPrSyncIcon, + stylesPrSyncIconPath, + githubPrContextClose, + githubPrContextDisconnect, + aiChatToggle, + workspacesToggle, + githubPrOpenIcon, + githubPrPushCommitIcon, + closeChatDrawer: () => { + chatDrawerController.setOpen(false) + }, + closePrDrawer: () => { + prDrawerController.setOpen(false) + }, + closeWorkspacesDrawer: () => workspacesDrawerController?.setOpen(false), +}) const byotControls = createGitHubByotControls({ controlsRoot: githubAiControls, @@ -777,7 +459,7 @@ const byotControls = createGitHubByotControls({ }, onTokenChange: token => { githubAiContextState.token = token - syncAiChatTokenVisibility(token) + prContextUi.syncAiChatTokenVisibility(token) chatDrawerController.setToken(token) prDrawerController.setToken(token) }, @@ -793,130 +475,70 @@ const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.g 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 getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ + getCurrentSelectedRepository, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, +}) -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 toWorkspaceSyncTimestamp = value => - Number.isFinite(value) && value > 0 ? Math.max(0, Number(value)) : null - -const toWorkspaceSyncSha = value => - typeof value === 'string' && value.trim().length > 0 ? value.trim() : null - -const toWorkspaceSyncedContent = value => (typeof value === 'string' ? value : null) - -const normalizeWorkspacePathValue = value => - toNonEmptyWorkspaceText(value).replace(/\\/g, '/').replace(/\/+/g, '/') - -const getTabTargetPrFilePath = tab => normalizeWorkspacePathValue(tab?.targetPrFilePath) - -const hasTabSyncBaseline = tab => - Boolean( - getTabTargetPrFilePath(tab) || - toWorkspaceSyncTimestamp(tab?.syncedAt) || - toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), - ) - -const hasTabCommittedSyncState = tab => - Boolean( - toWorkspaceSyncTimestamp(tab?.syncedAt) || - toWorkspaceSyncSha(tab?.lastSyncedRemoteSha) || - toWorkspaceSyncedContent(tab?.syncedContent), - ) - -const getDirtyStateForTabChange = (tab, nextContent) => { - if (!hasTabSyncBaseline(tab)) { - return Boolean(tab?.isDirty) - } - - const normalizedNextContent = typeof nextContent === 'string' ? nextContent : '' - const syncedContent = toWorkspaceSyncedContent(tab?.syncedContent) - - if (syncedContent === null) { - if (normalizedNextContent === (typeof tab?.content === 'string' ? tab.content : '')) { - return Boolean(tab?.isDirty) - } - - return true - } - - return normalizedNextContent !== syncedContent -} - -const resolveSyncedBaselineContent = ({ tab, content }) => { - const explicitSyncedContent = toWorkspaceSyncedContent(tab?.syncedContent) - if (explicitSyncedContent !== null) { - return explicitSyncedContent - } - - if (hasTabSyncBaseline(tab) && !tab?.isDirty) { - return content - } - - return null -} - -const isStyleTabLanguage = language => - styleTabLanguages.has(toNonEmptyWorkspaceText(language)) - -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 { + getWorkspaceTabByKind, + syncHeaderLabels, + persistActiveTabEditorContent, + loadWorkspaceTabIntoEditor, +} = createWorkspaceEditorHelpers({ + workspaceTabsState, + getTabKind, + editorKinds, + editorPanelsByKind, + editorHeaderLabelByKind, + defaultTabNameByKind, + toNonEmptyWorkspaceText, + getLoadedStylesTabId: () => loadedStylesTabId, + getLoadedComponentTabId: () => loadedComponentTabId, + setLoadedStylesTabId: value => (loadedStylesTabId = value), + setLoadedComponentTabId: value => (loadedComponentTabId = value), + getCssSource: () => getCssSource(), + getJsxSource: () => getJsxSource(), + getDirtyStateForTabChange, + setCssSource, + setJsxSource, + styleMode, + toStyleModeForTabLanguage, + getStyleEditorLanguage, + getCssCodeEditor: () => cssCodeEditor, + setSuppressEditorChangeSideEffects: value => (suppressEditorChangeSideEffects = value), + editorPool, +}) + +const workspaceSyncController = createWorkspaceSyncController({ + workspaceTabsState, + getTabKind, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncedContent, + toWorkspaceSyncSha, + toNonEmptyWorkspaceText, + hasTabCommittedSyncState, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + getWorkspaceTabByKind, + queueWorkspaceSave: () => queueWorkspaceSave(), + resolveWorkspaceRecordIdentity, + getWorkspaceContextSnapshot, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + getRenderModeValue: () => renderMode.value, + normalizeRenderMode: mode => normalizeRenderMode(mode), +}) + const getLoadedComponentWorkspaceTab = () => workspaceTabsState.getTab(loadedComponentTabId) ?? getWorkspaceTabByKind('component') @@ -925,2554 +547,534 @@ const getTypecheckSourcePath = () => { return toNonEmptyWorkspaceText(loadedComponentTab?.path) || defaultComponentTabPath } -const toStyleModeForTabLanguage = language => { - const normalized = toNonEmptyWorkspaceText(language) - if (normalized === 'less') { - return 'less' - } +const createWorkspaceTabId = prefix => createWorkspaceTabIdFactory(prefix) - if (normalized === 'sass') { - return 'sass' - } +const makeUniqueTabPath = ({ basePath, suffix = '' }) => + makeUniqueTabPathFactory({ + basePath, + suffix, + tabs: workspaceTabsState.getTabs(), + toNonEmptyWorkspaceText, + }) - if (normalized === 'module') { - return 'module' - } +const ensureWorkspaceTabsShape = createEnsureWorkspaceTabsShape({ + defaultComponentTabName, + defaultComponentTabPath, + defaultStylesTabName, + defaultStylesTabPath, + defaultJsx, + normalizeEntryTabPath, + getPathFileName, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncTimestamp, + toWorkspaceSyncSha, + resolveSyncedBaselineContent, + toNonEmptyWorkspaceText, + isStyleTabLanguage, +}) - return 'css' -} +const buildWorkspaceTabsSnapshot = () => + workspaceSyncController.buildWorkspaceTabsSnapshot() -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] +const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => + workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - if (headerLabel) { - headerLabel.textContent = - toNonEmptyWorkspaceText(tab?.name) || defaultTabNameByKind[editorKind] - } - } -} +const getWorkspacePrFileCommits = () => + workspaceSyncController.getWorkspacePrFileCommits() -const persistActiveTabEditorContent = () => { - const activeTab = getActiveWorkspaceTab() +const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets() - if (!activeTab) { - return - } +const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => + workspaceSyncController.reconcileWorkspaceTabsWithEditorSync({ tabTargets }) - const nextContent = getTabKind(activeTab) === 'styles' ? getCssSource() : getJsxSource() +const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => + workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId }) - if (nextContent === activeTab.content) { - return - } +const { + workspaceSaveController, + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + queueWorkspaceSave, + flushWorkspaceSave, + setActiveWorkspaceTab, + addWorkspaceTab, + renderWorkspaceTabs, + loadPreferredWorkspaceContext, + bindWorkspaceMetadataPersistence, +} = createWorkspaceControllersSetup({ + createDebouncedWorkspaceSaver, + workspaceStorage, + getWorkspacesDrawerController: () => workspacesDrawerController, + toNonEmptyWorkspaceText, + buildWorkspaceRecordSnapshot, + setStatus, + getIsApplyingWorkspaceSnapshot: () => isApplyingWorkspaceSnapshot, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + getCurrentSelectedRepository, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), + ensureWorkspaceTabsShape, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabsState, + resolveWorkspaceActiveTabId, + normalizeRenderMode: mode => normalizeRenderMode(mode), + getRenderModeValue: () => renderMode.value, + setRenderModeValue: value => { + renderMode.value = value + }, + persistRenderMode: mode => persistRenderMode(mode), + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + updateRenderModeEditability: () => updateRenderModeEditability(), + getHasCompletedInitialWorkspaceBootstrap: () => hasCompletedInitialWorkspaceBootstrap, + maybeRender: () => maybeRender(), + toWorkspaceRecordId, + workspaceTabsStrip, + getWorkspaceTabRenameState: () => workspaceTabRenameState, + getDraggedWorkspaceTabId: () => draggedWorkspaceTabId, + setDraggedWorkspaceTabId: value => (draggedWorkspaceTabId = value), + getDragOverWorkspaceTabId: () => dragOverWorkspaceTabId, + setDragOverWorkspaceTabId: value => (dragOverWorkspaceTabId = value), + getSuppressWorkspaceTabClick: () => suppressWorkspaceTabClick, + setSuppressWorkspaceTabClick: value => (suppressWorkspaceTabClick = value), + getIsRenderingWorkspaceTabs: () => isRenderingWorkspaceTabs, + setIsRenderingWorkspaceTabs: value => (isRenderingWorkspaceTabs = value), + getHasPendingWorkspaceTabsRender: () => hasPendingWorkspaceTabsRender, + setHasPendingWorkspaceTabsRender: value => (hasPendingWorkspaceTabsRender = value), + persistActiveTabEditorContent, + getWorkspaceTabDisplay, + workspaceTabsShell, + workspaceTabAddWrap, + setWorkspaceTabRenameState: value => (workspaceTabRenameState = value), + allowedEntryTabFileNames, + getPathFileName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + defaultComponentTabName, + getDirtyStateForTabChange, + syncHeaderLabels, + setWorkspaceTabAddMenuOpen: isOpen => { + workspaceTabAddMenuUi.setOpen(isOpen) + }, + confirmAction: options => confirmAction(options), + getTabKind, + getLoadedComponentTabId: () => loadedComponentTabId, + setLoadedComponentTabId: value => (loadedComponentTabId = value), + getLoadedStylesTabId: () => loadedStylesTabId, + setLoadedStylesTabId: value => (loadedStylesTabId = value), + getWorkspaceTabByKind, + makeUniqueTabPath, + createWorkspaceTabId, +}) - workspaceTabsState.upsertTab( - { - ...activeTab, - content: nextContent, - isDirty: getDirtyStateForTabChange(activeTab, nextContent), - lastModified: Date.now(), - isActive: true, +const githubWorkflows = createGitHubWorkflowsSetup({ + factories: { + createGitHubPrEditorSyncController, + createGitHubChatDrawer, + createGitHubPrDrawer, + createWorkspacesDrawer, + }, + platform: { + ensureJsxTransformSource, + collectTopLevelDeclarations, + cdnImports, + importFromCdnWithFallback, + }, + state: { + githubAiContextState, + }, + byot: { + byotControls, + getCurrentGitHubToken, + getCurrentSelectedRepository, + setCurrentSelectedRepository: fullName => + byotControls.setSelectedRepository(fullName), + }, + ui: { + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + githubPrToggle, + githubPrDrawer, + githubPrClose, + githubPrRepoSelect, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + githubPrCommitMessage, + githubPrIncludeAppWrapper, + githubPrSubmit, + openPrTitle, + githubPrStatus, + workspacesToggle, + workspacesDrawer, + workspacesClose, + workspacesStatus, + workspacesSearch, + workspacesSelect, + workspacesOpen, + workspacesRemove, + }, + workspace: { + workspaceStorage, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + getWorkspacePrFileCommits, + getEditorSyncTargets, + reconcileWorkspaceTabsWithPushUpdates, + }, + runtime: { + getRenderMode: () => renderMode.value, + getStyleMode: () => styleMode.value, + getActivePrContextSyncKey, + prContextUi, + getTokenForVisibility: () => githubAiContextState.token, + closeWorkspacesDrawer: () => { + void workspacesDrawerController?.setOpen(false) }, - { 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) { + getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, + syncFromActiveContext: ({ tabTargets }) => { + reconcileWorkspaceTabsWithEditorSync({ tabTargets }) + }, + formatActivePrReference, + githubPrContextClose, + githubPrContextDisconnect, + }, + actions: { + applyRenderMode, + applyStyleMode, + confirmAction: options => confirmAction(options), + setStatus, + showAppToast, + setComponentSource: value => { suppressEditorChangeSideEffects = true try { - cssCodeEditor.setLanguage(getStyleEditorLanguage(nextStyleMode)) + setJsxSource(value) } 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 nextTabs = [...inputTabs] - - if (!hasComponent) { - nextTabs.unshift({ - id: 'component', - name: defaultComponentTabName, - path: defaultComponentTabPath, - language: 'javascript-jsx', - role: 'entry', - content: defaultJsx, - isActive: true, - }) - } - - return nextTabs.map(tab => { - if (tab?.id === 'component') { - const normalizedEntryPath = normalizeEntryTabPath(tab.path, { - preferredFileName: tab.name, - }) - return { - ...tab, - role: 'entry', - language: 'javascript-jsx', - content: typeof tab?.content === 'string' ? tab.content : '', - path: normalizedEntryPath, - name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, - targetPrFilePath: - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(normalizedEntryPath), - isDirty: Boolean(tab?.isDirty), - syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), - lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), - syncedContent: resolveSyncedBaselineContent({ - tab, - content: typeof tab?.content === 'string' ? tab.content : '', - }), - } - } - - 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', - content: typeof tab?.content === 'string' ? tab.content : '', - path: normalizedStylesPath, - name: - !normalizedStylesNameInput || - normalizedStylesNameInput.toLowerCase() === 'styles' - ? getPathFileName(normalizedStylesPath) || defaultStylesTabName - : normalizedStylesNameInput, - targetPrFilePath: - getTabTargetPrFilePath(tab) || - normalizeWorkspacePathValue(normalizedStylesPath), - isDirty: Boolean(tab?.isDirty), - syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), - lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), - syncedContent: resolveSyncedBaselineContent({ - tab, - content: typeof tab?.content === 'string' ? tab.content : '', - }), - } - } - - const nextPath = toNonEmptyWorkspaceText(tab?.path) - const nextContent = typeof tab?.content === 'string' ? tab.content : '' - return { - ...tab, - role: 'module', - language: isStyleTabLanguage(tab?.language) ? tab.language : 'javascript-jsx', - path: nextPath, - content: nextContent, - name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, - targetPrFilePath: getTabTargetPrFilePath(tab) || null, - isDirty: Boolean(tab?.isDirty), - syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), - lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), - syncedContent: resolveSyncedBaselineContent({ - tab, - content: nextContent, - }), - } - }) -} - -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 currentPath = toNonEmptyWorkspaceText(tab.path) - - const currentContent = - tab.id === activeTabId - ? getTabKind(tab) === 'styles' - ? getCssSource() - : getJsxSource() - : typeof tab.content === 'string' - ? tab.content - : '' - - const targetPrFilePath = - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(currentPath) || null - - return { - ...tab, - path: currentPath, - content: currentContent, - syncedContent: toWorkspaceSyncedContent(tab?.syncedContent), - targetPrFilePath, - isActive: activeTabId === tab.id, - lastModified: Date.now(), - } - }) -} - -const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { - const updates = Array.isArray(fileUpdates) ? fileUpdates : [] - if (updates.length === 0) { - return 0 - } - - const updatesByPath = new Map() - for (const update of updates) { - const normalizedPath = normalizeWorkspacePathValue(update?.path) - if (!normalizedPath) { - continue - } - - updatesByPath.set(normalizedPath, toWorkspaceSyncSha(update?.commitSha)) - } - - if (updatesByPath.size === 0) { - return 0 - } - - const now = Date.now() - let updatedTabCount = 0 - const activeTabId = workspaceTabsState.getActiveTabId() - const nextTabs = workspaceTabsState.getTabs().map(tab => { - const candidatePaths = [ - getTabTargetPrFilePath(tab), - normalizeWorkspacePathValue(tab.path), - ].filter(Boolean) - - const matchedPath = candidatePaths.find(path => updatesByPath.has(path)) - if (!matchedPath) { - return tab - } - - updatedTabCount += 1 - const commitSha = updatesByPath.get(matchedPath) - - return { - ...tab, - targetPrFilePath: matchedPath, - syncedContent: typeof tab?.content === 'string' ? tab.content : '', - isDirty: false, - syncedAt: now, - lastSyncedRemoteSha: commitSha || toWorkspaceSyncSha(tab.lastSyncedRemoteSha), - lastModified: now, - } - }) - - if (updatedTabCount > 0) { - workspaceTabsState.replaceTabs({ - tabs: nextTabs, - activeTabId, - }) - queueWorkspaceSave() - } - - return updatedTabCount -} - -const getWorkspacePrFileCommits = () => { - const snapshotTabs = buildWorkspaceTabsSnapshot() - const dedupedByPath = new Map() - - for (const tab of snapshotTabs) { - const shouldCommitTab = Boolean(tab?.isDirty) || !hasTabCommittedSyncState(tab) - if (!shouldCommitTab) { - continue - } - - const path = - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' - if (!path) { - continue - } - - dedupedByPath.set(path, { - path, - content: typeof tab?.content === 'string' ? tab.content : '', - tabLabel: toNonEmptyWorkspaceText(tab?.name) || toNonEmptyWorkspaceText(tab?.id), - isEntry: tab?.role === 'entry', - }) - } - - return [...dedupedByPath.values()] -} - -const getEditorSyncTargets = () => { - const componentTab = getWorkspaceTabByKind('component') - const stylesTab = getWorkspaceTabByKind('styles') - - return { - componentFilePath: - getTabTargetPrFilePath(componentTab) || - normalizeWorkspacePathValue(componentTab?.path) || - '', - stylesFilePath: - getTabTargetPrFilePath(stylesTab) || - normalizeWorkspacePathValue(stylesTab?.path) || - '', - } -} - -const reconcileWorkspaceTabsWithEditorSync = ({ componentPath, stylesPath } = {}) => { - const normalizedComponentPath = normalizeWorkspacePathValue(componentPath) - const normalizedStylesPath = normalizeWorkspacePathValue(stylesPath) - - if (!normalizedComponentPath && !normalizedStylesPath) { - return 0 - } - - const now = Date.now() - let updatedTabCount = 0 - const activeTabId = workspaceTabsState.getActiveTabId() - const componentSource = getJsxSource() - const stylesSource = getCssSource() - - const nextTabs = workspaceTabsState.getTabs().map(tab => { - const tabKind = getTabKind(tab) - const expectedPath = - tabKind === 'styles' ? normalizedStylesPath : normalizedComponentPath - if (!expectedPath) { - return tab - } - - const candidatePaths = [ - getTabTargetPrFilePath(tab), - normalizeWorkspacePathValue(tab.path), - ].filter(Boolean) - const matchedPath = candidatePaths.find(path => path === expectedPath) - if (!matchedPath) { - return tab - } - - const syncedContent = tabKind === 'styles' ? stylesSource : componentSource - updatedTabCount += 1 - return { - ...tab, - content: syncedContent, - syncedContent, - isDirty: false, - syncedAt: now, - lastModified: now, - } - }) - - if (updatedTabCount > 0) { - workspaceTabsState.replaceTabs({ - tabs: nextTabs, - activeTabId, - }) - queueWorkspaceSave() - } - - return updatedTabCount -} - -const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { - const context = getWorkspaceContextSnapshot() - const id = - 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 listLocalContextRecords = async () => { - const selectedRepository = getCurrentSelectedRepository() - return workspaceStorage.listWorkspaces({ - repo: selectedRepository || '', - }) -} - -const refreshLocalContextOptions = async () => { - const options = await listLocalContextRecords() - - if (workspacesDrawerController) { - workspacesDrawerController.setSelectedId(activeWorkspaceRecordId) - await workspacesDrawerController.refresh() - } - - 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) - 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 - } - persistRenderMode(nextRenderMode) - - const activeTab = getActiveWorkspaceTab() - if (activeTab) { - loadWorkspaceTabIntoEditor(activeTab) - } - - renderWorkspaceTabs() - updateRenderModeEditability() - - 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() - updateRenderModeEditability() - return - } - - persistActiveTabEditorContent() - - const changed = workspaceTabsState.setActiveTab(targetTab.id) - const activeTab = getActiveWorkspaceTab() - if (activeTab) { - loadWorkspaceTabIntoEditor(activeTab) - } - - renderWorkspaceTabs() - updateRenderModeEditability() - - 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, - isDirty: getDirtyStateForTabChange( - tab, - typeof tab?.content === 'string' ? tab.content : '', - ), - lastModified: Date.now(), - }) - - syncHeaderLabels() - renderWorkspaceTabs() - queueWorkspaceSave() - maybeRender() -} - -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 - } - - if ( - nextOpen && - document.activeElement === workspaceTabAddButton && - workspaceTabAddModule instanceof HTMLButtonElement - ) { - workspaceTabAddModule.focus() - } - - if ( - !nextOpen && - workspaceTabAddMenu instanceof HTMLElement && - document.activeElement instanceof Node && - workspaceTabAddMenu.contains(document.activeElement) && - workspaceTabAddButton instanceof HTMLButtonElement - ) { - workspaceTabAddButton.focus() - } -} - -const clearWorkspaceTabDragState = () => { - draggedWorkspaceTabId = '' - dragOverWorkspaceTabId = '' -} - -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 isRenaming = workspaceTabRenameState.tabId === tab.id - const tabContainer = document.createElement('li') - tabContainer.className = 'workspace-tab' - tabContainer.dataset.active = isActive ? 'true' : 'false' - tabContainer.dataset.tabId = tab.id - tabContainer.setAttribute('aria-label', `Workspace tab ${tab.name}`) - tabContainer.draggable = !isRenaming - tabContainer.dataset.dragOver = - dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false' - tabContainer.addEventListener('click', event => { - if (suppressWorkspaceTabClick) { - suppressWorkspaceTabClick = false - return - } - - const clickTarget = event.target - if (!(clickTarget instanceof Element)) { - return - } - - if ( - clickTarget.closest('.workspace-tab__rename, .workspace-tab__remove, input') - ) { - return - } - - setActiveWorkspaceTab(tab.id) - }) - if (!isRenaming) { - tabContainer.addEventListener('dragstart', event => { - draggedWorkspaceTabId = tab.id - dragOverWorkspaceTabId = '' - suppressWorkspaceTabClick = true - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'move' - event.dataTransfer.setData('text/plain', tab.id) - } - }) - tabContainer.addEventListener('dragend', () => { - clearWorkspaceTabDragState() - queueMicrotask(() => { - suppressWorkspaceTabClick = false - }) - renderWorkspaceTabs() - }) - tabContainer.addEventListener('dragover', event => { - if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { - return - } - - event.preventDefault() - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move' - } - - if (dragOverWorkspaceTabId !== tab.id) { - dragOverWorkspaceTabId = tab.id - tabContainer.dataset.dragOver = 'true' - } - }) - tabContainer.addEventListener('dragleave', event => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && tabContainer.contains(relatedTarget)) { - return - } - - if (dragOverWorkspaceTabId === tab.id) { - dragOverWorkspaceTabId = '' - tabContainer.dataset.dragOver = 'false' - } - }) - tabContainer.addEventListener('drop', event => { - event.preventDefault() - - if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { - clearWorkspaceTabDragState() - renderWorkspaceTabs() - return - } - - persistActiveTabEditorContent() - - const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id) - clearWorkspaceTabDragState() - renderWorkspaceTabs() - - if (!moved) { - return - } - - queueWorkspaceSave() - }) - } - - 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 - } - - const fileNameNode = document.createElement('span') - fileNameNode.className = 'workspace-tab__path-file' - fileNameNode.textContent = tabDisplay.fileName || tab.name - selectButton.append(fileNameNode) - - if (isActive) { - selectButton.setAttribute('aria-current', 'true') - } else { - selectButton.removeAttribute('aria-current') - } - 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) - - if (tab.role === 'entry') { - const metaBadge = document.createElement('span') - metaBadge.className = 'workspace-tab__meta' - metaBadge.textContent = 'Entry' - tabContainer.append(metaBadge) - } - - if (tab.isDirty) { - const dirtyBadge = document.createElement('span') - dirtyBadge.className = 'workspace-tab__meta workspace-tab__meta--dirty' - dirtyBadge.textContent = 'Dirty' - tabContainer.append(dirtyBadge) - } - - const renameButton = document.createElement('button') - renameButton.className = 'workspace-tab__rename' - renameButton.type = 'button' - 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) - } - - if ( - workspaceTabAddWrap instanceof HTMLElement && - workspaceTabsShell instanceof HTMLElement - ) { - workspaceTabsShell.append(workspaceTabAddWrap) - } - } finally { - isRenderingWorkspaceTabs = false - } - - if (hasPendingWorkspaceTabsRender) { - hasPendingWorkspaceTabsRender = false - renderWorkspaceTabs() - return - } - - syncEditorFromActiveWorkspaceTab() -} - -const loadPreferredWorkspaceContext = async () => { - const options = await refreshLocalContextOptions() - - if (!Array.isArray(options) || options.length === 0) { - return - } - - const preferredId = - activeWorkspaceRecordId || - toWorkspaceRecordId({ - repositoryFullName: getCurrentSelectedRepository(), - headBranch: - typeof githubPrHeadBranch?.value === 'string' - ? githubPrHeadBranch.value.trim() - : '', - }) - - const preferred = options.find(workspace => workspace.id === preferredId) - const next = preferred ?? options[0] - - if (!next) { - return - } - - await applyWorkspaceRecord(next, { silent: true }) -} - -const bindWorkspaceMetadataPersistence = element => { - if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) { - return - } - - const queue = () => { - queueWorkspaceSave() - } - - const flush = () => { - void flushWorkspaceSave().catch(() => { - /* Save failures are already surfaced through saver onError. */ - }) - } - - element.addEventListener('input', queue) - element.addEventListener('change', queue) - element.addEventListener('blur', flush) -} - -const getCurrentWritableRepositories = () => - githubAiContextState.writableRepositories.length > 0 - ? [...githubAiContextState.writableRepositories] - : byotControls.getWritableRepositories() - -const setCurrentSelectedRepository = fullName => - byotControls.setSelectedRepository(fullName) - -const getTopLevelDeclarations = async source => { - if (typeof source !== 'string' || !source.trim()) { - return [] - } - - const transformJsxSource = await ensureJsxTransformSource({ - cdnImports, - importFromCdnWithFallback, - }) - return collectTopLevelDeclarations({ source, transformJsxSource }) -} - -const prEditorSyncController = createGitHubPrEditorSyncController({ - setComponentSource: value => { - suppressEditorChangeSideEffects = true - try { - setJsxSource(value) - } finally { - suppressEditorChangeSideEffects = false - } - }, - setStylesSource: value => { - suppressEditorChangeSideEffects = true - try { - setCssSource(value) - } finally { - suppressEditorChangeSideEffects = false - } - }, - scheduleRender: () => { - if ( - autoRenderToggle?.checked && - typeof renderRuntime?.scheduleRender === 'function' - ) { - renderRuntime.scheduleRender() - } - }, -}) - -chatDrawerController = createGitHubChatDrawer({ - toggleButton: aiChatToggle, - drawer: aiChatDrawer, - closeButton: aiChatClose, - promptInput: aiChatPrompt, - modelSelect: aiChatModel, - includeEditorsContextToggle: aiChatIncludeEditors, - sendButton: aiChatSend, - clearButton: aiChatClear, - statusNode: aiChatStatus, - repositoryNode: aiChatRepository, - messagesNode: aiChatMessages, - getToken: getCurrentGitHubToken, - getSelectedRepository: getCurrentSelectedRepository, - getComponentSource: () => getJsxSource(), - setComponentSource: value => setJsxSource(value), - getStylesSource: () => getCssSource(), - setStylesSource: value => setCssSource(value), - scheduleRender: () => { - if ( - autoRenderToggle?.checked && - typeof renderRuntime?.scheduleRender === 'function' - ) { - renderRuntime.scheduleRender() - } - }, - getRenderMode: () => renderMode.value, - getStyleMode: () => styleMode.value, - getDrawerSide: () => { - return 'right' - }, -}) - -prDrawerController = createGitHubPrDrawer({ - toggleButton: githubPrToggle, - drawer: githubPrDrawer, - closeButton: githubPrClose, - repositorySelect: githubPrRepoSelect, - baseBranchInput: githubPrBaseBranch, - headBranchInput: githubPrHeadBranch, - prTitleInput: githubPrTitle, - prBodyInput: githubPrBody, - commitMessageInput: githubPrCommitMessage, - includeAppWrapperToggle: githubPrIncludeAppWrapper, - submitButton: githubPrSubmit, - titleNode: openPrTitle, - statusNode: githubPrStatus, - getToken: getCurrentGitHubToken, - getSelectedRepository: getCurrentSelectedRepository, - getWritableRepositories: getCurrentWritableRepositories, - setSelectedRepository: setCurrentSelectedRepository, - getFileCommits: getWorkspacePrFileCommits, - getEditorSyncTargets, - getTopLevelDeclarations, - getRenderMode: () => renderMode.value, - getStyleMode: () => styleMode.value, - getDrawerSide: () => { - return 'right' - }, - confirmBeforeSubmit: options => { - confirmAction(options) - }, - onPullRequestOpened: ({ url, fileUpdates }) => { - const activeContextSyncKey = getActivePrContextSyncKey( - githubAiContextState.activePrContext, - ) - if ( - activeContextSyncKey && - activeContextSyncKey === githubAiContextState.activePrEditorSyncKey - ) { - githubAiContextState.hasSyncedActivePrEditorContent = true - syncEditorPrContextIndicators(true) - } - - const message = url - ? `Pull request opened: ${url}` - : 'Pull request opened successfully.' - reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - showAppToast(message) - }, - onPullRequestCommitPushed: ({ branch, fileUpdates }) => { - reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0 - const message = - fileCount > 0 - ? `Pushed commit to ${branch} (${fileCount} file${fileCount === 1 ? '' : 's'}).` - : `Pushed commit to ${branch}.` - showAppToast(message) - }, - onActivePrContextChange: activeContext => { - syncActivePrContextUi(activeContext) - syncAiChatTokenVisibility(githubAiContextState.token) - if (workspacesToggle instanceof HTMLButtonElement) { - workspacesToggle.hidden = Boolean(activeContext) - } - - if (activeContext) { - void workspacesDrawerController?.setOpen(false) - } - }, - onSyncActivePrEditorContent: async args => { - const result = await prEditorSyncController.syncFromActiveContext(args) - const syncedContextKey = getActivePrContextSyncKey(args?.activeContext) - - if ( - !syncedContextKey || - syncedContextKey !== githubAiContextState.activePrEditorSyncKey - ) { - return result - } - - if (result?.synced === true) { - githubAiContextState.hasSyncedActivePrEditorContent = true - syncEditorPrContextIndicators(true) - - reconcileWorkspaceTabsWithEditorSync({ - componentPath: args?.activeContext?.componentFilePath, - stylesPath: args?.activeContext?.stylesFilePath, - }) - } - - return result - }, - onRestoreRenderMode: mode => { - applyRenderMode({ mode, fromActivePrContext: true }) - }, - onRestoreStyleMode: mode => { - applyStyleMode({ mode }) - }, -}) - -workspacesDrawerController = createWorkspacesDrawer({ - toggleButton: workspacesToggle, - drawer: workspacesDrawer, - closeButton: workspacesClose, - statusNode: workspacesStatus, - searchInput: workspacesSearch, - selectInput: workspacesSelect, - openButton: workspacesOpen, - removeButton: workspacesRemove, - getDrawerSide: () => { - return 'right' - }, - onRefreshRequested: listLocalContextRecords, - onOpenSelected: async workspaceId => { - try { - const record = await workspaceStorage.getWorkspaceById(workspaceId) - if (!record) { - await refreshLocalContextOptions() - workspacesDrawerController?.setStatus( - 'Stored local context no longer exists.', - 'error', - ) - return false - } - - return applyWorkspaceRecord(record, { silent: false }) - } catch { - workspacesDrawerController?.setStatus( - 'Could not load selected local context.', - 'error', - ) - return false - } - }, - onRemoveSelected: async workspaceId => { - confirmAction({ - title: 'Remove stored local context?', - copy: 'This removes only local workspace metadata and editor content from this browser.', - confirmButtonText: 'Remove', - onConfirm: () => { - void workspaceStorage - .removeWorkspace(workspaceId) - .then(async () => { - if (activeWorkspaceRecordId === workspaceId) { - activeWorkspaceRecordId = '' - activeWorkspaceCreatedAt = null - } - - await refreshLocalContextOptions() - workspacesDrawerController?.setStatus( - 'Removed stored local context.', - 'neutral', - ) - }) - .catch(() => { - workspacesDrawerController?.setStatus( - 'Could not remove stored local context.', - 'error', - ) - }) - }, - }) - - return false - }, -}) - -prDrawerController.setToken(githubAiContextState.token) -prDrawerController.setSelectedRepository(githubAiContextState.selectedRepository) -prDrawerController.syncRepositories() -syncActivePrContextUi(prDrawerController.getActivePrContext()) - -githubPrContextClose?.addEventListener('click', () => { - if (!githubAiContextState.activePrContext) { - return - } - - const activePrReference = formatActivePrReference(githubAiContextState.activePrContext) - const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' - - confirmAction({ - title: 'Close pull request on GitHub?', - copy: `${referenceLine}PR title: ${githubAiContextState.activePrContext.prTitle}\nHead branch: ${githubAiContextState.activePrContext.headBranch}\n\nThis will close the pull request on GitHub and clear the active pull request context for the selected repository.`, - confirmButtonText: 'Close PR on GitHub', - onConfirm: () => { - void prDrawerController - .closeActivePullRequestOnGitHub() - .then(result => { - const reference = result?.reference - setStatus( - reference - ? `Closed pull request on GitHub and cleared active context (${reference}).` - : 'Closed pull request on GitHub and cleared active context.', - 'neutral', - ) - showAppToast( - reference - ? `Closed pull request on GitHub and cleared active context (${reference}).` - : 'Closed pull request on GitHub and cleared active context.', - ) - }) - .catch(error => { - const message = - error instanceof Error - ? error.message - : 'Could not close pull request context on GitHub.' - setStatus(`Close context failed: ${message}`, 'error') - showAppToast(`Close context failed: ${message}`) - }) - }, - }) -}) - -githubPrContextDisconnect?.addEventListener('click', () => { - if (!githubAiContextState.activePrContext) { - return - } - - const activePrReference = formatActivePrReference(githubAiContextState.activePrContext) - const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' - - confirmAction({ - title: 'Disconnect PR context?', - copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`, - confirmButtonText: 'Disconnect', - onConfirm: () => { - const result = prDrawerController.disconnectActivePrContext() - const reference = result?.reference - setStatus( - reference - ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.` - : 'Disconnected PR context. Pull request remains open on GitHub.', - 'neutral', - ) - }, - }) -}) - -const getStyleEditorLanguage = mode => { - if (mode === 'less') return 'less' - if (mode === 'sass') return 'sass' - return 'css' -} - -const normalizeRenderMode = mode => (mode === 'react' ? 'react' : 'dom') - -const persistRenderMode = mode => { - const normalizedMode = normalizeRenderMode(mode) - - try { - localStorage.setItem(renderModeStorageKey, normalizedMode) - } catch { - /* Ignore storage write errors in restricted browsing modes. */ - } -} - -const getInitialRenderMode = () => { - try { - const value = localStorage.getItem(renderModeStorageKey) - return normalizeRenderMode(value) - } catch { - /* Ignore storage read errors in restricted browsing modes. */ - } - - return 'dom' -} - -const updateRenderModeEditability = () => { - if (!(renderMode instanceof HTMLSelectElement)) { - return - } - - const activeTab = getActiveWorkspaceTab() - const isEntryTab = activeTab?.role === 'entry' - renderMode.disabled = !isEntryTab -} - -const normalizeStyleMode = mode => { - if (mode === 'module') return 'module' - if (mode === 'less') return 'less' - if (mode === 'sass') return 'sass' - return 'css' -} - -const createEditorHost = textarea => { - const host = document.createElement('div') - host.className = 'editor-host' - textarea.before(host) - return host -} - -const initializeCodeEditors = async () => { - const jsxHost = createEditorHost(jsxEditor) - const cssHost = createEditorHost(cssEditor) - - try { - const [nextJsxEditor, nextCssEditor] = await Promise.all([ - createCodeMirrorEditor({ - parent: jsxHost, - value: getJsxSource(), - language: 'javascript-jsx', - contentAttributes: { - 'aria-label': 'Component source editor', - 'aria-multiline': 'true', - }, - onChange: () => { - if (suppressEditorChangeSideEffects) { - return - } - const activeTab = getActiveWorkspaceTab() - if (activeTab && getTabKind(activeTab) === 'component') { - const nextContent = getJsxSource() - const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) - workspaceTabsState.upsertTab( - { - ...activeTab, - content: nextContent, - syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), - isDirty: nextDirtyState, - lastModified: Date.now(), - isActive: true, - }, - { emitReason: 'componentEditorChange' }, - ) - - if (nextDirtyState !== Boolean(activeTab.isDirty)) { - renderWorkspaceTabs() - } - } - queueWorkspaceSave() - maybeRenderFromComponentEditorChange() - markTypeDiagnosticsStale() - markComponentLintDiagnosticsStale() - }, - }), - createCodeMirrorEditor({ - parent: cssHost, - value: getCssSource(), - language: getStyleEditorLanguage(styleMode.value), - contentAttributes: { - 'aria-label': 'Styles source editor', - 'aria-multiline': 'true', - }, - onChange: () => { - if (suppressEditorChangeSideEffects) { - return - } - const activeTab = getActiveWorkspaceTab() - if (activeTab && getTabKind(activeTab) === 'styles') { - const nextContent = getCssSource() - const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) - workspaceTabsState.upsertTab( - { - ...activeTab, - content: nextContent, - syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), - isDirty: nextDirtyState, - lastModified: Date.now(), - isActive: true, - }, - { emitReason: 'stylesEditorChange' }, - ) - - if (nextDirtyState !== Boolean(activeTab.isDirty)) { - renderWorkspaceTabs() - } - } - 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) { - jsxHost.remove() - cssHost.remove() - const message = error instanceof Error ? error.message : String(error) - setStatus(`Editor fallback: ${message}`, 'neutral') - } -} - -const setTypecheckButtonLoading = isLoading => { - if (!typecheckButton) { - return - } - - typecheckButton.classList.toggle('render-button--loading', isLoading) - typecheckButton.setAttribute('aria-busy', isLoading ? 'true' : 'false') - typecheckButton.disabled = isLoading -} - -const setLintButtonLoading = ({ button, isLoading }) => { - if (!(button instanceof HTMLButtonElement)) { - return - } - - button.classList.toggle('render-button--loading', isLoading) - button.setAttribute('aria-busy', isLoading ? 'true' : 'false') - button.disabled = isLoading -} - -const setCdnLoading = isLoading => { - if (!cdnLoading) return - cdnLoading.hidden = !isLoading -} - -const setRenderedStatus = () => { - if (typeDiagnostics.getLastTypeErrorCount() > 0) { - setStatus( - `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`, - 'error', - ) - return - } - - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } -} - -const typeDiagnostics = createTypeDiagnosticsController({ - cdnImports, - importFromCdnWithFallback, - getTypeScriptLibUrls, - getTypePackageFileUrls, - getJsxSource: () => getJsxSource(), - getTypecheckSourcePath, - getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(), - getRenderMode: () => renderMode.value, - setTypecheckButtonLoading, - setTypeDiagnosticsDetails, - setTypeDiagnosticsPending, - setStatus, - setRenderedStatus, - isRenderedStatus: () => - statusNode.textContent === 'Rendered' || - statusNode.textContent.startsWith('Rendered (Type errors:'), - isRenderedTypeErrorStatus: () => - statusNode.textContent.startsWith('Rendered (Type errors:'), - incrementTypeDiagnosticsRuns, - decrementTypeDiagnosticsRuns, - getActiveTypeDiagnosticsRuns, - onIssuesDetected: ({ issueCount }) => { - if (issueCount > 0) { - setDiagnosticsDrawerOpen(true) - } - }, -}) - -const lintDiagnostics = createLintDiagnosticsController({ - cdnImports, - importFromCdnWithFallback, - getComponentSource: () => getJsxSource(), - getStylesSource: () => getCssSource(), - getStyleMode: () => styleMode.value, - setComponentDiagnostics: setTypeDiagnosticsDetails, - setStyleDiagnostics: setStyleDiagnosticsDetails, - setStatus, - onIssuesDetected: ({ issueCount }) => { - if (issueCount > 0) { - setDiagnosticsDrawerOpen(true) - } - }, -}) - -let activeComponentLintAbortController = null -let activeStylesLintAbortController = null -let lastComponentLintIssueCount = 0 -let lastStylesLintIssueCount = 0 -let scheduledComponentLintRecheck = null -let scheduledStylesLintRecheck = null -let componentLintPending = false -let stylesLintPending = false - -const clearComponentLintRecheckTimer = () => { - if (scheduledComponentLintRecheck) { - clearTimeout(scheduledComponentLintRecheck) - scheduledComponentLintRecheck = null - } -} - -const clearStylesLintRecheckTimer = () => { - if (scheduledStylesLintRecheck) { - clearTimeout(scheduledStylesLintRecheck) - scheduledStylesLintRecheck = null - } -} - -const syncLintPendingState = () => { - setLintDiagnosticsPending(componentLintPending || stylesLintPending) -} - -const runComponentLint = ({ userInitiated = false, source = undefined } = {}) => { - activeComponentLintAbortController?.abort() - const controller = new AbortController() - activeComponentLintAbortController = controller - componentLintPending = false - syncLintPendingState() - incrementLintDiagnosticsRuns() - - setLintButtonLoading({ button: lintComponentButton, isLoading: true }) - - return lintDiagnostics - .lintComponent({ - signal: controller.signal, - userInitiated, - source, - }) - .then(result => { - if (result) { - lastComponentLintIssueCount = result.issueCount - } - return result - }) - .finally(() => { - decrementLintDiagnosticsRuns() - if (activeComponentLintAbortController === controller) { - activeComponentLintAbortController = null - setLintButtonLoading({ button: lintComponentButton, isLoading: false }) - } - }) -} - -const runStylesLint = ({ userInitiated = false, source = undefined } = {}) => { - activeStylesLintAbortController?.abort() - const controller = new AbortController() - activeStylesLintAbortController = controller - stylesLintPending = false - syncLintPendingState() - incrementLintDiagnosticsRuns() - - setLintButtonLoading({ button: lintStylesButton, isLoading: true }) - - return lintDiagnostics - .lintStyles({ - signal: controller.signal, - userInitiated, - source, - }) - .then(result => { - if (result) { - lastStylesLintIssueCount = result.issueCount - } - return result - }) - .finally(() => { - decrementLintDiagnosticsRuns() - if (activeStylesLintAbortController === controller) { - activeStylesLintAbortController = null - setLintButtonLoading({ button: lintStylesButton, isLoading: false }) - } - }) -} - -const markTypeDiagnosticsStale = () => { - typeDiagnostics.markTypeDiagnosticsStale() -} - -const markComponentLintDiagnosticsStale = () => { - clearComponentLintRecheckTimer() - - if (lastComponentLintIssueCount > 0) { - componentLintPending = true - syncLintPendingState() - setTypeDiagnosticsDetails({ - headline: 'Source changed. Re-checking lint issues…', - level: 'muted', - }) - - scheduledComponentLintRecheck = setTimeout(() => { - scheduledComponentLintRecheck = null - void runComponentLint() - }, 450) - return - } - - componentLintPending = false - syncLintPendingState() - setTypeDiagnosticsDetails({ - headline: 'Source changed. Click Lint to run diagnostics.', - level: 'muted', - }) - - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } -} - -const markStylesLintDiagnosticsStale = () => { - clearStylesLintRecheckTimer() - - if (lastStylesLintIssueCount > 0) { - stylesLintPending = true - syncLintPendingState() - setStyleDiagnosticsDetails({ - headline: 'Source changed. Re-checking lint issues…', - level: 'muted', - }) - - scheduledStylesLintRecheck = setTimeout(() => { - scheduledStylesLintRecheck = null - void runStylesLint() - }, 450) - return - } - - stylesLintPending = false - syncLintPendingState() - setStyleDiagnosticsDetails({ - headline: 'Source changed. Click Lint to run diagnostics.', - level: 'muted', - }) - - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } -} - -const clearComponentLintDiagnosticsState = () => { - lastComponentLintIssueCount = 0 - componentLintPending = false - clearComponentLintRecheckTimer() - syncLintPendingState() -} - -const clearStylesLintDiagnosticsState = () => { - lastStylesLintIssueCount = 0 - stylesLintPending = false - clearStylesLintRecheckTimer() - syncLintPendingState() -} - -const resetDiagnosticsFlow = () => { - activeComponentLintAbortController?.abort() - activeStylesLintAbortController?.abort() - activeComponentLintAbortController = null - activeStylesLintAbortController = null - - lintDiagnostics.cancelAll() - typeDiagnostics.cancelTypeDiagnostics() - clearComponentLintDiagnosticsState() - clearStylesLintDiagnosticsState() - clearAllDiagnostics() + }, + setStylesSource: value => { + suppressEditorChangeSideEffects = true + try { + setCssSource(value) + } finally { + suppressEditorChangeSideEffects = false + } + }, + getComponentSource: () => getJsxSource(), + getStylesSource: () => getCssSource(), + scheduleRender: () => { + if ( + autoRenderToggle?.checked && + typeof renderRuntime?.scheduleRender === 'function' + ) { + renderRuntime.scheduleRender() + } + }, + }, +}) - setLintButtonLoading({ button: lintComponentButton, isLoading: false }) - setLintButtonLoading({ button: lintStylesButton, isLoading: false }) - setStatus('Rendered', 'neutral') -} +chatDrawerController = githubWorkflows.chatDrawerController +prDrawerController = githubWorkflows.prDrawerController +workspacesDrawerController = githubWorkflows.workspacesDrawerController -const renderPreview = async () => { - await renderRuntime.renderPreview() -} +const persistRenderMode = mode => persistRenderModeValue(mode, { renderModeStorageKey }) -const maybeRender = () => { - if (autoRenderToggle.checked) { - renderRuntime.scheduleRender() - } -} +const getInitialRenderMode = () => getInitialRenderModeValue({ renderModeStorageKey }) -const maybeRenderFromComponentEditorChange = () => { - if (!autoRenderToggle.checked) { - return - } +const updateRenderModeEditability = () => + updateRenderModeEditabilityValue({ renderMode, getActiveWorkspaceTab }) - const activeTab = getActiveWorkspaceTab() - if (activeTab && getTabKind(activeTab) === 'component') { - const shouldRender = renderRuntime.shouldAutoRenderForTabChange(activeTab.id) - if (!shouldRender) { - return - } - } +const editorBootstrapOptions = createEditorBootstrapOptions({ + createCodeMirrorEditor, + jsxEditor, + cssEditor, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + getStyleEditorLanguage, + styleMode, + getSuppressEditorChangeSideEffects: () => suppressEditorChangeSideEffects, + getActiveWorkspaceTab, + getTabKind, + getDirtyStateForTabChange, + workspaceTabsState, + toWorkspaceSyncedContent, + renderWorkspaceTabs, + queueWorkspaceSave, + maybeRenderFromComponentEditorChange: () => maybeRenderFromComponentEditorChange(), + markTypeDiagnosticsStale: () => markTypeDiagnosticsStale(), + markComponentLintDiagnosticsStale: () => markComponentLintDiagnosticsStale(), + maybeRender: () => maybeRender(), + markStylesLintDiagnosticsStale: () => markStylesLintDiagnosticsStale(), + flushWorkspaceSave, + setJsxCodeEditor: value => (jsxCodeEditor = value), + setCssCodeEditor: value => (cssCodeEditor = value), + setGetJsxSource: value => (getJsxSource = value), + setGetCssSource: value => (getCssSource = value), + editorPool, + componentEditorPanel, + stylesEditorPanel, + loadWorkspaceTabIntoEditor, + setStatus, +}) +const editorBootstrapController = createEditorBootstrapController(editorBootstrapOptions) - renderRuntime.scheduleRender() -} +const initializeCodeEditors = async () => + editorBootstrapController.initializeCodeEditors() -renderRuntime = createRenderRuntimeController({ +const runtimeCoreOptions = createRuntimeCoreOptions({ + createDiagnosticsFlowController, + createRenderRuntimeController, + createTypeDiagnosticsController, + createLintDiagnosticsController, cdnImports, importFromCdnWithFallback, - renderMode, - isAutoRenderEnabled: () => autoRenderToggle.checked, + getTypeScriptLibUrls, + getTypePackageFileUrls, getJsxSource: () => getJsxSource(), - getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(), - getPreviewHost: () => previewHost, - getPreviewBackgroundColor: () => previewBackground.getPreviewBackgroundColor(), - clearStyleDiagnostics: () => clearDiagnosticsScope('styles'), + getCssSource: () => getCssSource(), + getTypecheckSourcePath, + buildWorkspaceTabsSnapshot, + renderMode, + styleMode, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, setStyleDiagnosticsDetails, + setLintDiagnosticsPending, setStatus, - setRenderedStatus, - onFirstRenderComplete: () => {}, - setCdnLoading, + statusNode, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + incrementLintDiagnosticsRuns, + decrementLintDiagnosticsRuns, + setDiagnosticsDrawerOpen, + clearAllDiagnostics, + lintComponentButton, + lintStylesButton, + autoRenderToggle, + getActiveWorkspaceTab, + getTabKind, + getRenderRuntime: () => renderRuntime, + getPreviewHost: () => previewHost, + previewBackground, + clearDiagnosticsScope, + clearConfirmDialog, + clearConfirmTitle, + clearConfirmCopy, + clearConfirmButton, + setPendingClearAction: value => (pendingClearAction = value), + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode, + resetDiagnosticsFlow: () => diagnosticsFlowController.resetDiagnosticsFlow(), + maybeRender: () => diagnosticsFlowController.maybeRender(), + flushWorkspaceSave, + getCssCodeEditor: () => cssCodeEditor, + setSuppressEditorChangeSideEffects: value => (suppressEditorChangeSideEffects = value), + getStyleEditorLanguage, + workspaceTabsState, + queueWorkspaceSave, }) +const runtimeCore = createRuntimeCoreSetup(runtimeCoreOptions) + +const diagnosticsFlowController = runtimeCore.diagnosticsFlowController +renderRuntime = runtimeCore.renderRuntime +const setCdnLoading = runtimeCore.setCdnLoading +const typeDiagnostics = diagnosticsFlowController.typeDiagnostics +const runComponentLint = options => diagnosticsFlowController.runComponentLint(options) +const runStylesLint = options => diagnosticsFlowController.runStylesLint(options) +const markTypeDiagnosticsStale = () => + diagnosticsFlowController.markTypeDiagnosticsStale() +const markComponentLintDiagnosticsStale = () => + diagnosticsFlowController.markComponentLintDiagnosticsStale() +const markStylesLintDiagnosticsStale = () => + diagnosticsFlowController.markStylesLintDiagnosticsStale() +const clearComponentLintDiagnosticsState = () => + diagnosticsFlowController.clearComponentLintDiagnosticsState() +const clearStylesLintDiagnosticsState = () => + diagnosticsFlowController.clearStylesLintDiagnosticsState() +const renderPreview = async () => diagnosticsFlowController.renderPreview() +const maybeRender = () => diagnosticsFlowController.maybeRender() +const maybeRenderFromComponentEditorChange = () => + diagnosticsFlowController.maybeRenderFromComponentEditorChange() function setJsxSource(value) { - if (jsxCodeEditor) { - suppressEditorChangeSideEffects = true - try { - jsxCodeEditor.setValue(value) - } finally { - suppressEditorChangeSideEffects = false - } - } - jsxEditor.value = value + setJsxSourceValue({ + value, + jsxCodeEditor, + setSuppressEditorChangeSideEffects: nextValue => + (suppressEditorChangeSideEffects = nextValue), + jsxEditor, + }) } function setCssSource(value) { - if (cssCodeEditor) { - suppressEditorChangeSideEffects = true - try { - cssCodeEditor.setValue(value) - } finally { - suppressEditorChangeSideEffects = false - } - } - cssEditor.value = value -} - -const clearComponentSource = () => { - setJsxSource('') - clearDiagnosticsScope('component') - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - setStatus('Component cleared', 'neutral') - renderRuntime.clearPreview() - queueWorkspaceSave() -} - -const clearStylesSource = () => { - setCssSource('') - clearDiagnosticsScope('styles') - clearStylesLintDiagnosticsState() - setStatus('Styles cleared', 'neutral') - maybeRender() - queueWorkspaceSave() -} - -const confirmAction = ({ title, copy, confirmButtonText = 'Clear', onConfirm }) => { - const toConfirmText = value => (typeof value === 'string' ? value.trim() : '') - if ( - !(clearConfirmDialog instanceof HTMLDialogElement) || - typeof clearConfirmDialog.showModal !== 'function' - ) { - return - } - - if (clearConfirmDialog.open) { - return - } - - if (clearConfirmTitle) { - clearConfirmTitle.textContent = title - } - - if (clearConfirmCopy instanceof HTMLUListElement) { - const lines = toConfirmText(copy) - .split('\n') - .map(line => line.replace(/^\s*[-*]\s*/, '').trim()) - .filter(Boolean) - - clearConfirmCopy.replaceChildren() - const items = lines.length > 0 ? lines : [toConfirmText(copy)] - - for (const line of items) { - if (!line) { - continue - } - - const listItem = document.createElement('li') - listItem.textContent = line - clearConfirmCopy.append(listItem) - } - } else if (clearConfirmCopy) { - clearConfirmCopy.textContent = copy - } - - if (clearConfirmButton instanceof HTMLButtonElement) { - clearConfirmButton.textContent = confirmButtonText - clearConfirmButton.removeAttribute('aria-label') - } - - pendingClearAction = onConfirm - clearConfirmDialog.showModal() -} - -const confirmClearSource = ({ label, onConfirm }) => { - confirmAction({ - title: `Clear ${label} source?`, - copy: 'This action will remove all text from the editor. This cannot be undone.', - onConfirm, + setCssSourceValue({ + value, + cssCodeEditor, + setSuppressEditorChangeSideEffects: nextValue => + (suppressEditorChangeSideEffects = nextValue), + cssEditor, }) } -const copyTextToClipboard = async text => { - if (!clipboardSupported) { - throw new Error('Clipboard API is not available in this browser context.') - } - - await navigator.clipboard.writeText(text) -} - -const copyComponentSource = async () => { - try { - await copyTextToClipboard(getJsxSource()) - setStatus('Component copied', 'neutral') - } catch { - setStatus('Copy failed', 'error') - } -} - -const copyStylesSource = async () => { - try { - await copyTextToClipboard(getCssSource()) - setStatus('Styles copied', 'neutral') - } catch { - setStatus('Copy failed', 'error') - } -} - -const initializePreviewBackgroundPicker = () => { - previewBackground.initializePreviewBackgroundPicker() -} - -const updateRenderButtonVisibility = () => { - renderButton.hidden = autoRenderToggle.checked -} +const confirmAction = options => runtimeCore.confirmAction(options) function applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext = false }) { - const nextMode = normalizeRenderMode(mode) - - if (renderMode.value !== nextMode) { - renderMode.value = nextMode - } - - persistRenderMode(nextMode) - - resetDiagnosticsFlow() - - maybeRender() - void flushWorkspaceSave().catch(() => { - /* Save failures are already surfaced through saver onError. */ - }) + runtimeCore.applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext }) } function applyStyleMode({ mode }) { - const nextMode = normalizeStyleMode(mode) - - if (styleMode.value !== nextMode) { - styleMode.value = nextMode - } - - resetDiagnosticsFlow() - - if (cssCodeEditor) { - suppressEditorChangeSideEffects = true - try { - cssCodeEditor.setLanguage(getStyleEditorLanguage(nextMode)) - } finally { - suppressEditorChangeSideEffects = false - } - } - - const activeTab = getActiveWorkspaceTab() - if (activeTab && getTabKind(activeTab) === 'styles') { - const nextLanguage = - nextMode === 'less' - ? 'less' - : nextMode === 'sass' - ? 'sass' - : nextMode === 'module' - ? 'module' - : 'css' - - if (activeTab.language !== nextLanguage) { - workspaceTabsState.upsertTab( - { - ...activeTab, - language: nextLanguage, - lastModified: Date.now(), - isActive: true, - }, - { emitReason: 'styleModeChange' }, - ) - queueWorkspaceSave() - } - } - - maybeRender() - void flushWorkspaceSave().catch(() => { - /* Save failures are already surfaced through saver onError. */ - }) -} - -renderMode.addEventListener('change', () => { - applyRenderMode({ mode: renderMode.value }) -}) -styleMode.addEventListener('change', () => { - applyStyleMode({ mode: styleMode.value }) -}) -autoRenderToggle.addEventListener('change', () => { - renderRuntime.clearPreview() - updateRenderButtonVisibility() - if (autoRenderToggle.checked) { - renderPreview() - } -}) -if (diagnosticsToggle) { - diagnosticsToggle.addEventListener('click', () => { - setDiagnosticsDrawerOpen(!getDiagnosticsDrawerOpen()) - }) -} -if (diagnosticsClose) { - diagnosticsClose.addEventListener('click', () => { - setDiagnosticsDrawerOpen(false) - }) -} -if (diagnosticsClearComponent) { - diagnosticsClearComponent.addEventListener('click', () => { - clearDiagnosticsScope('component') - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } - }) -} -if (diagnosticsClearStyles) { - diagnosticsClearStyles.addEventListener('click', () => { - clearDiagnosticsScope('styles') - clearStylesLintDiagnosticsState() - }) -} -if (diagnosticsClearAll) { - diagnosticsClearAll.addEventListener('click', () => { - clearAllDiagnostics() - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - clearStylesLintDiagnosticsState() - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } - }) -} -if (typecheckButton) { - typecheckButton.addEventListener('click', () => { - typeDiagnostics.triggerTypeDiagnostics({ - userInitiated: true, - source: getJsxSource(), - sourcePath: getTypecheckSourcePath(), - }) - }) -} -if (lintComponentButton) { - lintComponentButton.addEventListener('click', () => { - void runComponentLint({ - userInitiated: true, - source: getJsxSource(), - }) - }) -} -if (lintStylesButton) { - lintStylesButton.addEventListener('click', () => { - void runStylesLint({ - userInitiated: true, - source: getCssSource(), - }) - }) -} -renderButton.addEventListener('click', renderPreview) -if (clipboardSupported) { - copyComponentButton.addEventListener('click', () => { - void copyComponentSource() - }) - copyStylesButton.addEventListener('click', () => { - void copyStylesSource() - }) -} else { - copyComponentButton.hidden = true - copyStylesButton.hidden = true -} -if (clearConfirmDialog instanceof HTMLDialogElement) { - clearConfirmDialog.addEventListener('close', () => { - if (clearConfirmDialog.returnValue === 'confirm') { - pendingClearAction?.() - } - pendingClearAction = null - }) -} - -clearComponentButton.addEventListener('click', () => { - confirmClearSource({ - label: 'Component', - onConfirm: clearComponentSource, - }) -}) - -clearStylesButton.addEventListener('click', () => { - confirmClearSource({ - label: 'Styles', - onConfirm: clearStylesSource, - }) -}) - -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 element of [githubPrBaseBranch, githubPrHeadBranch, githubPrTitle]) { - bindWorkspaceMetadataPersistence(element) -} - -for (const button of appThemeButtons) { - button.addEventListener('click', () => { - const nextTheme = button.dataset.appTheme - if (!nextTheme) { - return - } - applyTheme(nextTheme) - }) -} - -if (aiControlsToggle instanceof HTMLButtonElement) { - aiControlsToggle.addEventListener('click', () => { - if (!isCompactViewport()) { - return - } - - setCompactAiControlsOpen(!compactAiControlsOpen) - }) -} - -if (githubTokenInfo instanceof HTMLButtonElement && githubTokenInfoPanel) { - githubTokenInfo.addEventListener('click', event => { - event.preventDefault() - setGitHubTokenInfoOpen(!githubTokenInfoOpen) - }) -} - -document.addEventListener('click', event => { - const clickTarget = event.target - if (!(clickTarget instanceof Node)) { - return - } - - if (isCompactViewport() && compactAiControlsOpen) { - if ( - !githubAiControls.contains(clickTarget) && - !aiControlsToggle?.contains(clickTarget) - ) { - setCompactAiControlsOpen(false) - } - } - - if (githubTokenInfoOpen) { - if ( - !githubTokenInfo?.contains(clickTarget) && - !githubTokenInfoPanel?.contains(clickTarget) - ) { - setGitHubTokenInfoOpen(false) - } - } -}) - -document.addEventListener('keydown', event => { - if (event.key !== 'Escape') { - return - } - - setCompactAiControlsOpen(false) - setGitHubTokenInfoOpen(false) -}) - -for (const button of editorToolsButtons) { - button.addEventListener('click', () => { - const panelName = button.dataset.editorToolsToggle - if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { - return - } - - panelToolsState[panelName] = !panelToolsState[panelName] - applyEditorToolsVisibility() - }) -} - -for (const button of panelCollapseButtons) { - button.addEventListener('click', () => { - const panelName = button.dataset.panelCollapse - if (!panelName) { - return - } - - togglePanelCollapse(panelName) - }) -} - -const handleCompactViewportChange = () => { - applyPanelCollapseState() - setCompactAiControlsOpen(false) -} - -if (typeof compactViewportMediaQuery.addEventListener === 'function') { - compactViewportMediaQuery.addEventListener('change', handleCompactViewportChange) -} else { - compactViewportMediaQuery.onchange = handleCompactViewportChange -} - -window.addEventListener('beforeunload', () => { - if (appToastDismissTimer) { - clearTimeout(appToastDismissTimer) - appToastDismissTimer = null - } - clearComponentLintRecheckTimer() - clearStylesLintRecheckTimer() - lintDiagnostics.dispose() - void flushWorkspaceSave().catch(() => { - /* noop */ - }) - workspaceSaver?.dispose() - void workspaceStorage.close() - chatDrawerController.dispose() - prDrawerController.dispose() -}) - -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.focus() + runtimeCore.applyStyleMode({ mode }) +} + +bindAppEventsAndStart({ + editorUi: { + renderMode, + styleMode, + autoRenderToggle, + renderButton, + typecheckButton, + lintComponentButton, + lintStylesButton, + copyComponentButton, + copyStylesButton, + clearConfirmDialog, + clearComponentButton, + clearStylesButton, + jsxEditor, + cssEditor, + }, + diagnosticsUi: { + diagnosticsToggle, + diagnosticsClose, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + statusNode, + }, + sourceActions: { + applyRenderMode, + applyStyleMode, + updateRenderButtonVisibility: () => (renderButton.hidden = autoRenderToggle.checked), + clearDiagnosticsScope, + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + clearAllDiagnostics, + setStatus, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + runComponentLint, + runStylesLint, + renderPreview, + setJsxSource, + setCssSource, + queueWorkspaceSave, + maybeRender, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + confirmAction, + getPendingClearAction: () => pendingClearAction, + setPendingClearAction: value => (pendingClearAction = value), + getDiagnosticsDrawerOpen, + setDiagnosticsDrawerOpen, + setTypeDiagnosticsDetails, + setCdnLoading, + }, + workspaceUi: { + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabAddMenuUi, + workspaceTabAddButton, + workspaceTabAddModule, + workspaceTabAddStyles, + addWorkspaceTab, + syncHeaderLabels, + renderWorkspaceTabs, + updateRenderModeEditability, + loadPreferredWorkspaceContext, + getActiveWorkspaceTab, + setActiveWorkspaceTab, + workspaceTabsState, + loadedStylesTabIdRef: { + get value() { + return loadedStylesTabId + }, + }, + getWorkspaceTabByKind, + workspaceSaveController, + workspaceStorage, + bindWorkspaceMetadataPersistence, + setHasCompletedInitialWorkspaceBootstrap: value => + (hasCompletedInitialWorkspaceBootstrap = value), + }, + themeUi: { + appThemeButtons, + applyTheme, + getInitialTheme, + getInitialRenderMode, + }, + githubUi: { + aiControlsToggle, + compactAiControlsUi, + githubTokenInfo, + githubTokenInfoPanel, + githubTokenInfoUi, + prContextUi, + githubAiContextState, + }, + panelUi: { + editorToolsButtons, + panelToolsState, + applyEditorToolsVisibility, + panelCollapseButtons, + togglePanelCollapse, + applyPanelCollapseState, + }, + lifecycle: { + clearToastTimer: () => { + if (!appToastDismissTimer) { + return } - } - }) -} -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 }) -renderMode.value = getInitialRenderMode() -applyEditorToolsVisibility() -applyPanelCollapseState() -syncHeaderLabels() -renderWorkspaceTabs() -updateRenderModeEditability() -setCompactAiControlsOpen(false) -setGitHubTokenInfoOpen(false) -syncAiChatTokenVisibility(githubAiContextState.token) - -updateRenderButtonVisibility() -renderDiagnosticsScope('component') -renderDiagnosticsScope('styles') -updateDiagnosticsToggleLabel() -updateUiIssueIndicators() -setDiagnosticsDrawerOpen(false) -setTypeDiagnosticsDetails({ headline: '' }) -renderRuntime.setStyleCompiling(false) -setCdnLoading(true) -initializePreviewBackgroundPicker() -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() + clearTimeout(appToastDismissTimer) + appToastDismissTimer = null + }, + diagnosticsFlowController, + chatDrawerController, + prDrawerController, + }, + startup: { + renderRuntime, + typeDiagnostics, + clipboardSupported, + previewBackground, + initializeCodeEditors, + }, }) diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js new file mode 100644 index 0000000..165c4af --- /dev/null +++ b/src/modules/app-core/app-bindings-startup.js @@ -0,0 +1,470 @@ +const bindAppEventsAndStart = ({ + editorUi, + diagnosticsUi, + sourceActions, + workspaceUi, + themeUi, + githubUi, + panelUi, + lifecycle, + startup, +}) => { + const { + renderMode, + styleMode, + autoRenderToggle, + renderButton, + typecheckButton, + lintComponentButton, + lintStylesButton, + copyComponentButton, + copyStylesButton, + clearConfirmDialog, + clearComponentButton, + clearStylesButton, + jsxEditor, + cssEditor, + } = editorUi + const { + diagnosticsToggle, + diagnosticsClose, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + statusNode, + } = diagnosticsUi + const { + applyRenderMode, + applyStyleMode, + updateRenderButtonVisibility, + clearDiagnosticsScope, + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + clearAllDiagnostics, + setStatus, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + runComponentLint, + runStylesLint, + renderPreview, + setJsxSource, + setCssSource, + queueWorkspaceSave, + maybeRender, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + confirmAction, + getPendingClearAction, + setPendingClearAction, + getDiagnosticsDrawerOpen, + setDiagnosticsDrawerOpen, + setTypeDiagnosticsDetails, + setCdnLoading, + } = sourceActions + const { + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabAddMenuUi, + workspaceTabAddButton, + workspaceTabAddModule, + workspaceTabAddStyles, + addWorkspaceTab, + syncHeaderLabels, + renderWorkspaceTabs, + updateRenderModeEditability, + loadPreferredWorkspaceContext, + getActiveWorkspaceTab, + setActiveWorkspaceTab, + workspaceTabsState, + loadedStylesTabIdRef, + getWorkspaceTabByKind, + workspaceSaveController, + workspaceStorage, + bindWorkspaceMetadataPersistence, + setHasCompletedInitialWorkspaceBootstrap, + } = workspaceUi + const { appThemeButtons, applyTheme, getInitialTheme, getInitialRenderMode } = themeUi + const { + aiControlsToggle, + compactAiControlsUi, + githubTokenInfo, + githubTokenInfoPanel, + githubTokenInfoUi, + prContextUi, + githubAiContextState, + } = githubUi + const { + editorToolsButtons, + panelToolsState, + applyEditorToolsVisibility, + panelCollapseButtons, + togglePanelCollapse, + applyPanelCollapseState, + } = panelUi + const { + clearToastTimer, + diagnosticsFlowController, + chatDrawerController, + prDrawerController, + } = lifecycle + const { + renderRuntime, + typeDiagnostics, + clipboardSupported, + previewBackground, + initializeCodeEditors, + } = startup + const clearComponentSource = () => { + setJsxSource('') + clearDiagnosticsScope('component') + typeDiagnostics.clearTypeDiagnosticsState() + clearComponentLintDiagnosticsState() + setStatus('Component cleared', 'neutral') + renderRuntime.clearPreview() + queueWorkspaceSave() + } + + const clearStylesSource = () => { + setCssSource('') + clearDiagnosticsScope('styles') + clearStylesLintDiagnosticsState() + setStatus('Styles cleared', 'neutral') + maybeRender() + queueWorkspaceSave() + } + + const confirmClearSource = ({ label, onConfirm }) => { + confirmAction({ + title: `Clear ${label} source?`, + copy: 'This action will remove all text from the editor. This cannot be undone.', + onConfirm, + }) + } + + const copyTextToClipboard = async text => { + if (!clipboardSupported) { + throw new Error('Clipboard API is not available in this browser context.') + } + + await navigator.clipboard.writeText(text) + } + + const copyComponentSource = async () => { + try { + await copyTextToClipboard(getJsxSource()) + setStatus('Component copied', 'neutral') + } catch { + setStatus('Copy failed', 'error') + } + } + + const copyStylesSource = async () => { + try { + await copyTextToClipboard(getCssSource()) + setStatus('Styles copied', 'neutral') + } catch { + setStatus('Copy failed', 'error') + } + } + + renderMode.addEventListener('change', () => { + applyRenderMode({ mode: renderMode.value }) + }) + styleMode.addEventListener('change', () => { + applyStyleMode({ mode: styleMode.value }) + }) + autoRenderToggle.addEventListener('change', () => { + renderRuntime.clearPreview() + updateRenderButtonVisibility() + if (autoRenderToggle.checked) { + renderPreview() + } + }) + if (diagnosticsToggle) { + diagnosticsToggle.addEventListener('click', () => { + setDiagnosticsDrawerOpen(!getDiagnosticsDrawerOpen()) + }) + } + if (diagnosticsClose) { + diagnosticsClose.addEventListener('click', () => { + setDiagnosticsDrawerOpen(false) + }) + } + if (diagnosticsClearComponent) { + diagnosticsClearComponent.addEventListener('click', () => { + clearDiagnosticsScope('component') + typeDiagnostics.clearTypeDiagnosticsState() + clearComponentLintDiagnosticsState() + if (statusNode.textContent.startsWith('Rendered (Type errors:')) { + setStatus('Rendered', 'neutral') + } + }) + } + if (diagnosticsClearStyles) { + diagnosticsClearStyles.addEventListener('click', () => { + clearDiagnosticsScope('styles') + clearStylesLintDiagnosticsState() + }) + } + if (diagnosticsClearAll) { + diagnosticsClearAll.addEventListener('click', () => { + clearAllDiagnostics() + typeDiagnostics.clearTypeDiagnosticsState() + clearComponentLintDiagnosticsState() + clearStylesLintDiagnosticsState() + if (statusNode.textContent.startsWith('Rendered (Type errors:')) { + setStatus('Rendered', 'neutral') + } + }) + } + if (typecheckButton) { + typecheckButton.addEventListener('click', () => { + typeDiagnostics.triggerTypeDiagnostics({ + userInitiated: true, + source: getJsxSource(), + sourcePath: getTypecheckSourcePath(), + }) + }) + } + if (lintComponentButton) { + lintComponentButton.addEventListener('click', () => { + void runComponentLint({ + userInitiated: true, + source: getJsxSource(), + }) + }) + } + if (lintStylesButton) { + lintStylesButton.addEventListener('click', () => { + void runStylesLint({ + userInitiated: true, + source: getCssSource(), + }) + }) + } + renderButton.addEventListener('click', renderPreview) + if (clipboardSupported) { + copyComponentButton.addEventListener('click', () => { + void copyComponentSource() + }) + copyStylesButton.addEventListener('click', () => { + void copyStylesSource() + }) + } else { + copyComponentButton.hidden = true + copyStylesButton.hidden = true + } + if (clearConfirmDialog instanceof HTMLDialogElement) { + clearConfirmDialog.addEventListener('close', () => { + if (clearConfirmDialog.returnValue === 'confirm') { + getPendingClearAction()?.() + } + setPendingClearAction(null) + }) + } + + clearComponentButton.addEventListener('click', () => { + confirmClearSource({ + label: 'Component', + onConfirm: clearComponentSource, + }) + }) + + clearStylesButton.addEventListener('click', () => { + confirmClearSource({ + label: 'Styles', + onConfirm: clearStylesSource, + }) + }) + + 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 element of [githubPrBaseBranch, githubPrHeadBranch, githubPrTitle]) { + bindWorkspaceMetadataPersistence(element) + } + + for (const button of appThemeButtons) { + button.addEventListener('click', () => { + const nextTheme = button.dataset.appTheme + if (!nextTheme) { + return + } + applyTheme(nextTheme) + }) + } + + if (aiControlsToggle instanceof HTMLButtonElement) { + aiControlsToggle.addEventListener('click', () => { + if (!compactAiControlsUi.isCompactViewport()) { + return + } + + compactAiControlsUi.toggle() + }) + } + + if (githubTokenInfo instanceof HTMLButtonElement && githubTokenInfoPanel) { + githubTokenInfo.addEventListener('click', event => { + event.preventDefault() + githubTokenInfoUi.toggle() + }) + } + + document.addEventListener('click', event => { + const clickTarget = event.target + if (!(clickTarget instanceof Node)) { + return + } + + compactAiControlsUi.handleDocumentClick(clickTarget) + + if (githubTokenInfoUi.shouldCloseForClickTarget(clickTarget)) { + githubTokenInfoUi.close() + } + }) + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') { + return + } + + compactAiControlsUi.setOpen(false) + githubTokenInfoUi.close() + }) + + for (const button of editorToolsButtons) { + button.addEventListener('click', () => { + const panelName = button.dataset.editorToolsToggle + if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { + return + } + + panelToolsState[panelName] = !panelToolsState[panelName] + applyEditorToolsVisibility() + }) + } + + for (const button of panelCollapseButtons) { + button.addEventListener('click', () => { + const panelName = button.dataset.panelCollapse + if (!panelName) { + return + } + + togglePanelCollapse(panelName) + }) + } + + const handleCompactViewportChange = () => { + applyPanelCollapseState() + compactAiControlsUi.setOpen(false) + } + compactAiControlsUi.onViewportChange(handleCompactViewportChange) + + window.addEventListener('beforeunload', () => { + clearToastTimer() + diagnosticsFlowController.dispose() + void flushWorkspaceSave().catch(() => { + /* noop */ + }) + workspaceSaveController.dispose() + void workspaceStorage.close() + chatDrawerController.dispose() + prDrawerController.dispose() + }) + + document.addEventListener('pointerdown', event => { + workspaceTabAddMenuUi.handleDocumentPointerdown(event.target) + }) + + document.addEventListener('keydown', event => { + workspaceTabAddMenuUi.handleEscape(event) + }) + + if (workspaceTabAddButton instanceof HTMLButtonElement) { + workspaceTabAddButton.addEventListener('click', event => { + event.stopPropagation() + workspaceTabAddMenuUi.toggle() + }) + + workspaceTabAddButton.addEventListener('keydown', event => { + workspaceTabAddMenuUi.handleAddButtonKeydown(event) + }) + } + + 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 }) + renderMode.value = getInitialRenderMode() + applyEditorToolsVisibility() + applyPanelCollapseState() + syncHeaderLabels() + renderWorkspaceTabs() + updateRenderModeEditability() + compactAiControlsUi.setOpen(false) + githubTokenInfoUi.close() + prContextUi.syncAiChatTokenVisibility(githubAiContextState.token) + + updateRenderButtonVisibility() + setDiagnosticsDrawerOpen(false) + setTypeDiagnosticsDetails({ headline: '' }) + renderRuntime.setStyleCompiling(false) + setCdnLoading(true) + previewBackground.initializePreviewBackgroundPicker() + 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(loadedStylesTabIdRef.value) ?? + getWorkspaceTabByKind('styles') + if (stylesTab && typeof stylesTab.content === 'string') { + setCssSource(stylesTab.content) + } + + setHasCompletedInitialWorkspaceBootstrap(true) + await renderPreview() + }) +} + +export { bindAppEventsAndStart } diff --git a/src/modules/app-core/app-composition-options.js b/src/modules/app-core/app-composition-options.js new file mode 100644 index 0000000..3c8a9cd --- /dev/null +++ b/src/modules/app-core/app-composition-options.js @@ -0,0 +1,395 @@ +const createRuntimeCoreOptions = ({ + createDiagnosticsFlowController, + createRenderRuntimeController, + createTypeDiagnosticsController, + createLintDiagnosticsController, + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getTypePackageFileUrls, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + buildWorkspaceTabsSnapshot, + renderMode, + styleMode, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, + setStyleDiagnosticsDetails, + setLintDiagnosticsPending, + setStatus, + statusNode, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + incrementLintDiagnosticsRuns, + decrementLintDiagnosticsRuns, + setDiagnosticsDrawerOpen, + clearAllDiagnostics, + lintComponentButton, + lintStylesButton, + autoRenderToggle, + getActiveWorkspaceTab, + getTabKind, + getRenderRuntime, + getPreviewHost, + previewBackground, + clearDiagnosticsScope, + clearConfirmDialog, + clearConfirmTitle, + clearConfirmCopy, + clearConfirmButton, + setPendingClearAction, + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode, + resetDiagnosticsFlow, + maybeRender, + flushWorkspaceSave, + getCssCodeEditor, + setSuppressEditorChangeSideEffects, + getStyleEditorLanguage, + workspaceTabsState, + queueWorkspaceSave, +}) => ({ + createDiagnosticsFlowController, + createRenderRuntimeController, + diagnosticsFlowOptions: { + createTypeDiagnosticsController, + createLintDiagnosticsController, + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getTypePackageFileUrls, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(), + getRenderMode: () => renderMode.value, + getStyleMode: () => styleMode.value, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, + setStyleDiagnosticsDetails, + setLintDiagnosticsPending, + setStatus, + statusNode, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + incrementLintDiagnosticsRuns, + decrementLintDiagnosticsRuns, + setDiagnosticsDrawerOpen, + clearAllDiagnostics, + lintComponentButton, + lintStylesButton, + autoRenderToggle, + getActiveWorkspaceTab, + getTabKind, + getRenderRuntime, + }, + renderRuntimeOptions: { + cdnImports, + importFromCdnWithFallback, + renderMode, + isAutoRenderEnabled: () => autoRenderToggle.checked, + getJsxSource, + getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(), + getPreviewHost, + getPreviewBackgroundColor: () => previewBackground.getPreviewBackgroundColor(), + clearStyleDiagnostics: () => clearDiagnosticsScope('styles'), + setStyleDiagnosticsDetails, + setStatus, + onFirstRenderComplete: () => {}, + }, + clearConfirmDialog, + clearConfirmTitle, + clearConfirmCopy, + clearConfirmButton, + setPendingClearAction, + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode, + resetDiagnosticsFlow, + maybeRender, + flushWorkspaceSave, + renderMode, + styleMode, + getCssCodeEditor, + setSuppressEditorChangeSideEffects, + getStyleEditorLanguage, + getActiveWorkspaceTab, + getTabKind, + workspaceTabsState, + queueWorkspaceSave, +}) + +const createEditorBootstrapOptions = ({ + createCodeMirrorEditor, + jsxEditor, + cssEditor, + getJsxSource, + getCssSource, + getStyleEditorLanguage, + styleMode, + getSuppressEditorChangeSideEffects, + getActiveWorkspaceTab, + getTabKind, + getDirtyStateForTabChange, + workspaceTabsState, + toWorkspaceSyncedContent, + renderWorkspaceTabs, + queueWorkspaceSave, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + maybeRender, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + setJsxCodeEditor, + setCssCodeEditor, + setGetJsxSource, + setGetCssSource, + editorPool, + componentEditorPanel, + stylesEditorPanel, + loadWorkspaceTabIntoEditor, + setStatus, +}) => ({ + createCodeMirrorEditor, + jsxEditor, + cssEditor, + getJsxSource, + getCssSource, + getStyleEditorLanguage, + getStyleModeValue: () => styleMode.value, + getSuppressEditorChangeSideEffects, + getActiveWorkspaceTab, + getTabKind, + getDirtyStateForTabChange, + workspaceTabsState, + toWorkspaceSyncedContent, + renderWorkspaceTabs, + queueWorkspaceSave, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + maybeRender, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + setJsxCodeEditor, + setCssCodeEditor, + setGetJsxSource, + setGetCssSource, + editorPool, + componentEditorPanel, + stylesEditorPanel, + loadWorkspaceTabIntoEditor, + setStatus, +}) + +const createAppStartupBindingsOptions = ({ + renderMode, + styleMode, + autoRenderToggle, + applyRenderMode, + applyStyleMode, + renderRuntime, + renderButton, + diagnosticsToggle, + getDiagnosticsDrawerOpen, + diagnosticsClose, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + clearDiagnosticsScope, + typeDiagnostics, + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + clearAllDiagnostics, + statusNode, + setStatus, + typecheckButton, + getJsxSource, + getTypecheckSourcePath, + lintComponentButton, + runComponentLint, + lintStylesButton, + getCssSource, + runStylesLint, + renderPreview, + clipboardSupported, + copyComponentButton, + copyStylesButton, + clearConfirmDialog, + confirmAction, + getPendingClearAction, + setPendingClearAction, + clearComponentButton, + clearStylesButton, + setJsxSource, + setCssSource, + queueWorkspaceSave, + maybeRender, + maybeRenderFromComponentEditorChange, + jsxEditor, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + flushWorkspaceSave, + cssEditor, + markStylesLintDiagnosticsStale, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + bindWorkspaceMetadataPersistence, + appThemeButtons, + applyTheme, + getInitialTheme, + getInitialRenderMode, + aiControlsToggle, + compactAiControlsUi, + githubTokenInfo, + githubTokenInfoPanel, + githubTokenInfoUi, + editorToolsButtons, + panelToolsState, + applyEditorToolsVisibility, + panelCollapseButtons, + togglePanelCollapse, + applyPanelCollapseState, + clearToastTimer, + diagnosticsFlowController, + workspaceSaveController, + workspaceStorage, + chatDrawerController, + prDrawerController, + workspaceTabAddMenuUi, + workspaceTabAddButton, + workspaceTabAddModule, + workspaceTabAddStyles, + addWorkspaceTab, + syncHeaderLabels, + renderWorkspaceTabs, + updateRenderModeEditability, + prContextUi, + githubAiContextState, + setDiagnosticsDrawerOpen, + setTypeDiagnosticsDetails, + setCdnLoading, + previewBackground, + loadPreferredWorkspaceContext, + initializeCodeEditors, + getActiveWorkspaceTab, + setActiveWorkspaceTab, + workspaceTabsState, + getLoadedStylesTabId, + getWorkspaceTabByKind, + setHasCompletedInitialWorkspaceBootstrap, +}) => ({ + renderMode, + styleMode, + autoRenderToggle, + applyRenderMode, + applyStyleMode, + renderRuntime, + updateRenderButtonVisibility: () => (renderButton.hidden = autoRenderToggle.checked), + diagnosticsToggle, + getDiagnosticsDrawerOpen, + diagnosticsClose, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + clearDiagnosticsScope, + typeDiagnostics, + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + clearAllDiagnostics, + statusNode, + setStatus, + typecheckButton, + getJsxSource, + getTypecheckSourcePath, + lintComponentButton, + runComponentLint, + lintStylesButton, + getCssSource, + runStylesLint, + renderButton, + renderPreview, + clipboardSupported, + copyComponentButton, + copyStylesButton, + clearConfirmDialog, + confirmAction, + getPendingClearAction, + setPendingClearAction, + clearComponentButton, + clearStylesButton, + setJsxSource, + setCssSource, + queueWorkspaceSave, + maybeRender, + maybeRenderFromComponentEditorChange, + jsxEditor, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + flushWorkspaceSave, + cssEditor, + markStylesLintDiagnosticsStale, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + bindWorkspaceMetadataPersistence, + appThemeButtons, + applyTheme, + getInitialTheme, + getInitialRenderMode, + aiControlsToggle, + compactAiControlsUi, + githubTokenInfo, + githubTokenInfoPanel, + githubTokenInfoUi, + editorToolsButtons, + panelToolsState, + applyEditorToolsVisibility, + panelCollapseButtons, + togglePanelCollapse, + applyPanelCollapseState, + clearToastTimer, + diagnosticsFlowController, + workspaceSaveController, + workspaceStorage, + chatDrawerController, + prDrawerController, + workspaceTabAddMenuUi, + workspaceTabAddButton, + workspaceTabAddModule, + workspaceTabAddStyles, + addWorkspaceTab, + syncHeaderLabels, + renderWorkspaceTabs, + updateRenderModeEditability, + prContextUi, + githubAiContextState, + setDiagnosticsDrawerOpen, + setTypeDiagnosticsDetails, + setCdnLoading, + previewBackground, + loadPreferredWorkspaceContext, + initializeCodeEditors, + getActiveWorkspaceTab, + setActiveWorkspaceTab, + workspaceTabsState, + loadedStylesTabIdRef: { + get value() { + return getLoadedStylesTabId() + }, + }, + getWorkspaceTabByKind, + setHasCompletedInitialWorkspaceBootstrap, +}) + +export { + createAppStartupBindingsOptions, + createEditorBootstrapOptions, + createRuntimeCoreOptions, +} diff --git a/src/modules/app-core/compact-ai-controls-ui.js b/src/modules/app-core/compact-ai-controls-ui.js new file mode 100644 index 0000000..abc0341 --- /dev/null +++ b/src/modules/app-core/compact-ai-controls-ui.js @@ -0,0 +1,76 @@ +export const createCompactAiControlsUiController = ({ + toggleButton, + controlsRoot, + closeTokenInfo, + mediaQuery = window.matchMedia('(max-width: 900px)'), +}) => { + let open = false + + const isCompactViewport = () => mediaQuery.matches + + const setOpen = isOpen => { + if ( + !(toggleButton instanceof HTMLButtonElement) || + !(controlsRoot instanceof HTMLElement) + ) { + return + } + + toggleButton.removeAttribute('hidden') + + if (!isCompactViewport()) { + open = false + closeTokenInfo?.() + toggleButton.setAttribute('aria-expanded', 'false') + controlsRoot.removeAttribute('data-compact-open') + controlsRoot.removeAttribute('hidden') + return + } + + open = Boolean(isOpen) + toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') + controlsRoot.dataset.compactOpen = open ? 'true' : 'false' + + if (!open) { + closeTokenInfo?.() + } + } + + const toggle = () => { + setOpen(!open) + } + + const handleDocumentClick = target => { + if (!(target instanceof Node)) { + return + } + + if (!isCompactViewport() || !open) { + return + } + + if (controlsRoot.contains(target) || toggleButton?.contains(target)) { + return + } + + setOpen(false) + } + + const onViewportChange = listener => { + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', listener) + return + } + + mediaQuery.onchange = listener + } + + return { + handleDocumentClick, + isCompactViewport, + isOpen: () => open, + onViewportChange, + setOpen, + toggle, + } +} diff --git a/src/modules/defaults.js b/src/modules/app-core/defaults.js similarity index 100% rename from src/modules/defaults.js rename to src/modules/app-core/defaults.js diff --git a/src/modules/app-core/diagnostics-flow-controller.js b/src/modules/app-core/diagnostics-flow-controller.js new file mode 100644 index 0000000..d92cab5 --- /dev/null +++ b/src/modules/app-core/diagnostics-flow-controller.js @@ -0,0 +1,365 @@ +const createDiagnosticsFlowController = ({ + createTypeDiagnosticsController, + createLintDiagnosticsController, + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getTypePackageFileUrls, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + getWorkspaceTabs, + getRenderMode, + getStyleMode, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, + setStyleDiagnosticsDetails, + setLintDiagnosticsPending, + setStatus, + statusNode, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + incrementLintDiagnosticsRuns, + decrementLintDiagnosticsRuns, + setDiagnosticsDrawerOpen, + clearAllDiagnostics, + lintComponentButton, + lintStylesButton, + autoRenderToggle, + getActiveWorkspaceTab, + getTabKind, + getRenderRuntime, +}) => { + let activeComponentLintAbortController = null + let activeStylesLintAbortController = null + let lastComponentLintIssueCount = 0 + let lastStylesLintIssueCount = 0 + let scheduledComponentLintRecheck = null + let scheduledStylesLintRecheck = null + let componentLintPending = false + let stylesLintPending = false + + const setTypecheckButtonLoading = isLoading => { + const runtimeTypecheckButton = document.getElementById('typecheck-button') + if (!(runtimeTypecheckButton instanceof HTMLButtonElement)) { + return + } + + runtimeTypecheckButton.classList.toggle('render-button--loading', isLoading) + runtimeTypecheckButton.setAttribute('aria-busy', isLoading ? 'true' : 'false') + runtimeTypecheckButton.disabled = isLoading + } + + const setLintButtonLoading = ({ button, isLoading }) => { + if (!(button instanceof HTMLButtonElement)) { + return + } + + button.classList.toggle('render-button--loading', isLoading) + button.setAttribute('aria-busy', isLoading ? 'true' : 'false') + button.disabled = isLoading + } + + const typeDiagnostics = createTypeDiagnosticsController({ + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getTypePackageFileUrls, + getJsxSource, + getTypecheckSourcePath, + getWorkspaceTabs, + getRenderMode, + setTypecheckButtonLoading, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, + setStatus, + setRenderedStatus: () => { + if (typeDiagnostics.getLastTypeErrorCount() > 0) { + setStatus( + `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`, + 'error', + ) + return + } + + if (statusNode.textContent.startsWith('Rendered (Type errors:')) { + setStatus('Rendered', 'neutral') + } + }, + isRenderedStatus: () => + statusNode.textContent === 'Rendered' || + statusNode.textContent.startsWith('Rendered (Type errors:'), + isRenderedTypeErrorStatus: () => + statusNode.textContent.startsWith('Rendered (Type errors:'), + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + onIssuesDetected: ({ issueCount }) => { + if (issueCount > 0) { + setDiagnosticsDrawerOpen(true) + } + }, + }) + + const setRenderedStatus = () => { + if (typeDiagnostics.getLastTypeErrorCount() > 0) { + setStatus( + `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`, + 'error', + ) + return + } + + if (statusNode.textContent.startsWith('Rendered (Type errors:')) { + setStatus('Rendered', 'neutral') + } + } + + const lintDiagnostics = createLintDiagnosticsController({ + cdnImports, + importFromCdnWithFallback, + getComponentSource: getJsxSource, + getStylesSource: getCssSource, + getStyleMode, + setComponentDiagnostics: setTypeDiagnosticsDetails, + setStyleDiagnostics: setStyleDiagnosticsDetails, + setStatus, + onIssuesDetected: ({ issueCount }) => { + if (issueCount > 0) { + setDiagnosticsDrawerOpen(true) + } + }, + }) + + const clearComponentLintRecheckTimer = () => { + if (scheduledComponentLintRecheck) { + clearTimeout(scheduledComponentLintRecheck) + scheduledComponentLintRecheck = null + } + } + + const clearStylesLintRecheckTimer = () => { + if (scheduledStylesLintRecheck) { + clearTimeout(scheduledStylesLintRecheck) + scheduledStylesLintRecheck = null + } + } + + const syncLintPendingState = () => { + setLintDiagnosticsPending(componentLintPending || stylesLintPending) + } + + const runComponentLint = ({ userInitiated = false, source = undefined } = {}) => { + activeComponentLintAbortController?.abort() + const controller = new AbortController() + activeComponentLintAbortController = controller + componentLintPending = false + syncLintPendingState() + incrementLintDiagnosticsRuns() + + setLintButtonLoading({ button: lintComponentButton, isLoading: true }) + + return lintDiagnostics + .lintComponent({ + signal: controller.signal, + userInitiated, + source, + }) + .then(result => { + if (result) { + lastComponentLintIssueCount = result.issueCount + } + return result + }) + .finally(() => { + decrementLintDiagnosticsRuns() + if (activeComponentLintAbortController === controller) { + activeComponentLintAbortController = null + setLintButtonLoading({ button: lintComponentButton, isLoading: false }) + } + }) + } + + const runStylesLint = ({ userInitiated = false, source = undefined } = {}) => { + activeStylesLintAbortController?.abort() + const controller = new AbortController() + activeStylesLintAbortController = controller + stylesLintPending = false + syncLintPendingState() + incrementLintDiagnosticsRuns() + + setLintButtonLoading({ button: lintStylesButton, isLoading: true }) + + return lintDiagnostics + .lintStyles({ + signal: controller.signal, + userInitiated, + source, + }) + .then(result => { + if (result) { + lastStylesLintIssueCount = result.issueCount + } + return result + }) + .finally(() => { + decrementLintDiagnosticsRuns() + if (activeStylesLintAbortController === controller) { + activeStylesLintAbortController = null + setLintButtonLoading({ button: lintStylesButton, isLoading: false }) + } + }) + } + + const markTypeDiagnosticsStale = () => { + typeDiagnostics.markTypeDiagnosticsStale() + } + + const markComponentLintDiagnosticsStale = () => { + clearComponentLintRecheckTimer() + + if (lastComponentLintIssueCount > 0) { + componentLintPending = true + syncLintPendingState() + setTypeDiagnosticsDetails({ + headline: 'Source changed. Re-checking lint issues…', + level: 'muted', + }) + + scheduledComponentLintRecheck = setTimeout(() => { + scheduledComponentLintRecheck = null + void runComponentLint() + }, 450) + return + } + + componentLintPending = false + syncLintPendingState() + setTypeDiagnosticsDetails({ + headline: 'Source changed. Click Lint to run diagnostics.', + level: 'muted', + }) + + if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { + setStatus('Rendered', 'neutral') + } + } + + const markStylesLintDiagnosticsStale = () => { + clearStylesLintRecheckTimer() + + if (lastStylesLintIssueCount > 0) { + stylesLintPending = true + syncLintPendingState() + setStyleDiagnosticsDetails({ + headline: 'Source changed. Re-checking lint issues…', + level: 'muted', + }) + + scheduledStylesLintRecheck = setTimeout(() => { + scheduledStylesLintRecheck = null + void runStylesLint() + }, 450) + return + } + + stylesLintPending = false + syncLintPendingState() + setStyleDiagnosticsDetails({ + headline: 'Source changed. Click Lint to run diagnostics.', + level: 'muted', + }) + + if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { + setStatus('Rendered', 'neutral') + } + } + + const clearComponentLintDiagnosticsState = () => { + lastComponentLintIssueCount = 0 + componentLintPending = false + clearComponentLintRecheckTimer() + syncLintPendingState() + } + + const clearStylesLintDiagnosticsState = () => { + lastStylesLintIssueCount = 0 + stylesLintPending = false + clearStylesLintRecheckTimer() + syncLintPendingState() + } + + const resetDiagnosticsFlow = () => { + activeComponentLintAbortController?.abort() + activeStylesLintAbortController?.abort() + activeComponentLintAbortController = null + activeStylesLintAbortController = null + + lintDiagnostics.cancelAll() + typeDiagnostics.cancelTypeDiagnostics() + clearComponentLintDiagnosticsState() + clearStylesLintDiagnosticsState() + clearAllDiagnostics() + + setLintButtonLoading({ button: lintComponentButton, isLoading: false }) + setLintButtonLoading({ button: lintStylesButton, isLoading: false }) + setStatus('Rendered', 'neutral') + } + + const dispose = () => { + activeComponentLintAbortController?.abort() + activeStylesLintAbortController?.abort() + activeComponentLintAbortController = null + activeStylesLintAbortController = null + clearComponentLintRecheckTimer() + clearStylesLintRecheckTimer() + lintDiagnostics.dispose() + } + + const renderPreview = async () => { + await getRenderRuntime()?.renderPreview() + } + + const maybeRender = () => { + if (autoRenderToggle.checked) { + getRenderRuntime()?.scheduleRender() + } + } + + const maybeRenderFromComponentEditorChange = () => { + if (!autoRenderToggle.checked) { + return + } + + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'component') { + const shouldRender = getRenderRuntime()?.shouldAutoRenderForTabChange(activeTab.id) + if (!shouldRender) { + return + } + } + + getRenderRuntime()?.scheduleRender() + } + + return { + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + dispose, + lintDiagnostics, + markComponentLintDiagnosticsStale, + markStylesLintDiagnosticsStale, + markTypeDiagnosticsStale, + maybeRender, + maybeRenderFromComponentEditorChange, + renderPreview, + resetDiagnosticsFlow, + runComponentLint, + runStylesLint, + setRenderedStatus, + typeDiagnostics, + } +} + +export { createDiagnosticsFlowController } diff --git a/src/modules/app-core/editor-bootstrap-controller.js b/src/modules/app-core/editor-bootstrap-controller.js new file mode 100644 index 0000000..6111f21 --- /dev/null +++ b/src/modules/app-core/editor-bootstrap-controller.js @@ -0,0 +1,201 @@ +const createEditorBootstrapController = ({ + createCodeMirrorEditor, + jsxEditor, + cssEditor, + getJsxSource, + getCssSource, + getStyleEditorLanguage, + getStyleModeValue, + getSuppressEditorChangeSideEffects, + getActiveWorkspaceTab, + getTabKind, + getDirtyStateForTabChange, + workspaceTabsState, + toWorkspaceSyncedContent, + renderWorkspaceTabs, + queueWorkspaceSave, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + maybeRender, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + setJsxCodeEditor, + setCssCodeEditor, + setGetJsxSource, + setGetCssSource, + editorPool, + componentEditorPanel, + stylesEditorPanel, + loadWorkspaceTabIntoEditor, + setStatus, +}) => { + const createEditorHost = textarea => { + const host = document.createElement('div') + host.className = 'editor-host' + textarea.before(host) + return host + } + + const initializeCodeEditors = async () => { + const jsxHost = createEditorHost(jsxEditor) + const cssHost = createEditorHost(cssEditor) + + let nextJsxEditor = null + let nextCssEditor = null + + try { + ;[nextJsxEditor, nextCssEditor] = await Promise.all([ + createCodeMirrorEditor({ + parent: jsxHost, + value: getJsxSource(), + language: 'javascript-jsx', + contentAttributes: { + 'aria-label': 'Component source editor', + 'aria-multiline': 'true', + }, + onChange: () => { + if (getSuppressEditorChangeSideEffects()) { + return + } + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'component') { + const nextContent = getJsxSource() + const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) + workspaceTabsState.upsertTab( + { + ...activeTab, + content: nextContent, + syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), + isDirty: nextDirtyState, + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'componentEditorChange' }, + ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } + } + queueWorkspaceSave() + maybeRenderFromComponentEditorChange() + markTypeDiagnosticsStale() + markComponentLintDiagnosticsStale() + }, + }), + createCodeMirrorEditor({ + parent: cssHost, + value: getCssSource(), + language: getStyleEditorLanguage(getStyleModeValue()), + contentAttributes: { + 'aria-label': 'Styles source editor', + 'aria-multiline': 'true', + }, + onChange: () => { + if (getSuppressEditorChangeSideEffects()) { + return + } + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'styles') { + const nextContent = getCssSource() + const nextDirtyState = getDirtyStateForTabChange(activeTab, nextContent) + workspaceTabsState.upsertTab( + { + ...activeTab, + content: nextContent, + syncedContent: toWorkspaceSyncedContent(activeTab?.syncedContent), + isDirty: nextDirtyState, + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'stylesEditorChange' }, + ) + + if (nextDirtyState !== Boolean(activeTab.isDirty)) { + renderWorkspaceTabs() + } + } + queueWorkspaceSave() + maybeRender() + markStylesLintDiagnosticsStale() + }, + }), + ]) + } catch (error) { + jsxHost.remove() + cssHost.remove() + const message = error instanceof Error ? error.message : String(error) + setStatus(`Editor fallback: ${message}`, 'neutral') + return + } + + setJsxCodeEditor(nextJsxEditor) + setCssCodeEditor(nextCssEditor) + setGetJsxSource(() => nextJsxEditor.getValue()) + setGetCssSource(() => nextCssEditor.getValue()) + jsxEditor.classList.add('source-textarea--hidden') + cssEditor.classList.add('source-textarea--hidden') + + try { + 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. */ + }) + } + }) + + 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) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setStatus(`Editor sync warning: ${message}`, 'neutral') + } + } + + return { + initializeCodeEditors, + } +} + +export { createEditorBootstrapController } diff --git a/src/modules/app-core/github-pr-context-ui.js b/src/modules/app-core/github-pr-context-ui.js new file mode 100644 index 0000000..ec93b62 --- /dev/null +++ b/src/modules/app-core/github-pr-context-ui.js @@ -0,0 +1,158 @@ +export const createGitHubPrContextUiController = ({ + contextState, + getActivePrContextSyncKey, + githubPrToggle, + githubPrToggleLabel, + githubPrToggleIcon, + githubPrToggleIconPath, + componentPrSyncIcon, + componentPrSyncIconPath, + stylesPrSyncIcon, + stylesPrSyncIconPath, + githubPrContextClose, + githubPrContextDisconnect, + aiChatToggle, + workspacesToggle, + githubPrOpenIcon, + githubPrPushCommitIcon, + closeChatDrawer, + closePrDrawer, + closeWorkspacesDrawer, +}) => { + const setGitHubPrToggleVisual = mode => { + if ( + !(githubPrToggle instanceof HTMLButtonElement) || + !(githubPrToggleLabel instanceof HTMLElement) || + !(githubPrToggleIcon instanceof SVGElement) || + !(githubPrToggleIconPath instanceof SVGPathElement) + ) { + 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 + + 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 setActivePrContext = activeContext => { + contextState.activePrContext = activeContext ?? null + const nextSyncKey = getActivePrContextSyncKey(activeContext) + + if (!nextSyncKey) { + contextState.activePrEditorSyncKey = '' + contextState.hasSyncedActivePrEditorContent = false + } else if (contextState.activePrEditorSyncKey !== nextSyncKey) { + contextState.activePrEditorSyncKey = nextSyncKey + contextState.hasSyncedActivePrEditorContent = false + } + + const hasActiveContext = Boolean(activeContext?.prTitle) + const shouldShowEditorSyncIndicators = + hasActiveContext && contextState.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 markActivePrEditorContentSynced = () => { + const hasActiveContext = Boolean(contextState.activePrContext?.prTitle) + if (!hasActiveContext) { + return + } + + contextState.hasSyncedActivePrEditorContent = true + syncEditorPrContextIndicators(true) + } + + const syncAiChatTokenVisibility = token => { + const hasToken = typeof token === 'string' && token.trim().length > 0 + + if (hasToken) { + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.disabled = false + } + + aiChatToggle?.removeAttribute('hidden') + + githubPrToggle?.removeAttribute('hidden') + if (!contextState.activePrContext) { + workspacesToggle?.removeAttribute('hidden') + } + + if (contextState.activePrContext) { + githubPrContextClose?.removeAttribute('hidden') + githubPrContextDisconnect?.removeAttribute('hidden') + workspacesToggle?.setAttribute('hidden', '') + } else { + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') + } + return + } + + aiChatToggle?.setAttribute('hidden', '') + aiChatToggle?.setAttribute('aria-expanded', 'false') + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.disabled = true + } + contextState.activePrContext = null + contextState.activePrEditorSyncKey = '' + contextState.hasSyncedActivePrEditorContent = false + syncEditorPrContextIndicators(false) + setGitHubPrToggleVisual('open-pr') + githubPrToggle?.setAttribute('hidden', '') + githubPrToggle?.setAttribute('aria-expanded', 'false') + workspacesToggle?.setAttribute('hidden', '') + workspacesToggle?.setAttribute('aria-expanded', 'false') + githubPrContextClose?.setAttribute('hidden', '') + githubPrContextDisconnect?.setAttribute('hidden', '') + closeChatDrawer?.() + closePrDrawer?.() + void closeWorkspacesDrawer?.() + } + + return { + markActivePrEditorContentSynced, + setActivePrContext, + syncAiChatTokenVisibility, + } +} diff --git a/src/modules/app-core/github-token-info-ui.js b/src/modules/app-core/github-token-info-ui.js new file mode 100644 index 0000000..b57af4e --- /dev/null +++ b/src/modules/app-core/github-token-info-ui.js @@ -0,0 +1,45 @@ +export const createGitHubTokenInfoUiController = ({ + tokenInfoButton, + tokenInfoPanel, +}) => { + let open = false + + const isReady = () => + tokenInfoButton instanceof HTMLButtonElement && tokenInfoPanel instanceof HTMLElement + + const setOpen = isOpen => { + if (!isReady()) { + return + } + + open = Boolean(isOpen) + tokenInfoButton.setAttribute('aria-expanded', open ? 'true' : 'false') + + if (open) { + tokenInfoPanel.removeAttribute('hidden') + return + } + + tokenInfoPanel.setAttribute('hidden', '') + } + + const toggle = () => { + setOpen(!open) + } + + const shouldCloseForClickTarget = target => { + if (!isReady() || !open || !(target instanceof Node)) { + return false + } + + return !tokenInfoButton.contains(target) && !tokenInfoPanel.contains(target) + } + + return { + close: () => setOpen(false), + isOpen: () => open, + setOpen, + shouldCloseForClickTarget, + toggle, + } +} diff --git a/src/modules/app-core/github-workflows-setup.js b/src/modules/app-core/github-workflows-setup.js new file mode 100644 index 0000000..a366ec5 --- /dev/null +++ b/src/modules/app-core/github-workflows-setup.js @@ -0,0 +1,56 @@ +import { initializeGitHubWorkflows } from './github-workflows.js' + +const createGitHubWorkflowsSetup = ({ + factories, + platform, + state, + byot, + ui, + workspace, + runtime, + actions, +}) => + initializeGitHubWorkflows({ + ...factories, + ...platform, + githubAiContextState: state.githubAiContextState, + byotControls: byot.byotControls, + getCurrentGitHubToken: byot.getCurrentGitHubToken, + getCurrentSelectedRepository: byot.getCurrentSelectedRepository, + ...ui, + workspaceStorage: workspace.workspaceStorage, + getActiveWorkspaceRecordId: workspace.getActiveWorkspaceRecordId, + setActiveWorkspaceRecordId: workspace.setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: workspace.setActiveWorkspaceCreatedAt, + listLocalContextRecords: workspace.listLocalContextRecords, + refreshLocalContextOptions: workspace.refreshLocalContextOptions, + applyWorkspaceRecord: workspace.applyWorkspaceRecord, + getWorkspacePrFileCommits: workspace.getWorkspacePrFileCommits, + getEditorSyncTargets: workspace.getEditorSyncTargets, + getRenderMode: runtime.getRenderMode, + getStyleMode: runtime.getStyleMode, + setCurrentSelectedRepository: byot.setCurrentSelectedRepository, + reconcileWorkspaceTabsWithPushUpdates: + workspace.reconcileWorkspaceTabsWithPushUpdates, + getActivePrContextSyncKey: runtime.getActivePrContextSyncKey, + prContextUi: runtime.prContextUi, + getTokenForVisibility: runtime.getTokenForVisibility, + closeWorkspacesDrawer: runtime.closeWorkspacesDrawer, + getActivePrEditorSyncKey: runtime.getActivePrEditorSyncKey, + syncFromActiveContext: runtime.syncFromActiveContext, + applyRenderMode: actions.applyRenderMode, + applyStyleMode: actions.applyStyleMode, + formatActivePrReference: runtime.formatActivePrReference, + githubPrContextClose: runtime.githubPrContextClose, + githubPrContextDisconnect: runtime.githubPrContextDisconnect, + confirmAction: actions.confirmAction, + setStatus: actions.setStatus, + showAppToast: actions.showAppToast, + setComponentSource: actions.setComponentSource, + setStylesSource: actions.setStylesSource, + getComponentSource: actions.getComponentSource, + getStylesSource: actions.getStylesSource, + scheduleRender: actions.scheduleRender, + }) + +export { createGitHubWorkflowsSetup } diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js new file mode 100644 index 0000000..a88d0fc --- /dev/null +++ b/src/modules/app-core/github-workflows.js @@ -0,0 +1,370 @@ +const initializeGitHubWorkflows = ({ + createGitHubPrEditorSyncController, + createGitHubChatDrawer, + createGitHubPrDrawer, + createWorkspacesDrawer, + ensureJsxTransformSource, + collectTopLevelDeclarations, + cdnImports, + importFromCdnWithFallback, + githubAiContextState, + byotControls, + getCurrentGitHubToken, + getCurrentSelectedRepository, + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + githubPrToggle, + githubPrDrawer, + githubPrClose, + githubPrRepoSelect, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + githubPrCommitMessage, + githubPrIncludeAppWrapper, + githubPrSubmit, + openPrTitle, + githubPrStatus, + workspacesToggle, + workspacesDrawer, + workspacesClose, + workspacesStatus, + workspacesSearch, + workspacesSelect, + workspacesOpen, + workspacesRemove, + workspaceStorage, + getActiveWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + getWorkspacePrFileCommits, + getEditorSyncTargets, + getRenderMode, + getStyleMode, + setCurrentSelectedRepository, + reconcileWorkspaceTabsWithPushUpdates, + getActivePrContextSyncKey, + prContextUi, + getTokenForVisibility, + closeWorkspacesDrawer, + getActivePrEditorSyncKey, + syncFromActiveContext, + applyRenderMode, + applyStyleMode, + formatActivePrReference, + githubPrContextClose, + githubPrContextDisconnect, + confirmAction, + setStatus, + showAppToast, + setComponentSource, + setStylesSource, + getComponentSource, + getStylesSource, + scheduleRender, +}) => { + const getCurrentWritableRepositories = () => + githubAiContextState.writableRepositories.length > 0 + ? [...githubAiContextState.writableRepositories] + : byotControls.getWritableRepositories() + + const getTopLevelDeclarations = async source => { + if (typeof source !== 'string' || !source.trim()) { + return [] + } + + const transformJsxSource = await ensureJsxTransformSource({ + cdnImports, + importFromCdnWithFallback, + }) + return collectTopLevelDeclarations({ source, transformJsxSource }) + } + + const prEditorSyncController = createGitHubPrEditorSyncController({ + setComponentSource: value => { + setComponentSource(value) + }, + setStylesSource: value => { + setStylesSource(value) + }, + scheduleRender, + }) + + const chatDrawerController = createGitHubChatDrawer({ + toggleButton: aiChatToggle, + drawer: aiChatDrawer, + closeButton: aiChatClose, + promptInput: aiChatPrompt, + modelSelect: aiChatModel, + includeEditorsContextToggle: aiChatIncludeEditors, + sendButton: aiChatSend, + clearButton: aiChatClear, + statusNode: aiChatStatus, + repositoryNode: aiChatRepository, + messagesNode: aiChatMessages, + getToken: getCurrentGitHubToken, + getSelectedRepository: getCurrentSelectedRepository, + getComponentSource, + setComponentSource, + getStylesSource, + setStylesSource, + scheduleRender, + getRenderMode, + getStyleMode, + getDrawerSide: () => { + return 'right' + }, + }) + + const prDrawerController = createGitHubPrDrawer({ + toggleButton: githubPrToggle, + drawer: githubPrDrawer, + closeButton: githubPrClose, + repositorySelect: githubPrRepoSelect, + baseBranchInput: githubPrBaseBranch, + headBranchInput: githubPrHeadBranch, + prTitleInput: githubPrTitle, + prBodyInput: githubPrBody, + commitMessageInput: githubPrCommitMessage, + includeAppWrapperToggle: githubPrIncludeAppWrapper, + submitButton: githubPrSubmit, + titleNode: openPrTitle, + statusNode: githubPrStatus, + getToken: getCurrentGitHubToken, + getSelectedRepository: getCurrentSelectedRepository, + getWritableRepositories: getCurrentWritableRepositories, + setSelectedRepository: setCurrentSelectedRepository, + getFileCommits: getWorkspacePrFileCommits, + getEditorSyncTargets, + getTopLevelDeclarations, + getRenderMode, + getStyleMode, + getDrawerSide: () => { + return 'right' + }, + confirmBeforeSubmit: options => { + confirmAction(options) + }, + onPullRequestOpened: ({ url, fileUpdates }) => { + const activeContextSyncKey = getActivePrContextSyncKey( + githubAiContextState.activePrContext, + ) + if (activeContextSyncKey && activeContextSyncKey === getActivePrEditorSyncKey()) { + prContextUi.markActivePrEditorContentSynced() + } + + const message = url + ? `Pull request opened: ${url}` + : 'Pull request opened successfully.' + reconcileWorkspaceTabsWithPushUpdates(fileUpdates) + showAppToast(message) + }, + onPullRequestCommitPushed: ({ branch, fileUpdates }) => { + reconcileWorkspaceTabsWithPushUpdates(fileUpdates) + const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0 + const message = + fileCount > 0 + ? `Pushed commit to ${branch} (${fileCount} file${fileCount === 1 ? '' : 's'}).` + : `Pushed commit to ${branch}.` + showAppToast(message) + }, + onActivePrContextChange: activeContext => { + prContextUi.setActivePrContext(activeContext) + prContextUi.syncAiChatTokenVisibility(getTokenForVisibility()) + if (workspacesToggle instanceof HTMLButtonElement) { + workspacesToggle.hidden = Boolean(activeContext) + } + + if (activeContext) { + closeWorkspacesDrawer() + } + }, + onSyncActivePrEditorContent: async args => { + const result = await prEditorSyncController.syncFromActiveContext(args) + const syncedContextKey = getActivePrContextSyncKey(args?.activeContext) + + if (!syncedContextKey || syncedContextKey !== getActivePrEditorSyncKey()) { + return result + } + + if (result?.synced === true) { + prContextUi.markActivePrEditorContentSynced() + + syncFromActiveContext({ + tabTargets: args?.syncTargets?.tabTargets, + }) + } + + return result + }, + onRestoreRenderMode: mode => { + applyRenderMode({ mode, fromActivePrContext: true }) + }, + onRestoreStyleMode: mode => { + applyStyleMode({ mode }) + }, + }) + + const workspacesDrawerController = createWorkspacesDrawer({ + toggleButton: workspacesToggle, + drawer: workspacesDrawer, + closeButton: workspacesClose, + statusNode: workspacesStatus, + searchInput: workspacesSearch, + selectInput: workspacesSelect, + openButton: workspacesOpen, + removeButton: workspacesRemove, + getDrawerSide: () => { + return 'right' + }, + onRefreshRequested: listLocalContextRecords, + onOpenSelected: async workspaceId => { + try { + const record = await workspaceStorage.getWorkspaceById(workspaceId) + if (!record) { + await refreshLocalContextOptions() + workspacesDrawerController?.setStatus( + 'Stored local context no longer exists.', + 'error', + ) + return false + } + + return applyWorkspaceRecord(record, { silent: false }) + } catch { + workspacesDrawerController?.setStatus( + 'Could not load selected local context.', + 'error', + ) + return false + } + }, + onRemoveSelected: async workspaceId => { + confirmAction({ + title: 'Remove stored local context?', + copy: 'This removes only local workspace metadata and editor content from this browser.', + confirmButtonText: 'Remove', + onConfirm: () => { + void workspaceStorage + .removeWorkspace(workspaceId) + .then(async () => { + if (getActiveWorkspaceRecordId() === workspaceId) { + setActiveWorkspaceRecordId('') + setActiveWorkspaceCreatedAt(null) + } + + await refreshLocalContextOptions() + workspacesDrawerController?.setStatus( + 'Removed stored local context.', + 'neutral', + ) + }) + .catch(() => { + workspacesDrawerController?.setStatus( + 'Could not remove stored local context.', + 'error', + ) + }) + }, + }) + + return false + }, + }) + + prDrawerController.setToken(githubAiContextState.token) + prDrawerController.setSelectedRepository(githubAiContextState.selectedRepository) + prDrawerController.syncRepositories() + prContextUi.setActivePrContext(prDrawerController.getActivePrContext()) + + githubPrContextClose?.addEventListener('click', () => { + if (!githubAiContextState.activePrContext) { + return + } + + const activePrReference = formatActivePrReference( + githubAiContextState.activePrContext, + ) + const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' + + confirmAction({ + title: 'Close pull request on GitHub?', + copy: `${referenceLine}PR title: ${githubAiContextState.activePrContext.prTitle}\nHead branch: ${githubAiContextState.activePrContext.headBranch}\n\nThis will close the pull request on GitHub and clear the active pull request context for the selected repository.`, + confirmButtonText: 'Close PR on GitHub', + onConfirm: () => { + void prDrawerController + .closeActivePullRequestOnGitHub() + .then(result => { + const reference = result?.reference + setStatus( + reference + ? `Closed pull request on GitHub and cleared active context (${reference}).` + : 'Closed pull request on GitHub and cleared active context.', + 'neutral', + ) + showAppToast( + reference + ? `Closed pull request on GitHub and cleared active context (${reference}).` + : 'Closed pull request on GitHub and cleared active context.', + ) + }) + .catch(error => { + const message = + error instanceof Error + ? error.message + : 'Could not close pull request context on GitHub.' + setStatus(`Close context failed: ${message}`, 'error') + showAppToast(`Close context failed: ${message}`) + }) + }, + }) + }) + + githubPrContextDisconnect?.addEventListener('click', () => { + if (!githubAiContextState.activePrContext) { + return + } + + const activePrReference = formatActivePrReference( + githubAiContextState.activePrContext, + ) + const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' + + confirmAction({ + title: 'Disconnect PR context?', + copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`, + confirmButtonText: 'Disconnect', + onConfirm: () => { + const result = prDrawerController.disconnectActivePrContext() + const reference = result?.reference + setStatus( + reference + ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.` + : 'Disconnected PR context. Pull request remains open on GitHub.', + 'neutral', + ) + }, + }) + }) + + return { + chatDrawerController, + prDrawerController, + workspacesDrawerController, + } +} + +export { initializeGitHubWorkflows } diff --git a/src/modules/app-core/layout-diagnostics-setup.js b/src/modules/app-core/layout-diagnostics-setup.js new file mode 100644 index 0000000..29f6615 --- /dev/null +++ b/src/modules/app-core/layout-diagnostics-setup.js @@ -0,0 +1,307 @@ +const createLayoutDiagnosticsSetup = ({ + compactAiControlsUi, + appGrid, + previewPanel, + componentEditorPanel, + stylesEditorPanel, + panelCollapseButtons, + editorKinds, + editorPanelsByKind, + editorToolsButtons, + createDiagnosticsUiController, + diagnosticsToggle, + diagnosticsDrawer, + diagnosticsComponent, + diagnosticsStyles, + statusNode, + getJsxCodeEditor, + getCssCodeEditor, + jsxEditor, + cssEditor, +}) => { + const getPanelCollapseAxis = panelName => { + if (compactAiControlsUi.isCompactViewport()) { + return 'vertical' + } + + if (panelName === 'preview') { + return 'horizontal' + } + + if (panelName === 'component' || panelName === 'styles') { + return 'vertical' + } + + return 'vertical' + } + + const getPanelCollapseDirection = panelName => { + const axis = getPanelCollapseAxis(panelName) + if (axis !== 'horizontal') { + return 'none' + } + + if (panelName === 'preview') { + return 'right' + } + + if (panelName === 'component') { + return 'left' + } + + if (panelName === 'styles') { + return 'right' + } + + return 'right' + } + + const panelCollapseState = { + component: false, + styles: false, + preview: false, + } + + const panelToolsState = { + component: false, + styles: false, + } + + const applyEditorToolsVisibility = () => { + for (const editorKind of editorKinds) { + editorPanelsByKind[editorKind]?.classList.toggle( + 'panel--tools-hidden', + !panelToolsState[editorKind], + ) + } + + for (const button of editorToolsButtons) { + const panelName = button.dataset.editorToolsToggle + if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { + continue + } + + const isVisible = panelToolsState[panelName] + button.setAttribute('aria-pressed', isVisible ? 'true' : 'false') + button.setAttribute( + 'aria-label', + `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`, + ) + button.setAttribute('title', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) + } + } + + const normalizePanelCollapseState = () => { + const collapsedPanels = Object.entries(panelCollapseState) + .filter(([, isCollapsed]) => isCollapsed) + .map(([panelName]) => panelName) + + if (collapsedPanels.length === Object.keys(panelCollapseState).length) { + panelCollapseState.preview = false + } + } + + const syncPanelCollapseButtons = () => { + const collapsedCount = Object.values(panelCollapseState).filter(Boolean).length + + for (const button of panelCollapseButtons) { + const panelName = button.dataset.panelCollapse + if (!panelName || !Object.hasOwn(panelCollapseState, panelName)) { + continue + } + + const axis = getPanelCollapseAxis(panelName) + const direction = getPanelCollapseDirection(panelName) + const isCollapsed = panelCollapseState[panelName] === true + const panelTitle = `${panelName.charAt(0).toUpperCase()}${panelName.slice(1)}` + const canCollapse = isCollapsed || collapsedCount < 2 + + button.dataset.collapseAxis = axis + button.dataset.collapseDirection = direction + button.dataset.collapsed = isCollapsed ? 'true' : 'false' + button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true') + button.disabled = !canCollapse + button.setAttribute('aria-disabled', canCollapse ? 'false' : 'true') + button.setAttribute( + 'aria-label', + `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel`, + ) + button.setAttribute( + 'title', + canCollapse + ? `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel` + : 'At least one panel must remain expanded.', + ) + } + } + + const applyPanelCollapseState = () => { + normalizePanelCollapseState() + + const previewAxis = getPanelCollapseAxis('preview') + const componentAxis = getPanelCollapseAxis('component') + const stylesAxis = getPanelCollapseAxis('styles') + + if (componentEditorPanel) { + const isCollapsed = panelCollapseState.component + componentEditorPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && componentAxis === 'vertical', + ) + componentEditorPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && componentAxis === 'horizontal', + ) + } + + if (stylesEditorPanel) { + const isCollapsed = panelCollapseState.styles + stylesEditorPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && stylesAxis === 'vertical', + ) + stylesEditorPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && stylesAxis === 'horizontal', + ) + } + + if (previewPanel) { + const isCollapsed = panelCollapseState.preview + previewPanel.classList.toggle( + 'panel--collapsed-vertical', + isCollapsed && previewAxis === 'vertical', + ) + previewPanel.classList.toggle( + 'panel--collapsed-horizontal', + isCollapsed && previewAxis === 'horizontal', + ) + } + + appGrid.classList.toggle( + 'app-grid--preview-collapsed-horizontal', + panelCollapseState.preview && previewAxis === 'horizontal', + ) + appGrid.classList.toggle('app-grid--preview-collapsed', panelCollapseState.preview) + appGrid.classList.toggle( + 'app-grid--component-collapsed', + panelCollapseState.component, + ) + appGrid.classList.toggle('app-grid--styles-collapsed', panelCollapseState.styles) + appGrid.classList.toggle( + 'app-grid--component-collapsed-horizontal', + panelCollapseState.component && componentAxis === 'horizontal', + ) + appGrid.classList.toggle( + 'app-grid--styles-collapsed-horizontal', + panelCollapseState.styles && stylesAxis === 'horizontal', + ) + + syncPanelCollapseButtons() + } + + const togglePanelCollapse = panelName => { + if (!Object.hasOwn(panelCollapseState, panelName)) { + return + } + + panelCollapseState[panelName] = !panelCollapseState[panelName] + applyPanelCollapseState() + } + + const toTextareaOffset = (source, line, column = 1) => { + if (typeof source !== 'string' || source.length === 0) { + return 0 + } + + const targetLine = Number.isFinite(line) ? Math.max(1, Number(line)) : 1 + const targetColumn = Number.isFinite(column) ? Math.max(1, Number(column)) : 1 + + let currentLine = 1 + let lineStartOffset = 0 + + for (let index = 0; index < source.length; index += 1) { + if (currentLine === targetLine) { + lineStartOffset = index + break + } + + if (source[index] === '\n') { + currentLine += 1 + lineStartOffset = index + 1 + } + } + + const nextNewlineOffset = source.indexOf('\n', lineStartOffset) + const lineEndOffset = nextNewlineOffset === -1 ? source.length : nextNewlineOffset + return Math.min(lineStartOffset + targetColumn - 1, lineEndOffset) + } + + const navigateToComponentDiagnostic = ({ line, column }) => { + const jsxCodeEditor = getJsxCodeEditor() + if (jsxCodeEditor && typeof jsxCodeEditor.revealPosition === 'function') { + jsxCodeEditor.revealPosition({ line, column }) + return + } + + if (!(jsxEditor instanceof HTMLTextAreaElement)) { + return + } + + const source = jsxEditor.value + const offset = toTextareaOffset(source, line, column) + jsxEditor.focus() + jsxEditor.setSelectionRange(offset, offset) + } + + const navigateToStylesDiagnostic = ({ line, column }) => { + const cssCodeEditor = getCssCodeEditor() + if (cssCodeEditor && typeof cssCodeEditor.revealPosition === 'function') { + cssCodeEditor.revealPosition({ line, column }) + return + } + + if (!(cssEditor instanceof HTMLTextAreaElement)) { + return + } + + const source = cssEditor.value + const offset = toTextareaOffset(source, line, column) + cssEditor.focus() + cssEditor.setSelectionRange(offset, offset) + } + + const diagnosticsUi = createDiagnosticsUiController({ + diagnosticsToggle, + diagnosticsDrawer, + diagnosticsComponent, + diagnosticsStyles, + statusNode, + onNavigateDiagnostic: diagnostic => { + if (diagnostic?.scope === 'component') { + navigateToComponentDiagnostic({ + line: diagnostic.line, + column: diagnostic.column, + }) + return + } + + if (diagnostic?.scope === 'styles') { + navigateToStylesDiagnostic({ + line: diagnostic.line, + column: diagnostic.column, + }) + } + }, + }) + + return { + applyEditorToolsVisibility, + applyPanelCollapseState, + diagnosticsUi, + panelToolsState, + togglePanelCollapse, + } +} + +export { createLayoutDiagnosticsSetup } diff --git a/src/modules/app-core/runtime-core-setup.js b/src/modules/app-core/runtime-core-setup.js new file mode 100644 index 0000000..84a0db1 --- /dev/null +++ b/src/modules/app-core/runtime-core-setup.js @@ -0,0 +1,172 @@ +const createRuntimeCoreSetup = ({ + createDiagnosticsFlowController, + createRenderRuntimeController, + diagnosticsFlowOptions, + renderRuntimeOptions, + clearConfirmDialog, + clearConfirmTitle, + clearConfirmCopy, + clearConfirmButton, + setPendingClearAction, + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode, + resetDiagnosticsFlow, + maybeRender, + flushWorkspaceSave, + renderMode, + styleMode, + getCssCodeEditor, + setSuppressEditorChangeSideEffects, + getStyleEditorLanguage, + getActiveWorkspaceTab, + getTabKind, + workspaceTabsState, + queueWorkspaceSave, +}) => { + const setCdnLoading = isLoading => { + const cdnLoading = document.getElementById('cdn-loading') + if (!cdnLoading) return + cdnLoading.hidden = !isLoading + } + + const diagnosticsFlowController = + createDiagnosticsFlowController(diagnosticsFlowOptions) + + const setRenderedStatus = () => diagnosticsFlowController.setRenderedStatus() + + const renderRuntime = createRenderRuntimeController({ + ...renderRuntimeOptions, + setRenderedStatus, + setCdnLoading, + }) + + const confirmAction = ({ title, copy, confirmButtonText = 'Clear', onConfirm }) => { + const toConfirmText = value => (typeof value === 'string' ? value.trim() : '') + if ( + !(clearConfirmDialog instanceof HTMLDialogElement) || + typeof clearConfirmDialog.showModal !== 'function' + ) { + return + } + + if (clearConfirmDialog.open) { + return + } + + if (clearConfirmTitle) { + clearConfirmTitle.textContent = title + } + + if (clearConfirmCopy instanceof HTMLUListElement) { + const lines = toConfirmText(copy) + .split('\n') + .map(line => line.replace(/^\s*[-*]\s*/, '').trim()) + .filter(Boolean) + + clearConfirmCopy.replaceChildren() + const items = lines.length > 0 ? lines : [toConfirmText(copy)] + + for (const line of items) { + if (!line) { + continue + } + + const listItem = document.createElement('li') + listItem.textContent = line + clearConfirmCopy.append(listItem) + } + } else if (clearConfirmCopy) { + clearConfirmCopy.textContent = copy + } + + if (clearConfirmButton instanceof HTMLButtonElement) { + clearConfirmButton.textContent = confirmButtonText + clearConfirmButton.removeAttribute('aria-label') + } + + setPendingClearAction(onConfirm) + clearConfirmDialog.showModal() + } + + const applyRenderMode = ({ + mode, + fromActivePrContext: _fromActivePrContext = false, + }) => { + const nextMode = normalizeRenderMode(mode) + + if (renderMode.value !== nextMode) { + renderMode.value = nextMode + } + + persistRenderMode(nextMode) + resetDiagnosticsFlow() + + maybeRender() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + + const applyStyleMode = ({ mode }) => { + const nextMode = normalizeStyleMode(mode) + + if (styleMode.value !== nextMode) { + styleMode.value = nextMode + } + + resetDiagnosticsFlow() + + const cssCodeEditor = getCssCodeEditor() + if (cssCodeEditor) { + setSuppressEditorChangeSideEffects(true) + try { + cssCodeEditor.setLanguage(getStyleEditorLanguage(nextMode)) + } finally { + setSuppressEditorChangeSideEffects(false) + } + } + + const activeTab = getActiveWorkspaceTab() + if (activeTab && getTabKind(activeTab) === 'styles') { + const nextLanguage = + nextMode === 'less' + ? 'less' + : nextMode === 'sass' + ? 'sass' + : nextMode === 'module' + ? 'module' + : 'css' + + if (activeTab.language !== nextLanguage) { + workspaceTabsState.upsertTab( + { + ...activeTab, + language: nextLanguage, + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'styleModeChange' }, + ) + queueWorkspaceSave() + } + } + + maybeRender() + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + + return { + applyRenderMode, + applyStyleMode, + confirmAction, + diagnosticsFlowController, + renderRuntime, + setCdnLoading, + setRenderedStatus, + } +} + +export { createRuntimeCoreSetup } diff --git a/src/modules/app-core/runtime-editor-utils.js b/src/modules/app-core/runtime-editor-utils.js new file mode 100644 index 0000000..60c4f0e --- /dev/null +++ b/src/modules/app-core/runtime-editor-utils.js @@ -0,0 +1,90 @@ +const getStyleEditorLanguage = mode => { + if (mode === 'less') return 'less' + if (mode === 'sass') return 'sass' + return 'css' +} + +const normalizeRenderMode = mode => (mode === 'react' ? 'react' : 'dom') + +const persistRenderMode = (mode, { renderModeStorageKey }) => { + const normalizedMode = normalizeRenderMode(mode) + + try { + localStorage.setItem(renderModeStorageKey, normalizedMode) + } catch { + /* Ignore storage write errors in restricted browsing modes. */ + } +} + +const getInitialRenderMode = ({ renderModeStorageKey }) => { + try { + const value = localStorage.getItem(renderModeStorageKey) + return normalizeRenderMode(value) + } catch { + /* Ignore storage read errors in restricted browsing modes. */ + } + + return 'dom' +} + +const updateRenderModeEditability = ({ renderMode, getActiveWorkspaceTab }) => { + if (!(renderMode instanceof HTMLSelectElement)) { + return + } + + const activeTab = getActiveWorkspaceTab() + const isEntryTab = activeTab?.role === 'entry' + renderMode.disabled = !isEntryTab +} + +const normalizeStyleMode = mode => { + if (mode === 'module') return 'module' + if (mode === 'less') return 'less' + if (mode === 'sass') return 'sass' + return 'css' +} + +const setJsxSourceValue = ({ + value, + jsxCodeEditor, + setSuppressEditorChangeSideEffects, + jsxEditor, +}) => { + if (jsxCodeEditor) { + setSuppressEditorChangeSideEffects(true) + try { + jsxCodeEditor.setValue(value) + } finally { + setSuppressEditorChangeSideEffects(false) + } + } + jsxEditor.value = value +} + +const setCssSourceValue = ({ + value, + cssCodeEditor, + setSuppressEditorChangeSideEffects, + cssEditor, +}) => { + if (cssCodeEditor) { + setSuppressEditorChangeSideEffects(true) + try { + cssCodeEditor.setValue(value) + } finally { + setSuppressEditorChangeSideEffects(false) + } + } + cssEditor.value = value +} + +export { + getInitialRenderMode, + getStyleEditorLanguage, + normalizeRenderMode, + normalizeStyleMode, + persistRenderMode, + setCssSourceValue, + setJsxSourceValue, + updateRenderModeEditability, +} diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js new file mode 100644 index 0000000..4411bd0 --- /dev/null +++ b/src/modules/app-core/workspace-context-controller.js @@ -0,0 +1,140 @@ +const createWorkspaceContextController = ({ + workspaceStorage, + getCurrentSelectedRepository, + getWorkspacesDrawerController, + getActiveWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + setIsApplyingWorkspaceSnapshot, + ensureWorkspaceTabsShape, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabsState, + resolveWorkspaceActiveTabId, + normalizeRenderMode, + getRenderModeValue, + setRenderModeValue, + persistRenderMode, + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs, + updateRenderModeEditability, + getHasCompletedInitialWorkspaceBootstrap, + maybeRender, + setStatus, + toWorkspaceRecordId, + getHeadBranchValue, +}) => { + const listLocalContextRecords = async () => { + const selectedRepository = getCurrentSelectedRepository() + return workspaceStorage.listWorkspaces({ + repo: selectedRepository || '', + }) + } + + const refreshLocalContextOptions = async () => { + const options = await listLocalContextRecords() + const workspacesDrawerController = getWorkspacesDrawerController() + + if (workspacesDrawerController) { + workspacesDrawerController.setSelectedId(getActiveWorkspaceRecordId()) + await workspacesDrawerController.refresh() + } + + return options + } + + const applyWorkspaceRecord = async (workspace, { silent = false } = {}) => { + if (!workspace || typeof workspace !== 'object') { + return false + } + + setIsApplyingWorkspaceSnapshot(true) + + try { + setActiveWorkspaceRecordId(workspace.id) + setActiveWorkspaceCreatedAt(workspace.createdAt ?? null) + + const nextTabs = ensureWorkspaceTabsShape(workspace.tabs) + 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 (getRenderModeValue() !== nextRenderMode) { + setRenderModeValue(nextRenderMode) + } + persistRenderMode(nextRenderMode) + + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } + + renderWorkspaceTabs() + updateRenderModeEditability() + + if (getHasCompletedInitialWorkspaceBootstrap()) { + maybeRender() + } + await refreshLocalContextOptions() + if (!silent) { + setStatus('Loaded local workspace context.', 'neutral') + } + + return true + } finally { + setIsApplyingWorkspaceSnapshot(false) + } + } + + const loadPreferredWorkspaceContext = async () => { + const options = await refreshLocalContextOptions() + + if (!Array.isArray(options) || options.length === 0) { + return + } + + const preferredId = + getActiveWorkspaceRecordId() || + toWorkspaceRecordId({ + repositoryFullName: getCurrentSelectedRepository(), + headBranch: getHeadBranchValue(), + }) + + const preferred = options.find(workspace => workspace.id === preferredId) + const next = preferred ?? options[0] + + if (!next) { + return + } + + await applyWorkspaceRecord(next, { silent: true }) + } + + return { + applyWorkspaceRecord, + listLocalContextRecords, + loadPreferredWorkspaceContext, + refreshLocalContextOptions, + } +} + +export { createWorkspaceContextController } diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js new file mode 100644 index 0000000..371305a --- /dev/null +++ b/src/modules/app-core/workspace-controllers-setup.js @@ -0,0 +1,247 @@ +import { createWorkspaceContextController } from './workspace-context-controller.js' +import { createWorkspaceSaveController } from './workspace-save-controller.js' +import { createWorkspaceTabMutationsController } from './workspace-tab-mutations-controller.js' +import { createWorkspaceTabSelectionController } from './workspace-tab-selection-controller.js' +import { createWorkspaceTabsRenderer } from './workspace-tabs-renderer.js' + +const createWorkspaceControllersSetup = ({ + createDebouncedWorkspaceSaver, + workspaceStorage, + getWorkspacesDrawerController, + toNonEmptyWorkspaceText, + buildWorkspaceRecordSnapshot, + setStatus, + getIsApplyingWorkspaceSnapshot, + getActiveWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + getCurrentSelectedRepository, + getActiveWorkspaceRecordId, + setIsApplyingWorkspaceSnapshot, + ensureWorkspaceTabsShape, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabsState, + resolveWorkspaceActiveTabId, + normalizeRenderMode, + getRenderModeValue, + setRenderModeValue, + persistRenderMode, + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + updateRenderModeEditability, + getHasCompletedInitialWorkspaceBootstrap, + maybeRender, + toWorkspaceRecordId, + workspaceTabsStrip, + getWorkspaceTabRenameState, + getDraggedWorkspaceTabId, + setDraggedWorkspaceTabId, + getDragOverWorkspaceTabId, + setDragOverWorkspaceTabId, + getSuppressWorkspaceTabClick, + setSuppressWorkspaceTabClick, + getIsRenderingWorkspaceTabs, + setIsRenderingWorkspaceTabs, + getHasPendingWorkspaceTabsRender, + setHasPendingWorkspaceTabsRender, + persistActiveTabEditorContent, + getWorkspaceTabDisplay, + workspaceTabsShell, + workspaceTabAddWrap, + setWorkspaceTabRenameState, + allowedEntryTabFileNames, + getPathFileName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + defaultComponentTabName, + getDirtyStateForTabChange, + syncHeaderLabels, + setWorkspaceTabAddMenuOpen, + confirmAction, + getTabKind, + getLoadedComponentTabId, + setLoadedComponentTabId, + getLoadedStylesTabId, + setLoadedStylesTabId, + getWorkspaceTabByKind, + makeUniqueTabPath, + createWorkspaceTabId, +}) => { + let workspaceTabsRenderer = null + let workspaceTabMutationsController = null + + const renderWorkspaceTabs = () => workspaceTabsRenderer.renderWorkspaceTabs() + + const refreshLocalContextOptions = async () => + workspaceContextController.refreshLocalContextOptions() + + const workspaceSaveController = createWorkspaceSaveController({ + createDebouncedWorkspaceSaver, + workspaceStorage, + toNonEmptyWorkspaceText, + buildWorkspaceRecordSnapshot, + refreshLocalContextOptions, + setStatus, + getIsApplyingWorkspaceSnapshot, + getActiveWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + }) + + const queueWorkspaceSave = () => workspaceSaveController.queueWorkspaceSave() + + const flushWorkspaceSave = async () => workspaceSaveController.flushWorkspaceSave() + + const workspaceTabSelectionController = createWorkspaceTabSelectionController({ + toNonEmptyWorkspaceText, + workspaceTabsState, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs: () => renderWorkspaceTabs(), + updateRenderModeEditability: () => updateRenderModeEditability(), + persistActiveTabEditorContent, + getActiveWorkspaceTab, + flushWorkspaceSave, + }) + + const setActiveWorkspaceTab = tabId => + workspaceTabSelectionController.setActiveWorkspaceTab(tabId) + + const syncEditorFromActiveWorkspaceTabDelegate = () => + workspaceTabSelectionController.syncEditorFromActiveWorkspaceTab() + + workspaceTabMutationsController = createWorkspaceTabMutationsController({ + toNonEmptyWorkspaceText, + workspaceTabsState, + setWorkspaceTabRenameState: value => { + setWorkspaceTabRenameState(value) + }, + renderWorkspaceTabs: () => renderWorkspaceTabs(), + setStatus, + allowedEntryTabFileNames, + getPathFileName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + defaultComponentTabName, + getDirtyStateForTabChange, + syncHeaderLabels, + queueWorkspaceSave, + maybeRender: () => maybeRender(), + setWorkspaceTabAddMenuOpen, + confirmAction, + getTabKind, + persistActiveTabEditorContent, + getLoadedComponentTabId, + setLoadedComponentTabId, + getLoadedStylesTabId, + setLoadedStylesTabId, + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + getWorkspaceTabByKind, + setActiveWorkspaceTab, + makeUniqueTabPath, + createWorkspaceTabId, + }) + + const beginWorkspaceTabRenameDelegate = tabId => + workspaceTabMutationsController.beginWorkspaceTabRename(tabId) + + const finishWorkspaceTabRenameDelegate = ({ tabId, nextName, cancelled = false }) => + workspaceTabMutationsController.finishWorkspaceTabRename({ + tabId, + nextName, + cancelled, + }) + + const removeWorkspaceTabDelegate = tabId => + workspaceTabMutationsController.removeWorkspaceTab(tabId) + + workspaceTabsRenderer = createWorkspaceTabsRenderer({ + workspaceTabsStrip, + workspaceTabsState, + getWorkspaceTabRenameState, + getDraggedWorkspaceTabId, + setDraggedWorkspaceTabId, + getDragOverWorkspaceTabId, + setDragOverWorkspaceTabId, + getSuppressWorkspaceTabClick, + setSuppressWorkspaceTabClick, + getIsRenderingWorkspaceTabs, + setIsRenderingWorkspaceTabs, + getHasPendingWorkspaceTabsRender, + setHasPendingWorkspaceTabsRender, + setActiveWorkspaceTab, + persistActiveTabEditorContent, + queueWorkspaceSave, + beginWorkspaceTabRename: beginWorkspaceTabRenameDelegate, + finishWorkspaceTabRename: finishWorkspaceTabRenameDelegate, + removeWorkspaceTab: removeWorkspaceTabDelegate, + getWorkspaceTabDisplay, + workspaceTabsShell, + workspaceTabAddWrap, + syncEditorFromActiveWorkspaceTab: syncEditorFromActiveWorkspaceTabDelegate, + }) + + const workspaceContextController = createWorkspaceContextController({ + workspaceStorage, + getCurrentSelectedRepository, + getWorkspacesDrawerController, + getActiveWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, + setIsApplyingWorkspaceSnapshot, + ensureWorkspaceTabsShape, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabsState, + resolveWorkspaceActiveTabId, + normalizeRenderMode, + getRenderModeValue, + setRenderModeValue, + persistRenderMode, + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs: () => renderWorkspaceTabs(), + updateRenderModeEditability: () => updateRenderModeEditability(), + getHasCompletedInitialWorkspaceBootstrap, + maybeRender: () => maybeRender(), + setStatus, + toWorkspaceRecordId, + getHeadBranchValue: () => + typeof githubPrHeadBranch?.value === 'string' + ? githubPrHeadBranch.value.trim() + : '', + }) + + const listLocalContextRecords = async () => + workspaceContextController.listLocalContextRecords() + + const applyWorkspaceRecord = async (workspace, { silent = false } = {}) => + workspaceContextController.applyWorkspaceRecord(workspace, { silent }) + + const addWorkspaceTab = kind => workspaceTabMutationsController.addWorkspaceTab(kind) + + const loadPreferredWorkspaceContext = async () => + workspaceContextController.loadPreferredWorkspaceContext() + + const bindWorkspaceMetadataPersistence = element => + workspaceSaveController.bindWorkspaceMetadataPersistence(element) + + return { + workspaceSaveController, + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + queueWorkspaceSave, + flushWorkspaceSave, + setActiveWorkspaceTab, + addWorkspaceTab, + renderWorkspaceTabs, + loadPreferredWorkspaceContext, + bindWorkspaceMetadataPersistence, + } +} + +export { createWorkspaceControllersSetup } diff --git a/src/modules/app-core/workspace-editor-helpers.js b/src/modules/app-core/workspace-editor-helpers.js new file mode 100644 index 0000000..8d897ad --- /dev/null +++ b/src/modules/app-core/workspace-editor-helpers.js @@ -0,0 +1,147 @@ +const createWorkspaceEditorHelpers = ({ + workspaceTabsState, + getTabKind, + editorKinds, + editorPanelsByKind, + editorHeaderLabelByKind, + defaultTabNameByKind, + toNonEmptyWorkspaceText, + getLoadedStylesTabId, + getLoadedComponentTabId, + setLoadedStylesTabId, + setLoadedComponentTabId, + getCssSource, + getJsxSource, + getDirtyStateForTabChange, + setCssSource, + setJsxSource, + styleMode, + toStyleModeForTabLanguage, + getStyleEditorLanguage, + getCssCodeEditor, + setSuppressEditorChangeSideEffects, + editorPool, +}) => { + 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 syncHeaderLabels = () => { + for (const editorKind of editorKinds) { + const tab = + editorKind === 'styles' + ? (workspaceTabsState.getTab(getLoadedStylesTabId()) ?? + getWorkspaceTabByKind('styles')) + : (workspaceTabsState.getTab(getLoadedComponentTabId()) ?? + getWorkspaceTabByKind('component')) + const headerLabel = editorHeaderLabelByKind[editorKind] + + if (headerLabel) { + headerLabel.textContent = + toNonEmptyWorkspaceText(tab?.name) || defaultTabNameByKind[editorKind] + } + } + } + + const persistActiveTabEditorContent = () => { + const activeTab = workspaceTabsState.getTab(workspaceTabsState.getActiveTabId()) + + if (!activeTab) { + return + } + + const nextContent = + getTabKind(activeTab) === 'styles' ? getCssSource() : getJsxSource() + + if (nextContent === activeTab.content) { + return + } + + workspaceTabsState.upsertTab( + { + ...activeTab, + content: nextContent, + isDirty: getDirtyStateForTabChange(activeTab, nextContent), + lastModified: Date.now(), + isActive: true, + }, + { emitReason: 'tabContentSync' }, + ) + } + + 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 loadWorkspaceTabIntoEditor = tab => { + if (!tab || typeof tab !== 'object') { + return + } + + const nextContent = typeof tab.content === 'string' ? tab.content : '' + + if (getTabKind(tab) === 'styles') { + setLoadedStylesTabId(tab.id) + setCssSource(nextContent) + const nextStyleMode = toStyleModeForTabLanguage({ + language: tab.language, + toNonEmptyWorkspaceText, + }) + if (styleMode.value !== nextStyleMode) { + styleMode.value = nextStyleMode + } + const cssCodeEditor = getCssCodeEditor() + if (cssCodeEditor) { + setSuppressEditorChangeSideEffects(true) + try { + cssCodeEditor.setLanguage(getStyleEditorLanguage(nextStyleMode)) + } finally { + setSuppressEditorChangeSideEffects(false) + } + } + setVisibleEditorPanelForKind('styles') + editorPool.activate('styles') + } else { + setLoadedComponentTabId(tab.id) + setJsxSource(nextContent) + setVisibleEditorPanelForKind('component') + editorPool.activate('component') + } + + syncHeaderLabels() + } + + return { + getWorkspaceTabByKind, + syncHeaderLabels, + persistActiveTabEditorContent, + setVisibleEditorPanelForKind, + loadWorkspaceTabIntoEditor, + } +} + +export { createWorkspaceEditorHelpers } diff --git a/src/modules/app-core/workspace-local-helpers.js b/src/modules/app-core/workspace-local-helpers.js new file mode 100644 index 0000000..5767456 --- /dev/null +++ b/src/modules/app-core/workspace-local-helpers.js @@ -0,0 +1,39 @@ +const createWorkspaceContextSnapshotGetter = + ({ + getCurrentSelectedRepository, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + }) => + () => ({ + 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 toStyleModeForTabLanguage = ({ language, toNonEmptyWorkspaceText }) => { + const normalized = toNonEmptyWorkspaceText(language) + + if (normalized === 'less') { + return 'less' + } + + if (normalized === 'sass') { + return 'sass' + } + + if (normalized === 'module') { + return 'module' + } + + return 'css' +} + +export { createWorkspaceContextSnapshotGetter, toStyleModeForTabLanguage } diff --git a/src/modules/app-core/workspace-save-controller.js b/src/modules/app-core/workspace-save-controller.js new file mode 100644 index 0000000..78cebf2 --- /dev/null +++ b/src/modules/app-core/workspace-save-controller.js @@ -0,0 +1,121 @@ +const createWorkspaceSaveController = ({ + createDebouncedWorkspaceSaver, + workspaceStorage, + toNonEmptyWorkspaceText, + buildWorkspaceRecordSnapshot, + refreshLocalContextOptions, + setStatus, + getIsApplyingWorkspaceSnapshot, + getActiveWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt, +}) => { + const workspaceSaver = createDebouncedWorkspaceSaver({ + save: async payload => { + const saved = await workspaceStorage.upsertWorkspace(payload) + + const normalizedSavedRepo = toNonEmptyWorkspaceText(saved.repo) + const normalizedSavedHead = toNonEmptyWorkspaceText(saved.head) + + if (normalizedSavedHead) { + const siblingRecords = normalizedSavedRepo + ? await workspaceStorage.listWorkspaces({ repo: normalizedSavedRepo }) + : await workspaceStorage.listWorkspaces() + + const duplicateRecordIds = siblingRecords + .filter(record => { + if (!record || typeof record !== 'object') { + return false + } + + if ( + toNonEmptyWorkspaceText(record.id) === toNonEmptyWorkspaceText(saved.id) + ) { + return false + } + + return ( + toNonEmptyWorkspaceText(record.repo) === normalizedSavedRepo && + toNonEmptyWorkspaceText(record.head) === normalizedSavedHead + ) + }) + .map(record => toNonEmptyWorkspaceText(record.id)) + .filter(Boolean) + + await Promise.all( + duplicateRecordIds.map(duplicateId => + workspaceStorage.removeWorkspace(duplicateId), + ), + ) + } + + const supersededId = toNonEmptyWorkspaceText(payload?.supersededId) + if (supersededId && supersededId !== toNonEmptyWorkspaceText(saved.id)) { + await workspaceStorage.removeWorkspace(supersededId) + } + + setActiveWorkspaceRecordId(saved.id) + setActiveWorkspaceCreatedAt(saved.createdAt ?? getActiveWorkspaceCreatedAt()) + 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 (getIsApplyingWorkspaceSnapshot()) { + return + } + + const snapshot = buildWorkspaceRecordSnapshot() + setActiveWorkspaceRecordId(snapshot.id) + workspaceSaver.queue(snapshot) + } + + const flushWorkspaceSave = async () => { + if (getIsApplyingWorkspaceSnapshot()) { + return + } + + const snapshot = buildWorkspaceRecordSnapshot() + setActiveWorkspaceRecordId(snapshot.id) + await workspaceSaver.flushNow(snapshot) + } + + const bindWorkspaceMetadataPersistence = element => { + if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) { + return + } + + const queue = () => { + queueWorkspaceSave() + } + + const flush = () => { + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + + element.addEventListener('input', queue) + element.addEventListener('change', queue) + element.addEventListener('blur', flush) + } + + const dispose = () => { + workspaceSaver?.dispose() + } + + return { + bindWorkspaceMetadataPersistence, + dispose, + flushWorkspaceSave, + queueWorkspaceSave, + } +} + +export { createWorkspaceSaveController } diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js new file mode 100644 index 0000000..3eb8505 --- /dev/null +++ b/src/modules/app-core/workspace-sync-controller.js @@ -0,0 +1,258 @@ +const createWorkspaceSyncController = ({ + workspaceTabsState, + getTabKind, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncedContent, + toWorkspaceSyncSha, + toNonEmptyWorkspaceText, + hasTabCommittedSyncState, + getJsxSource, + getCssSource, + getWorkspaceTabByKind, + queueWorkspaceSave, + resolveWorkspaceRecordIdentity, + getWorkspaceContextSnapshot, + getActiveWorkspaceRecordId, + getActiveWorkspaceCreatedAt, + getRenderModeValue, + normalizeRenderMode, +}) => { + const buildWorkspaceTabsSnapshot = () => { + const activeTabId = workspaceTabsState.getActiveTabId() + return workspaceTabsState.getTabs().map(tab => { + const currentPath = toNonEmptyWorkspaceText(tab.path) + + const currentContent = + tab.id === activeTabId + ? getTabKind(tab) === 'styles' + ? getCssSource() + : getJsxSource() + : typeof tab.content === 'string' + ? tab.content + : '' + + const targetPrFilePath = + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(currentPath) || null + + return { + ...tab, + path: currentPath, + content: currentContent, + syncedContent: toWorkspaceSyncedContent(tab?.syncedContent), + targetPrFilePath, + isActive: activeTabId === tab.id, + lastModified: Date.now(), + } + }) + } + + const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { + const updates = Array.isArray(fileUpdates) ? fileUpdates : [] + if (updates.length === 0) { + return 0 + } + + const updatesByPath = new Map() + for (const update of updates) { + const normalizedPath = normalizeWorkspacePathValue(update?.path) + if (!normalizedPath) { + continue + } + + updatesByPath.set(normalizedPath, toWorkspaceSyncSha(update?.commitSha)) + } + + if (updatesByPath.size === 0) { + return 0 + } + + const now = Date.now() + let updatedTabCount = 0 + const activeTabId = workspaceTabsState.getActiveTabId() + const nextTabs = workspaceTabsState.getTabs().map(tab => { + const candidatePaths = [ + getTabTargetPrFilePath(tab), + normalizeWorkspacePathValue(tab.path), + ].filter(Boolean) + + const matchedPath = candidatePaths.find(path => updatesByPath.has(path)) + if (!matchedPath) { + return tab + } + + updatedTabCount += 1 + const commitSha = updatesByPath.get(matchedPath) + + return { + ...tab, + targetPrFilePath: matchedPath, + syncedContent: typeof tab?.content === 'string' ? tab.content : '', + isDirty: false, + syncedAt: now, + lastSyncedRemoteSha: commitSha || toWorkspaceSyncSha(tab.lastSyncedRemoteSha), + lastModified: now, + } + }) + + if (updatedTabCount > 0) { + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId, + }) + queueWorkspaceSave() + } + + return updatedTabCount + } + + const getWorkspacePrFileCommits = () => { + const snapshotTabs = buildWorkspaceTabsSnapshot() + const dedupedByPath = new Map() + + for (const tab of snapshotTabs) { + const shouldCommitTab = Boolean(tab?.isDirty) || !hasTabCommittedSyncState(tab) + if (!shouldCommitTab) { + continue + } + + const path = + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' + if (!path) { + continue + } + + dedupedByPath.set(path, { + path, + content: typeof tab?.content === 'string' ? tab.content : '', + tabLabel: toNonEmptyWorkspaceText(tab?.name) || toNonEmptyWorkspaceText(tab?.id), + isEntry: tab?.role === 'entry', + }) + } + + return [...dedupedByPath.values()] + } + + const getEditorSyncTargets = () => { + const tabTargets = [] + + for (const kind of ['component', 'styles']) { + const tab = getWorkspaceTabByKind(kind) + const path = + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' + + if (!path) { + continue + } + + tabTargets.push({ kind, path }) + } + + return { tabTargets } + } + + const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => { + const targetsByKind = new Map() + const normalizedTargets = Array.isArray(tabTargets) ? tabTargets : [] + + for (const target of normalizedTargets) { + const kind = toNonEmptyWorkspaceText(target?.kind) + const normalizedPath = normalizeWorkspacePathValue(target?.path) + if (!kind || !normalizedPath) { + continue + } + + targetsByKind.set(kind, normalizedPath) + } + + if (targetsByKind.size === 0) { + return 0 + } + + const now = Date.now() + let updatedTabCount = 0 + const activeTabId = workspaceTabsState.getActiveTabId() + const componentSource = getJsxSource() + const stylesSource = getCssSource() + + const nextTabs = workspaceTabsState.getTabs().map(tab => { + const tabKind = getTabKind(tab) + const expectedPath = targetsByKind.get(tabKind) + if (!expectedPath) { + return tab + } + + const candidatePaths = [ + getTabTargetPrFilePath(tab), + normalizeWorkspacePathValue(tab.path), + ].filter(Boolean) + const matchedPath = candidatePaths.find(path => path === expectedPath) + if (!matchedPath) { + return tab + } + + const syncedContent = tabKind === 'styles' ? stylesSource : componentSource + updatedTabCount += 1 + return { + ...tab, + targetPrFilePath: matchedPath, + content: syncedContent, + syncedContent, + isDirty: false, + syncedAt: now, + lastModified: now, + } + }) + + if (updatedTabCount > 0) { + workspaceTabsState.replaceTabs({ + tabs: nextTabs, + activeTabId, + }) + queueWorkspaceSave() + } + + return updatedTabCount + } + + const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => { + const context = getWorkspaceContextSnapshot() + const identity = + typeof recordId === 'string' && recordId.length > 0 + ? { + id: recordId, + supersededId: '', + } + : resolveWorkspaceRecordIdentity({ + repositoryFullName: context.repositoryFullName, + headBranch: context.headBranch, + activeRecordId: getActiveWorkspaceRecordId(), + }) + + return { + id: identity.id, + supersededId: identity.supersededId, + repo: context.repositoryFullName || '', + base: context.baseBranch || '', + head: context.headBranch || '', + prNumber: null, + prTitle: context.prTitle || '', + renderMode: normalizeRenderMode(getRenderModeValue()), + tabs: buildWorkspaceTabsSnapshot(), + activeTabId: workspaceTabsState.getActiveTabId(), + createdAt: getActiveWorkspaceCreatedAt() ?? Date.now(), + lastModified: Date.now(), + } + } + + return { + buildWorkspaceRecordSnapshot, + buildWorkspaceTabsSnapshot, + getEditorSyncTargets, + getWorkspacePrFileCommits, + reconcileWorkspaceTabsWithEditorSync, + reconcileWorkspaceTabsWithPushUpdates, + } +} + +export { createWorkspaceSyncController } diff --git a/src/modules/app-core/workspace-tab-add-menu-ui.js b/src/modules/app-core/workspace-tab-add-menu-ui.js new file mode 100644 index 0000000..e1d8e7b --- /dev/null +++ b/src/modules/app-core/workspace-tab-add-menu-ui.js @@ -0,0 +1,85 @@ +export const createWorkspaceTabAddMenuUiController = ({ + addButton, + addMenu, + addModuleButton, +}) => { + let open = false + + const setOpen = isOpen => { + const nextOpen = Boolean(isOpen) + if (open === nextOpen) { + return + } + + open = nextOpen + if (addButton instanceof HTMLButtonElement) { + addButton.setAttribute('aria-expanded', nextOpen ? 'true' : 'false') + } + + if (addMenu instanceof HTMLElement) { + addMenu.hidden = !nextOpen + } + + if ( + nextOpen && + document.activeElement === addButton && + addModuleButton instanceof HTMLButtonElement + ) { + addModuleButton.focus() + } + + if ( + !nextOpen && + addMenu instanceof HTMLElement && + document.activeElement instanceof Node && + addMenu.contains(document.activeElement) && + addButton instanceof HTMLButtonElement + ) { + addButton.focus() + } + } + + const toggle = () => { + setOpen(!open) + } + + const handleDocumentPointerdown = target => { + if (!open) { + return + } + + if (target instanceof Element && target.closest('#workspace-tab-add-wrap')) { + return + } + + setOpen(false) + } + + const handleEscape = event => { + if (!open || event.key !== 'Escape') { + return + } + + event.preventDefault() + setOpen(false) + } + + const handleAddButtonKeydown = event => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(true) + if (addModuleButton instanceof HTMLButtonElement) { + addModuleButton.focus() + } + } + } + + return { + handleAddButtonKeydown, + handleDocumentPointerdown, + handleEscape, + isOpen: () => open, + setOpen, + toggle, + } +} diff --git a/src/modules/app-core/workspace-tab-mutations-controller.js b/src/modules/app-core/workspace-tab-mutations-controller.js new file mode 100644 index 0000000..961b7f0 --- /dev/null +++ b/src/modules/app-core/workspace-tab-mutations-controller.js @@ -0,0 +1,197 @@ +const createWorkspaceTabMutationsController = ({ + toNonEmptyWorkspaceText, + workspaceTabsState, + setWorkspaceTabRenameState, + renderWorkspaceTabs, + setStatus, + allowedEntryTabFileNames, + getPathFileName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + defaultComponentTabName, + getDirtyStateForTabChange, + syncHeaderLabels, + queueWorkspaceSave, + maybeRender, + setWorkspaceTabAddMenuOpen, + confirmAction, + getTabKind, + persistActiveTabEditorContent, + getLoadedComponentTabId, + setLoadedComponentTabId, + getLoadedStylesTabId, + setLoadedStylesTabId, + getActiveWorkspaceTab, + loadWorkspaceTabIntoEditor, + getWorkspaceTabByKind, + setActiveWorkspaceTab, + makeUniqueTabPath, + createWorkspaceTabId, +}) => { + const beginWorkspaceTabRename = tabId => { + setWorkspaceTabAddMenuOpen(false) + setWorkspaceTabRenameState({ + tabId: toNonEmptyWorkspaceText(tabId), + }) + renderWorkspaceTabs() + } + + const finishWorkspaceTabRename = ({ tabId, nextName, cancelled = false }) => { + const normalizedTabId = toNonEmptyWorkspaceText(tabId) + const tab = workspaceTabsState.getTab(normalizedTabId) + + setWorkspaceTabRenameState({ 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, + isDirty: getDirtyStateForTabChange( + tab, + typeof tab?.content === 'string' ? tab.content : '', + ), + lastModified: Date.now(), + }) + + syncHeaderLabels() + renderWorkspaceTabs() + queueWorkspaceSave() + maybeRender() + } + + 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 (getLoadedComponentTabId() === tab.id) { + setLoadedComponentTabId( + workspaceTabsState.getTabs().find(entry => getTabKind(entry) === 'component') + ?.id || 'component', + ) + } + + if (getLoadedStylesTabId() === tab.id) { + setLoadedStylesTabId( + 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') + } + } + + return { + addWorkspaceTab, + beginWorkspaceTabRename, + finishWorkspaceTabRename, + removeWorkspaceTab, + } +} + +export { createWorkspaceTabMutationsController } diff --git a/src/modules/app-core/workspace-tab-selection-controller.js b/src/modules/app-core/workspace-tab-selection-controller.js new file mode 100644 index 0000000..1a7bcbf --- /dev/null +++ b/src/modules/app-core/workspace-tab-selection-controller.js @@ -0,0 +1,65 @@ +const createWorkspaceTabSelectionController = ({ + toNonEmptyWorkspaceText, + workspaceTabsState, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs, + updateRenderModeEditability, + persistActiveTabEditorContent, + getActiveWorkspaceTab, + flushWorkspaceSave, +}) => { + 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() + updateRenderModeEditability() + return + } + + persistActiveTabEditorContent() + + const changed = workspaceTabsState.setActiveTab(targetTab.id) + const activeTab = getActiveWorkspaceTab() + if (activeTab) { + loadWorkspaceTabIntoEditor(activeTab) + } + + renderWorkspaceTabs() + updateRenderModeEditability() + + if (!changed) { + return + } + + void flushWorkspaceSave().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + } + + const syncEditorFromActiveWorkspaceTab = () => { + const activeTab = getActiveWorkspaceTab() + if (!activeTab) { + return + } + + loadWorkspaceTabIntoEditor(activeTab) + } + + return { + setActiveWorkspaceTab, + syncEditorFromActiveWorkspaceTab, + } +} + +export { createWorkspaceTabSelectionController } diff --git a/src/modules/app-core/workspace-tabs-renderer.js b/src/modules/app-core/workspace-tabs-renderer.js new file mode 100644 index 0000000..7664d77 --- /dev/null +++ b/src/modules/app-core/workspace-tabs-renderer.js @@ -0,0 +1,297 @@ +const createWorkspaceTabsRenderer = ({ + workspaceTabsStrip, + workspaceTabsState, + getWorkspaceTabRenameState, + getDraggedWorkspaceTabId, + setDraggedWorkspaceTabId, + getDragOverWorkspaceTabId, + setDragOverWorkspaceTabId, + getSuppressWorkspaceTabClick, + setSuppressWorkspaceTabClick, + getIsRenderingWorkspaceTabs, + setIsRenderingWorkspaceTabs, + getHasPendingWorkspaceTabsRender, + setHasPendingWorkspaceTabsRender, + setActiveWorkspaceTab, + persistActiveTabEditorContent, + queueWorkspaceSave, + beginWorkspaceTabRename, + finishWorkspaceTabRename, + removeWorkspaceTab, + getWorkspaceTabDisplay, + workspaceTabsShell, + workspaceTabAddWrap, + syncEditorFromActiveWorkspaceTab, +}) => { + const clearWorkspaceTabDragState = () => { + setDraggedWorkspaceTabId('') + setDragOverWorkspaceTabId('') + } + + const renderWorkspaceTabs = () => { + if (!(workspaceTabsStrip instanceof HTMLElement)) { + return + } + + if (getIsRenderingWorkspaceTabs()) { + setHasPendingWorkspaceTabsRender(true) + return + } + + setIsRenderingWorkspaceTabs(true) + + try { + const tabs = workspaceTabsState.getTabs() + const activeTabId = workspaceTabsState.getActiveTabId() + + workspaceTabsStrip.replaceChildren() + + for (const tab of tabs) { + const isActive = tab.id === activeTabId + const isRenaming = getWorkspaceTabRenameState().tabId === tab.id + const tabContainer = document.createElement('li') + tabContainer.className = 'workspace-tab' + tabContainer.dataset.active = isActive ? 'true' : 'false' + tabContainer.dataset.tabId = tab.id + tabContainer.setAttribute('aria-label', `Workspace tab ${tab.name}`) + tabContainer.draggable = !isRenaming + tabContainer.dataset.dragOver = + getDragOverWorkspaceTabId() && getDragOverWorkspaceTabId() === tab.id + ? 'true' + : 'false' + tabContainer.addEventListener('click', event => { + if (getSuppressWorkspaceTabClick()) { + setSuppressWorkspaceTabClick(false) + return + } + + const clickTarget = event.target + if (!(clickTarget instanceof Element)) { + return + } + + if ( + clickTarget.closest('.workspace-tab__rename, .workspace-tab__remove, input') + ) { + return + } + + setActiveWorkspaceTab(tab.id) + }) + if (!isRenaming) { + tabContainer.addEventListener('dragstart', event => { + setDraggedWorkspaceTabId(tab.id) + setDragOverWorkspaceTabId('') + setSuppressWorkspaceTabClick(true) + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', tab.id) + } + }) + tabContainer.addEventListener('dragend', () => { + clearWorkspaceTabDragState() + queueMicrotask(() => { + setSuppressWorkspaceTabClick(false) + }) + renderWorkspaceTabs() + }) + tabContainer.addEventListener('dragover', event => { + if (!getDraggedWorkspaceTabId() || getDraggedWorkspaceTabId() === tab.id) { + return + } + + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move' + } + + if (getDragOverWorkspaceTabId() !== tab.id) { + setDragOverWorkspaceTabId(tab.id) + tabContainer.dataset.dragOver = 'true' + } + }) + tabContainer.addEventListener('dragleave', event => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && tabContainer.contains(relatedTarget)) { + return + } + + if (getDragOverWorkspaceTabId() === tab.id) { + setDragOverWorkspaceTabId('') + tabContainer.dataset.dragOver = 'false' + } + }) + tabContainer.addEventListener('drop', event => { + event.preventDefault() + + if (!getDraggedWorkspaceTabId() || getDraggedWorkspaceTabId() === tab.id) { + clearWorkspaceTabDragState() + renderWorkspaceTabs() + return + } + + persistActiveTabEditorContent() + + const moved = workspaceTabsState.moveTabBefore( + getDraggedWorkspaceTabId(), + tab.id, + ) + clearWorkspaceTabDragState() + renderWorkspaceTabs() + + if (!moved) { + return + } + + queueWorkspaceSave() + }) + } + + 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 + } + + const fileNameNode = document.createElement('span') + fileNameNode.className = 'workspace-tab__path-file' + fileNameNode.textContent = tabDisplay.fileName || tab.name + selectButton.append(fileNameNode) + + if (isActive) { + selectButton.setAttribute('aria-current', 'true') + } else { + selectButton.removeAttribute('aria-current') + } + 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) + + if (tab.role === 'entry') { + const metaBadge = document.createElement('span') + metaBadge.className = 'workspace-tab__meta' + metaBadge.textContent = 'Entry' + tabContainer.append(metaBadge) + } + + if (tab.isDirty) { + const dirtyBadge = document.createElement('span') + dirtyBadge.className = 'workspace-tab__meta workspace-tab__meta--dirty' + dirtyBadge.textContent = 'Dirty' + tabContainer.append(dirtyBadge) + } + + const renameButton = document.createElement('button') + renameButton.className = 'workspace-tab__rename' + renameButton.type = 'button' + 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) + } + + if ( + workspaceTabAddWrap instanceof HTMLElement && + workspaceTabsShell instanceof HTMLElement + ) { + workspaceTabsShell.append(workspaceTabAddWrap) + } + } finally { + setIsRenderingWorkspaceTabs(false) + } + + if (getHasPendingWorkspaceTabsRender()) { + setHasPendingWorkspaceTabsRender(false) + renderWorkspaceTabs() + return + } + + syncEditorFromActiveWorkspaceTab() + } + + return { + clearWorkspaceTabDragState, + renderWorkspaceTabs, + } +} + +export { createWorkspaceTabsRenderer } diff --git a/src/modules/diagnostics-ui.js b/src/modules/diagnostics/diagnostics-ui.js similarity index 100% rename from src/modules/diagnostics-ui.js rename to src/modules/diagnostics/diagnostics-ui.js diff --git a/src/modules/lint-diagnostics.js b/src/modules/diagnostics/lint-diagnostics.js similarity index 100% rename from src/modules/lint-diagnostics.js rename to src/modules/diagnostics/lint-diagnostics.js diff --git a/src/modules/type-diagnostics.js b/src/modules/diagnostics/type-diagnostics.js similarity index 100% rename from src/modules/type-diagnostics.js rename to src/modules/diagnostics/type-diagnostics.js diff --git a/src/modules/editor-codemirror.js b/src/modules/editor/editor-codemirror.js similarity index 99% rename from src/modules/editor-codemirror.js rename to src/modules/editor/editor-codemirror.js index 61032a7..60731a5 100644 --- a/src/modules/editor-codemirror.js +++ b/src/modules/editor/editor-codemirror.js @@ -1,4 +1,4 @@ -import { cdnImports, importFromCdnWithFallback } from './cdn.js' +import { cdnImports, importFromCdnWithFallback } from '../cdn.js' let codeMirrorRuntime = null let codeMirrorRuntimePromise = null diff --git a/src/modules/editor-pool-manager.js b/src/modules/editor/editor-pool-manager.js similarity index 100% rename from src/modules/editor-pool-manager.js rename to src/modules/editor/editor-pool-manager.js diff --git a/src/modules/github-chat-drawer/chat-utils.js b/src/modules/github/chat-drawer/chat-utils.js similarity index 100% rename from src/modules/github-chat-drawer/chat-utils.js rename to src/modules/github/chat-drawer/chat-utils.js diff --git a/src/modules/github-chat-drawer/drawer.js b/src/modules/github/chat-drawer/drawer.js similarity index 100% rename from src/modules/github-chat-drawer/drawer.js rename to src/modules/github/chat-drawer/drawer.js diff --git a/src/modules/github-chat-drawer/payload.js b/src/modules/github/chat-drawer/payload.js similarity index 100% rename from src/modules/github-chat-drawer/payload.js rename to src/modules/github/chat-drawer/payload.js diff --git a/src/modules/github-chat-drawer/proposals.js b/src/modules/github/chat-drawer/proposals.js similarity index 100% rename from src/modules/github-chat-drawer/proposals.js rename to src/modules/github/chat-drawer/proposals.js diff --git a/src/modules/github-api.js b/src/modules/github/github-api.js similarity index 98% rename from src/modules/github-api.js rename to src/modules/github/github-api.js index 98c4159..95ebf40 100644 --- a/src/modules/github-api.js +++ b/src/modules/github/github-api.js @@ -1470,10 +1470,6 @@ export const createEditorContentPullRequest = async ({ prTitle, prBody, fileUpdates, - componentFilePath, - componentSource, - stylesFilePath, - stylesSource, commitMessage, signal, }) => { @@ -1506,10 +1502,6 @@ export const createEditorContentPullRequest = async ({ repository, branch: nextBranch, fileUpdates, - componentFilePath, - componentSource, - stylesFilePath, - stylesSource, commitMessage, signal, }) @@ -1537,10 +1529,6 @@ export const commitEditorContentToExistingBranch = async ({ repository, branch, fileUpdates, - componentFilePath, - componentSource, - stylesFilePath, - stylesSource, commitMessage, signal, }) => { @@ -1555,26 +1543,16 @@ export const commitEditorContentToExistingBranch = async ({ throw new Error('An existing head branch is required.') } - const files = - Array.isArray(fileUpdates) && fileUpdates.length > 0 - ? fileUpdates - : [ - { - path: componentFilePath, - content: componentSource, - }, - { - path: stylesFilePath, - content: stylesSource, - }, - ] + if (!Array.isArray(fileUpdates) || fileUpdates.length === 0) { + throw new Error('At least one file update is required.') + } return commitFilesToExistingBranchWithGitDatabaseApi({ token, owner, repo, branch, - files, + files: fileUpdates, commitMessage, signal, }) diff --git a/src/modules/github-byot-controls.js b/src/modules/github/github-byot-controls.js similarity index 100% rename from src/modules/github-byot-controls.js rename to src/modules/github/github-byot-controls.js diff --git a/src/modules/github-pr-context.js b/src/modules/github/github-pr-context.js similarity index 81% rename from src/modules/github-pr-context.js rename to src/modules/github/github-pr-context.js index b703b64..ad2df53 100644 --- a/src/modules/github-pr-context.js +++ b/src/modules/github/github-pr-context.js @@ -40,8 +40,6 @@ export const formatActivePrReference = activeContext => { export const getActivePrContextSyncKey = activeContext => { const repositoryFullName = toSafeText(activeContext?.repositoryFullName) const headBranch = toSafeText(activeContext?.headBranch) - const componentFilePath = toSafeText(activeContext?.componentFilePath) - const stylesFilePath = toSafeText(activeContext?.stylesFilePath) const pullRequestNumber = typeof activeContext?.pullRequestNumber === 'number' && Number.isFinite(activeContext.pullRequestNumber) @@ -49,16 +47,9 @@ export const getActivePrContextSyncKey = activeContext => { : '' const pullRequestUrl = toSafeText(activeContext?.pullRequestUrl) - if (!repositoryFullName || !headBranch || !componentFilePath || !stylesFilePath) { + if (!repositoryFullName || !headBranch) { return '' } - return [ - repositoryFullName, - headBranch, - componentFilePath, - stylesFilePath, - pullRequestNumber, - pullRequestUrl, - ].join('|') + return [repositoryFullName, headBranch, pullRequestNumber, pullRequestUrl].join('|') } diff --git a/src/modules/github-pr-drawer.js b/src/modules/github/github-pr-drawer.js similarity index 96% rename from src/modules/github-pr-drawer.js rename to src/modules/github/github-pr-drawer.js index 425f6ac..6d93955 100644 --- a/src/modules/github-pr-drawer.js +++ b/src/modules/github/github-pr-drawer.js @@ -13,7 +13,7 @@ import { import { isFunctionLikeDeclaration, isFunctionLikeVariableInitializer, -} from './jsx-top-level-declarations.js' +} from '../preview/jsx-top-level-declarations.js' const prConfigStoragePrefix = 'knighted:develop:github-pr-config:' @@ -24,6 +24,14 @@ const supportedStyleModes = new Set(['css', 'module', 'less', 'sass']) const toSafeText = value => (typeof value === 'string' ? value.trim() : '') +const toPullRequestNumber = value => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + + return null +} + const normalizeRenderMode = value => { const mode = toSafeText(value).toLowerCase() return supportedRenderModes.has(mode) ? mode : 'dom' @@ -102,6 +110,26 @@ const saveRepositoryPrConfig = ({ repositoryFullName, config }) => { } } +const sanitizeRepositoryPrConfig = config => { + const source = config && typeof config === 'object' ? config : {} + const pullRequestUrl = toSafeText(source.pullRequestUrl) + const fallbackPullRequestNumber = parsePullRequestNumberFromUrl(pullRequestUrl) + const pullRequestNumber = + toPullRequestNumber(source.pullRequestNumber) ?? fallbackPullRequestNumber + + return { + baseBranch: toSafeText(source.baseBranch), + headBranch: sanitizeBranchPart(source.headBranch), + prTitle: toSafeText(source.prTitle), + prBody: typeof source.prBody === 'string' ? source.prBody.trim() : '', + renderMode: normalizeRenderMode(source.renderMode), + styleMode: normalizeStyleMode(source.styleMode), + isActivePr: source.isActivePr === true, + pullRequestNumber, + pullRequestUrl, + } +} + const removeRepositoryPrConfig = repositoryFullName => { if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { return @@ -124,8 +152,6 @@ const getActiveRepositoryPrContext = repositoryFullName => { const headBranch = sanitizeBranchPart(savedConfig.headBranch) const prTitle = toSafeText(savedConfig.prTitle) const baseBranch = toSafeText(savedConfig.baseBranch) - const componentFilePath = validateFilePath(savedConfig.syncComponentFilePath) - const stylesFilePath = validateFilePath(savedConfig.syncStylesFilePath) if (!headBranch || !prTitle) { return null @@ -133,8 +159,6 @@ const getActiveRepositoryPrContext = repositoryFullName => { return { headBranch, - componentFilePath: componentFilePath.ok ? componentFilePath.value : '', - stylesFilePath: stylesFilePath.ok ? stylesFilePath.value : '', renderMode: normalizeRenderMode(savedConfig.renderMode), styleMode: normalizeStyleMode(savedConfig.styleMode), prTitle, @@ -707,11 +731,15 @@ export const createGitHubPrDrawer = ({ const syncTargets = typeof getEditorSyncTargets === 'function' ? getEditorSyncTargets() : null - const componentSyncPath = - toSafeText(activeContext?.componentFilePath) || - toSafeText(syncTargets?.componentFilePath) - const stylesSyncPath = - toSafeText(activeContext?.stylesFilePath) || toSafeText(syncTargets?.stylesFilePath) + const tabSyncTargets = Array.isArray(syncTargets?.tabTargets) + ? syncTargets.tabTargets + : [] + const componentSyncPath = toSafeText( + tabSyncTargets.find(target => toSafeText(target?.kind) === 'component')?.path, + ) + const stylesSyncPath = toSafeText( + tabSyncTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, + ) if (!componentSyncPath || !stylesSyncPath) { lastActiveContentSyncKey = '' @@ -739,10 +767,12 @@ export const createGitHubPrDrawer = ({ await onSyncActivePrEditorContent({ token, repository, - activeContext: { - ...activeContext, - componentFilePath: componentSyncPath, - stylesFilePath: stylesSyncPath, + activeContext, + syncTargets: { + tabTargets: [ + { kind: 'component', path: componentSyncPath }, + { kind: 'styles', path: stylesSyncPath }, + ], }, signal: abortController.signal, }) @@ -844,6 +874,7 @@ export const createGitHubPrDrawer = ({ } if (resolvedPullRequest?.isOpen) { + const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) const nextHeadBranch = sanitizeBranchPart(resolvedPullRequest.headRef) || headBranch const nextBaseBranch = @@ -852,10 +883,8 @@ export const createGitHubPrDrawer = ({ saveRepositoryPrConfig({ repositoryFullName, config: { - ...savedConfig, + ...normalizedSavedConfig, isActivePr: true, - renderMode: normalizeRenderMode(savedConfig.renderMode), - styleMode: normalizeStyleMode(savedConfig.styleMode), headBranch: nextHeadBranch, baseBranch: nextBaseBranch, pullRequestNumber: resolvedPullRequest.number, @@ -874,7 +903,7 @@ export const createGitHubPrDrawer = ({ saveRepositoryPrConfig({ repositoryFullName, config: { - ...savedConfig, + ...sanitizeRepositoryPrConfig(savedConfig), isActivePr: false, }, }) @@ -1153,18 +1182,15 @@ export const createGitHubPrDrawer = ({ const values = getFormValues() const currentRenderMode = normalizeRenderMode(getRenderMode?.()) const currentStyleMode = normalizeStyleMode(getStyleMode?.()) - const syncTargets = - typeof getEditorSyncTargets === 'function' ? getEditorSyncTargets() : null const existingConfig = readRepositoryPrConfig(repositoryFullName) + const normalizedExistingConfig = sanitizeRepositoryPrConfig(existingConfig) const isActivePr = existingConfig?.isActivePr === true if (isActivePr) { saveRepositoryPrConfig({ repositoryFullName, config: { - ...existingConfig, - syncComponentFilePath: toSafeText(syncTargets?.componentFilePath), - syncStylesFilePath: toSafeText(syncTargets?.stylesFilePath), + ...normalizedExistingConfig, renderMode: currentRenderMode, styleMode: currentStyleMode, isActivePr: true, @@ -1185,8 +1211,6 @@ export const createGitHubPrDrawer = ({ headBranch: isAutoGeneratedHeadBranch(values.headBranch) ? '' : values.headBranch, prTitle: values.prTitle, prBody: values.prBody, - syncComponentFilePath: toSafeText(syncTargets?.componentFilePath), - syncStylesFilePath: toSafeText(syncTargets?.stylesFilePath), renderMode: currentRenderMode, styleMode: currentStyleMode, isActivePr: false, @@ -1448,10 +1472,6 @@ export const createGitHubPrDrawer = ({ saveRepositoryPrConfig({ repositoryFullName: repositoryLabel, config: { - syncComponentFilePath: toSafeText( - getEditorSyncTargets?.()?.componentFilePath, - ), - syncStylesFilePath: toSafeText(getEditorSyncTargets?.()?.stylesFilePath), renderMode: currentRenderMode, styleMode: currentStyleMode, baseBranch: targetBaseBranch, @@ -1575,6 +1595,7 @@ export const createGitHubPrDrawer = ({ } const savedConfig = readRepositoryPrConfig(repositoryFullName) + const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) const previousActiveContext = savedConfig?.isActivePr === true ? { @@ -1591,7 +1612,7 @@ export const createGitHubPrDrawer = ({ saveRepositoryPrConfig({ repositoryFullName, config: { - ...savedConfig, + ...normalizedSavedConfig, isActivePr: false, }, }) diff --git a/src/modules/github-pr-editor-sync.js b/src/modules/github/github-pr-editor-sync.js similarity index 74% rename from src/modules/github-pr-editor-sync.js rename to src/modules/github/github-pr-editor-sync.js index 1917395..b9e3358 100644 --- a/src/modules/github-pr-editor-sync.js +++ b/src/modules/github/github-pr-editor-sync.js @@ -12,14 +12,27 @@ export const createGitHubPrEditorSyncController = ({ const setStyles = typeof setStylesSource === 'function' ? setStylesSource : () => {} const schedule = typeof scheduleRender === 'function' ? scheduleRender : () => {} - const syncFromActiveContext = async ({ token, repository, activeContext, signal }) => { + const syncFromActiveContext = async ({ + token, + repository, + activeContext, + syncTargets, + signal, + }) => { const owner = toSafeText(repository?.owner) const repo = toSafeText(repository?.name) const branch = toSafeText(activeContext?.headBranch) - const componentFilePath = toSafeText(activeContext?.componentFilePath) - const stylesFilePath = toSafeText(activeContext?.stylesFilePath) + const tabTargets = Array.isArray(syncTargets?.tabTargets) + ? syncTargets.tabTargets + : [] + const componentTabPath = toSafeText( + tabTargets.find(target => toSafeText(target?.kind) === 'component')?.path, + ) + const stylesTabPath = toSafeText( + tabTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, + ) - if (!token || !owner || !repo || !branch || !componentFilePath || !stylesFilePath) { + if (!token || !owner || !repo || !branch || !componentTabPath || !stylesTabPath) { return { synced: false, componentSynced: false, @@ -31,19 +44,19 @@ export const createGitHubPrEditorSyncController = ({ token, owner, repo, - path: componentFilePath, + path: componentTabPath, ref: branch, signal, }) const stylesRequest = - stylesFilePath === componentFilePath + stylesTabPath === componentTabPath ? componentRequest : getRepositoryFileContent({ token, owner, repo, - path: stylesFilePath, + path: stylesTabPath, ref: branch, signal, }) @@ -77,7 +90,7 @@ export const createGitHubPrEditorSyncController = ({ stylesSynced = true } - if (stylesFilePath === componentFilePath) { + if (stylesTabPath === componentTabPath) { stylesSynced = componentSynced } diff --git a/src/modules/github-token-store.js b/src/modules/github/github-token-store.js similarity index 100% rename from src/modules/github-token-store.js rename to src/modules/github/github-token-store.js diff --git a/src/modules/jsx-top-level-declarations.js b/src/modules/preview/jsx-top-level-declarations.js similarity index 100% rename from src/modules/jsx-top-level-declarations.js rename to src/modules/preview/jsx-top-level-declarations.js diff --git a/src/modules/jsx-transform-runtime.js b/src/modules/preview/jsx-transform-runtime.js similarity index 100% rename from src/modules/jsx-transform-runtime.js rename to src/modules/preview/jsx-transform-runtime.js diff --git a/src/modules/preview-background.js b/src/modules/preview/preview-background.js similarity index 100% rename from src/modules/preview-background.js rename to src/modules/preview/preview-background.js diff --git a/src/modules/preview-entry-resolver.js b/src/modules/preview/preview-entry-resolver.js similarity index 100% rename from src/modules/preview-entry-resolver.js rename to src/modules/preview/preview-entry-resolver.js diff --git a/src/modules/preview-workspace-graph.js b/src/modules/preview/preview-workspace-graph.js similarity index 100% rename from src/modules/preview-workspace-graph.js rename to src/modules/preview/preview-workspace-graph.js diff --git a/src/modules/render-runtime.js b/src/modules/preview/render-runtime.js similarity index 99% rename from src/modules/render-runtime.js rename to src/modules/preview/render-runtime.js index 2c5972a..33dfcd3 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/preview/render-runtime.js @@ -4,11 +4,11 @@ import { hasFunctionLikeDeclarationNamed, } from './jsx-top-level-declarations.js' import { canRenderPreview, resolvePreviewEntryTab } from './preview-entry-resolver.js' -import { createWorkspaceIframePreviewBridge } from './preview-runtime/iframe-preview-executor.js' -import { planWorkspaceVirtualModules } from './preview-runtime/virtual-workspace-modules.js' +import { createWorkspaceIframePreviewBridge } from '../preview-runtime/iframe-preview-executor.js' +import { planWorkspaceVirtualModules } from '../preview-runtime/virtual-workspace-modules.js' import { createPreviewWorkspaceGraphCache } from './preview-workspace-graph.js' import { ensureJsxTransformSource } from './jsx-transform-runtime.js' -import { getCdnImportUrl } from './cdn.js' +import { getCdnImportUrl } from '../cdn.js' export const createRenderRuntimeController = ({ cdnImports, diff --git a/src/modules/layout-theme.js b/src/modules/ui/layout-theme.js similarity index 100% rename from src/modules/layout-theme.js rename to src/modules/ui/layout-theme.js diff --git a/src/modules/workspace-storage.js b/src/modules/workspace/workspace-storage.js similarity index 99% rename from src/modules/workspace-storage.js rename to src/modules/workspace/workspace-storage.js index 634eeba..9804147 100644 --- a/src/modules/workspace-storage.js +++ b/src/modules/workspace/workspace-storage.js @@ -1,4 +1,4 @@ -import { cdnImports, importFromCdnWithFallback } from './cdn.js' +import { cdnImports, importFromCdnWithFallback } from '../cdn.js' const workspaceDbName = 'knighted-develop-workspaces' const workspaceDbVersion = 1 diff --git a/src/modules/workspace/workspace-tab-factory.js b/src/modules/workspace/workspace-tab-factory.js new file mode 100644 index 0000000..4206a64 --- /dev/null +++ b/src/modules/workspace/workspace-tab-factory.js @@ -0,0 +1,29 @@ +const createWorkspaceTabId = prefix => { + const seed = Math.random().toString(36).slice(2, 8) + return `${prefix}-${Date.now().toString(36)}-${seed}` +} + +const makeUniqueTabPath = ({ basePath, suffix = '', tabs, toNonEmptyWorkspaceText }) => { + const existingPaths = new Set( + (Array.isArray(tabs) ? tabs : []) + .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)}` +} + +export { createWorkspaceTabId, makeUniqueTabPath } diff --git a/src/modules/workspace/workspace-tab-helpers.js b/src/modules/workspace/workspace-tab-helpers.js new file mode 100644 index 0000000..7cbe06a --- /dev/null +++ b/src/modules/workspace/workspace-tab-helpers.js @@ -0,0 +1,272 @@ +const defaultStyleTabLanguages = new Set(['css', 'less', 'sass', 'module']) +const defaultComponentTabPath = 'src/components/App.tsx' +const defaultComponentTabName = 'App.tsx' +const defaultEntryTabDirectory = 'src/components' +const defaultAllowedEntryTabFileNames = new Set(['app.tsx', 'app.js']) + +const toNonEmptyWorkspaceText = value => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : '' + +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 resolveWorkspaceRecordIdentity = ({ + repositoryFullName, + headBranch, + activeRecordId, +} = {}) => { + const canonicalId = toWorkspaceRecordId({ repositoryFullName, headBranch }) + const currentId = toNonEmptyWorkspaceText(activeRecordId) + + if (!currentId) { + return { + id: canonicalId, + supersededId: '', + } + } + + if (currentId === canonicalId) { + return { + id: currentId, + supersededId: '', + } + } + + const hasRepository = Boolean(toWorkspaceIdentitySegment(repositoryFullName)) + const shouldPromoteLocalIdToRepository = + hasRepository && currentId.startsWith('workspace_') + + if (shouldPromoteLocalIdToRepository) { + return { + id: canonicalId, + supersededId: currentId, + } + } + + return { + id: currentId, + supersededId: '', + } +} + +const toWorkspaceSyncTimestamp = value => + Number.isFinite(value) && value > 0 ? Math.max(0, Number(value)) : null + +const toWorkspaceSyncSha = value => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null + +const toWorkspaceSyncedContent = value => (typeof value === 'string' ? value : null) + +const normalizeWorkspacePathValue = value => + toNonEmptyWorkspaceText(value).replace(/\\/g, '/').replace(/\/+/g, '/') + +const getTabTargetPrFilePath = tab => normalizeWorkspacePathValue(tab?.targetPrFilePath) + +const hasTabSyncBaseline = tab => + Boolean( + getTabTargetPrFilePath(tab) || + toWorkspaceSyncTimestamp(tab?.syncedAt) || + toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + ) + +const hasTabCommittedSyncState = tab => + Boolean( + toWorkspaceSyncTimestamp(tab?.syncedAt) || + toWorkspaceSyncSha(tab?.lastSyncedRemoteSha) || + toWorkspaceSyncedContent(tab?.syncedContent), + ) + +const getDirtyStateForTabChange = (tab, nextContent) => { + if (!hasTabSyncBaseline(tab)) { + return Boolean(tab?.isDirty) + } + + const normalizedNextContent = typeof nextContent === 'string' ? nextContent : '' + const syncedContent = toWorkspaceSyncedContent(tab?.syncedContent) + + if (syncedContent === null) { + if (normalizedNextContent === (typeof tab?.content === 'string' ? tab.content : '')) { + return Boolean(tab?.isDirty) + } + + return true + } + + return normalizedNextContent !== syncedContent +} + +const resolveSyncedBaselineContent = ({ tab, content }) => { + const explicitSyncedContent = toWorkspaceSyncedContent(tab?.syncedContent) + if (explicitSyncedContent !== null) { + return explicitSyncedContent + } + + if (hasTabSyncBaseline(tab) && !tab?.isDirty) { + return content + } + + return null +} + +const isStyleTabLanguage = ( + language, + { styleTabLanguages = defaultStyleTabLanguages } = {}, +) => styleTabLanguages.has(toNonEmptyWorkspaceText(language)) + +const getTabKind = (tab, options) => + isStyleTabLanguage(tab?.language, options) ? 'styles' : 'component' + +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, { defaultDirectory = defaultEntryTabDirectory } = {}) => { + const segments = splitWorkspacePath(path) + if (segments.length <= 1) { + return defaultDirectory + } + + return segments.slice(0, -1).join('/') +} + +const normalizeEntryTabName = ( + value, + { + allowedEntryTabFileNames = defaultAllowedEntryTabFileNames, + defaultFileName = defaultComponentTabName, + } = {}, +) => { + const normalized = toNonEmptyWorkspaceText(value) + if (allowedEntryTabFileNames.has(normalized.toLowerCase())) { + return normalized + } + + return defaultFileName +} + +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 = '', + defaultPath = defaultComponentTabPath, + defaultDirectory = defaultEntryTabDirectory, + allowedEntryTabFileNames = defaultAllowedEntryTabFileNames, + defaultFileName = defaultComponentTabName, + } = {}, +) => { + const normalizedPath = toNonEmptyWorkspaceText(path) + const directory = getPathDirectory(normalizedPath || defaultPath, { + defaultDirectory, + }) + const requestedFileName = + toNonEmptyWorkspaceText(preferredFileName) || + getPathFileName(normalizedPath || defaultPath) + const fileName = normalizeEntryTabName(requestedFileName, { + allowedEntryTabFileNames, + defaultFileName, + }) + + return `${directory}/${fileName}` +} + +const normalizeModuleTabPathForRename = ( + path, + nextName, + { defaultDirectory = defaultEntryTabDirectory } = {}, +) => { + 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, { defaultDirectory }) + return `${directory}/${nextFileName}` +} + +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) +} + +export { + defaultStyleTabLanguages, + getDirtyStateForTabChange, + getPathDirectory, + getPathFileName, + getTabKind, + getTabTargetPrFilePath, + getWorkspaceTabDisplay, + hasTabCommittedSyncState, + hasTabSyncBaseline, + isStyleTabLanguage, + normalizeEntryTabName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + normalizeWorkspacePathValue, + resolveSyncedBaselineContent, + resolveWorkspaceActiveTabId, + resolveWorkspaceRecordIdentity, + splitWorkspacePath, + toNonEmptyWorkspaceText, + toWorkspaceIdentitySegment, + toWorkspaceRecordId, + toWorkspaceSyncSha, + toWorkspaceSyncedContent, + toWorkspaceSyncTimestamp, +} diff --git a/src/modules/workspace/workspace-tab-shape.js b/src/modules/workspace/workspace-tab-shape.js new file mode 100644 index 0000000..ee8e664 --- /dev/null +++ b/src/modules/workspace/workspace-tab-shape.js @@ -0,0 +1,109 @@ +const createEnsureWorkspaceTabsShape = + ({ + defaultComponentTabName, + defaultComponentTabPath, + defaultStylesTabName, + defaultStylesTabPath, + defaultJsx, + normalizeEntryTabPath, + getPathFileName, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncTimestamp, + toWorkspaceSyncSha, + resolveSyncedBaselineContent, + toNonEmptyWorkspaceText, + isStyleTabLanguage, + }) => + tabs => { + const inputTabs = Array.isArray(tabs) ? tabs : [] + const hasComponent = inputTabs.some(tab => tab?.id === 'component') + const nextTabs = [...inputTabs] + + if (!hasComponent) { + nextTabs.unshift({ + id: 'component', + name: defaultComponentTabName, + path: defaultComponentTabPath, + language: 'javascript-jsx', + role: 'entry', + content: defaultJsx, + isActive: true, + }) + } + + return nextTabs.map(tab => { + if (tab?.id === 'component') { + const normalizedEntryPath = normalizeEntryTabPath(tab.path, { + preferredFileName: tab.name, + }) + return { + ...tab, + role: 'entry', + language: 'javascript-jsx', + content: typeof tab?.content === 'string' ? tab.content : '', + path: normalizedEntryPath, + name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, + targetPrFilePath: + getTabTargetPrFilePath(tab) || + normalizeWorkspacePathValue(normalizedEntryPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: typeof tab?.content === 'string' ? tab.content : '', + }), + } + } + + 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', + content: typeof tab?.content === 'string' ? tab.content : '', + path: normalizedStylesPath, + name: + !normalizedStylesNameInput || + normalizedStylesNameInput.toLowerCase() === 'styles' + ? getPathFileName(normalizedStylesPath) || defaultStylesTabName + : normalizedStylesNameInput, + targetPrFilePath: + getTabTargetPrFilePath(tab) || + normalizeWorkspacePathValue(normalizedStylesPath), + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: typeof tab?.content === 'string' ? tab.content : '', + }), + } + } + + const nextPath = toNonEmptyWorkspaceText(tab?.path) + const nextContent = typeof tab?.content === 'string' ? tab.content : '' + return { + ...tab, + role: 'module', + language: isStyleTabLanguage(tab?.language) ? tab.language : 'javascript-jsx', + path: nextPath, + content: nextContent, + name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, + targetPrFilePath: getTabTargetPrFilePath(tab) || null, + isDirty: Boolean(tab?.isDirty), + syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), + lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), + syncedContent: resolveSyncedBaselineContent({ + tab, + content: nextContent, + }), + } + }) + } + +export { createEnsureWorkspaceTabsShape } diff --git a/src/modules/workspace-tabs-state.js b/src/modules/workspace/workspace-tabs-state.js similarity index 100% rename from src/modules/workspace-tabs-state.js rename to src/modules/workspace/workspace-tabs-state.js diff --git a/src/modules/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js similarity index 100% rename from src/modules/workspaces-drawer/drawer.js rename to src/modules/workspace/workspaces-drawer/drawer.js