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