Skip to content

Commit 7d4f968

Browse files
feat: mode-aware entry tab extensions. (#108)
1 parent 71bdbd0 commit 7d4f968

15 files changed

Lines changed: 207 additions & 47 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Repository structure:
4646
- Preserve progressive loading behavior (lazy-load optional compilers/runtime pieces where possible).
4747
- Do not introduce bundler-only assumptions into src/ runtime code.
4848
- Prefer async/await over promise chains.
49+
- Prefer const-assigned function expressions over function declarations, unless hoisting is explicitly required.
4950
- Do not use IIFE, find another pattern instead.
5051
- In Playwright tests, prefer accessible selectors first: `getByRole`, `getByLabel`, `getByText`, and explicit accessible names.
5152
- Avoid `locator()` for interactive controls when a semantic selector is available.

playwright/github-byot-ai.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
785785
await page.reload()
786786
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
787787
await expect(page.getByRole('status', { name: 'App status' })).toHaveText(
788-
'Loaded 2 writable repositories',
788+
/Loaded 2 writable repositories|Rendered/,
789789
{
790790
timeout: 60_000,
791791
},

playwright/rendering-modes/core.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,37 @@ test('renders in react mode with css modules', async ({ page }) => {
152152
await expectPreviewHasRenderedContent(page)
153153
})
154154

155+
test('react mode keeps App.ts entry but surfaces rename guidance until compatible', async ({
156+
page,
157+
}) => {
158+
await waitForInitialRender(page)
159+
await ensurePanelToolsVisible(page, 'component')
160+
161+
await renameWorkspaceTab(page, {
162+
from: 'App.tsx',
163+
to: 'App.ts',
164+
})
165+
166+
await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react')
167+
168+
const expectedMessage =
169+
'React mode requires the entry tab to end in .tsx, .jsx, or .js.'
170+
await expect(page.getByRole('status', { name: 'App status' })).toContainText(
171+
expectedMessage,
172+
)
173+
await expect(page.locator('#preview-host pre.preview-runtime-error')).toContainText(
174+
expectedMessage,
175+
)
176+
177+
await renameWorkspaceTab(page, {
178+
from: 'App.ts',
179+
to: 'App.jsx',
180+
})
181+
182+
await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered')
183+
await expectPreviewHasRenderedContent(page)
184+
})
185+
155186
test('css module imports expose class map for module tabs', async ({ page }) => {
156187
await waitForInitialRender(page)
157188

src/app.js

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
setJsxSourceValue,
2222
updateRenderModeEditability as updateRenderModeEditabilityValue,
2323
} from './modules/app-core/runtime-editor-utils.js'
24+
import { createSourceSetters } from './modules/app-core/source-setters.js'
2425
import { createRuntimeCoreSetup } from './modules/app-core/runtime-core-setup.js'
2526
import {
2627
createWorkspaceContextSnapshotGetter,
@@ -61,6 +62,7 @@ import { createGitHubPrDrawer } from './modules/github/pr/drawer/controller/crea
6162
import { createLayoutThemeController } from './modules/ui/layout-theme.js'
6263
import { createLintDiagnosticsController } from './modules/diagnostics/lint-diagnostics.js'
6364
import { createPreviewBackgroundController } from './modules/preview/preview-background.js'
65+
import { getReactEntryTabCompatibilityError } from './modules/preview/preview-entry-resolver.js'
6466
import { createRenderRuntimeController } from './modules/preview/render-runtime.js'
6567
import { createTypeDiagnosticsController } from './modules/diagnostics/type-diagnostics.js'
6668
import { collectTopLevelDeclarations } from './modules/preview/jsx-top-level-declarations.js'
@@ -80,6 +82,7 @@ import { createEnsureWorkspaceTabsShape } from './modules/workspace/workspace-ta
8082
import {
8183
createWorkspaceRecordId,
8284
getDirtyStateForTabChange,
85+
getAllowedEntryTabFileNames,
8386
getPathFileName,
8487
getTabKind,
8588
getTabTargetPrFilePath,
@@ -211,7 +214,6 @@ const defaultComponentTabPath = 'src/components/App.tsx'
211214
const defaultStylesTabPath = 'src/styles/app.css'
212215
const defaultComponentTabName = 'App.tsx'
213216
const defaultStylesTabName = 'app.css'
214-
const allowedEntryTabFileNames = new Set(['app.tsx', 'app.js'])
215217
const editorKinds = ['component', 'styles']
216218
const editorPanelsByKind = {
217219
component: componentEditorPanel,
@@ -237,6 +239,7 @@ let previewHost = document.getElementById('preview-host')
237239
let jsxCodeEditor = null
238240
let cssCodeEditor = null
239241
let diagnosticsFlowController = null
242+
let runtimeCore = null
240243
let getJsxSource = () => jsxEditor.value
241244
let getCssSource = () => cssEditor.value
242245
let renderRuntime = null
@@ -282,6 +285,16 @@ let draggedWorkspaceTabId = ''
282285
let dragOverWorkspaceTabId = ''
283286
let suppressWorkspaceTabClick = false
284287
const clipboardSupported = Boolean(navigator.clipboard?.writeText)
288+
const { setJsxSource, setCssSource } = createSourceSetters({
289+
setJsxSourceValue,
290+
setCssSourceValue,
291+
getJsxCodeEditor: () => jsxCodeEditor,
292+
getCssCodeEditor: () => cssCodeEditor,
293+
setSuppressEditorChangeSideEffects: nextValue =>
294+
(suppressEditorChangeSideEffects = nextValue),
295+
jsxEditor,
296+
cssEditor,
297+
})
285298

286299
const showAppToast = message => {
287300
if (!(appToast instanceof HTMLElement)) {
@@ -695,6 +708,7 @@ const ensureWorkspaceTabsShape = createEnsureWorkspaceTabsShape({
695708
defaultStylesTabPath,
696709
defaultJsx,
697710
normalizeEntryTabPath,
711+
getAllowedEntryTabFileNames,
698712
getPathFileName,
699713
getTabTargetPrFilePath,
700714
normalizeWorkspacePathValue,
@@ -791,7 +805,7 @@ const {
791805
workspaceTabsShell,
792806
workspaceTabAddWrap,
793807
setWorkspaceTabRenameState: value => (workspaceTabRenameState = value),
794-
allowedEntryTabFileNames,
808+
getAllowedEntryTabFileNames,
795809
getPathFileName,
796810
normalizeEntryTabPath,
797811
normalizeModuleTabPathForRename,
@@ -1191,8 +1205,8 @@ const githubWorkflows = createGitHubWorkflowsSetup({
11911205
githubPrContextClose,
11921206
},
11931207
actions: {
1194-
applyRenderMode,
1195-
applyStyleMode,
1208+
applyRenderMode: options => runtimeCore?.applyRenderMode(options),
1209+
applyStyleMode: options => runtimeCore?.applyStyleMode(options),
11961210
confirmAction: options => confirmAction(options),
11971211
setStatus,
11981212
showAppToast,
@@ -1325,8 +1339,12 @@ const runtimeCoreOptions = createRuntimeCoreOptions({
13251339
getStyleEditorLanguage,
13261340
workspaceTabsState,
13271341
queueWorkspaceSave,
1342+
getRenderModeCompatibilityError: mode =>
1343+
normalizeRenderMode(mode) === 'react'
1344+
? getReactEntryTabCompatibilityError(getEntryWorkspaceTab())
1345+
: null,
13281346
})
1329-
const runtimeCore = createRuntimeCoreSetup(runtimeCoreOptions)
1347+
runtimeCore = createRuntimeCoreSetup(runtimeCoreOptions)
13301348

13311349
diagnosticsFlowController = runtimeCore.diagnosticsFlowController
13321350
renderRuntime = runtimeCore.renderRuntime
@@ -1349,36 +1367,8 @@ const maybeRender = () => diagnosticsFlowController.maybeRender()
13491367
const maybeRenderFromComponentEditorChange = () =>
13501368
diagnosticsFlowController.maybeRenderFromComponentEditorChange()
13511369

1352-
function setJsxSource(value) {
1353-
setJsxSourceValue({
1354-
value,
1355-
jsxCodeEditor,
1356-
setSuppressEditorChangeSideEffects: nextValue =>
1357-
(suppressEditorChangeSideEffects = nextValue),
1358-
jsxEditor,
1359-
})
1360-
}
1361-
1362-
function setCssSource(value) {
1363-
setCssSourceValue({
1364-
value,
1365-
cssCodeEditor,
1366-
setSuppressEditorChangeSideEffects: nextValue =>
1367-
(suppressEditorChangeSideEffects = nextValue),
1368-
cssEditor,
1369-
})
1370-
}
1371-
13721370
const confirmAction = options => runtimeCore.confirmAction(options)
13731371

1374-
function applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext = false }) {
1375-
runtimeCore.applyRenderMode({ mode, fromActivePrContext: _fromActivePrContext })
1376-
}
1377-
1378-
function applyStyleMode({ mode }) {
1379-
runtimeCore.applyStyleMode({ mode })
1380-
}
1381-
13821372
bindAppEventsAndStart({
13831373
editorUi: {
13841374
renderMode,
@@ -1405,8 +1395,8 @@ bindAppEventsAndStart({
14051395
statusNode,
14061396
},
14071397
sourceActions: {
1408-
applyRenderMode,
1409-
applyStyleMode,
1398+
applyRenderMode: options => runtimeCore.applyRenderMode(options),
1399+
applyStyleMode: options => runtimeCore.applyStyleMode(options),
14101400
updateRenderButtonVisibility: () => (renderButton.hidden = autoRenderToggle.checked),
14111401
clearDiagnosticsScope,
14121402
clearComponentLintDiagnosticsState,

src/modules/app-core/app-composition-options.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const createRuntimeCoreOptions = ({
4242
clearConfirmCopy,
4343
clearConfirmButton,
4444
setPendingClearAction,
45+
getRenderModeCompatibilityError,
4546
normalizeRenderMode,
4647
normalizeStyleMode,
4748
resetDiagnosticsFlow,
@@ -108,6 +109,8 @@ const createRuntimeCoreOptions = ({
108109
clearConfirmCopy,
109110
clearConfirmButton,
110111
setPendingClearAction,
112+
setStatus,
113+
getRenderModeCompatibilityError,
111114
normalizeRenderMode,
112115
normalizeStyleMode,
113116
resetDiagnosticsFlow,

src/modules/app-core/runtime-core-setup.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ const createRuntimeCoreSetup = ({
88
clearConfirmCopy,
99
clearConfirmButton,
1010
setPendingClearAction,
11+
setStatus,
1112
normalizeRenderMode,
1213
normalizeStyleMode,
14+
getRenderModeCompatibilityError = () => null,
1315
resetDiagnosticsFlow,
1416
maybeRender,
1517
flushWorkspaceSave,
@@ -101,6 +103,11 @@ const createRuntimeCoreSetup = ({
101103
queueWorkspaceSave()
102104
resetDiagnosticsFlow()
103105

106+
const compatibilityError = getRenderModeCompatibilityError(nextMode)
107+
if (compatibilityError instanceof Error) {
108+
setStatus(compatibilityError.message, 'error')
109+
}
110+
104111
maybeRender()
105112
void flushWorkspaceSave().catch(() => {
106113
/* Save failures are already surfaced through saver onError. */
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const createSourceSetters = ({
2+
setJsxSourceValue,
3+
setCssSourceValue,
4+
getJsxCodeEditor,
5+
getCssCodeEditor,
6+
setSuppressEditorChangeSideEffects,
7+
jsxEditor,
8+
cssEditor,
9+
}) => {
10+
const setJsxSource = value => {
11+
setJsxSourceValue({
12+
value,
13+
jsxCodeEditor: getJsxCodeEditor(),
14+
setSuppressEditorChangeSideEffects,
15+
jsxEditor,
16+
})
17+
}
18+
19+
const setCssSource = value => {
20+
setCssSourceValue({
21+
value,
22+
cssCodeEditor: getCssCodeEditor(),
23+
setSuppressEditorChangeSideEffects,
24+
cssEditor,
25+
})
26+
}
27+
28+
return {
29+
setJsxSource,
30+
setCssSource,
31+
}
32+
}
33+
34+
export { createSourceSetters }

src/modules/app-core/workspace-context-controller.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ const createWorkspaceContextController = ({
100100
setWorkspaceScopeMarker(nextScope)
101101
}
102102

103-
const nextTabs = ensureWorkspaceTabsShape(workspace.tabs)
103+
const nextRenderMode = normalizeRenderMode(workspace.renderMode)
104+
const nextTabs = ensureWorkspaceTabsShape(workspace.tabs, {
105+
renderMode: nextRenderMode,
106+
})
104107
if (typeof workspace.base === 'string' && githubPrBaseBranch) {
105108
githubPrBaseBranch.value = workspace.base
106109
}
@@ -124,7 +127,6 @@ const createWorkspaceContextController = ({
124127
}),
125128
})
126129

127-
const nextRenderMode = normalizeRenderMode(workspace.renderMode)
128130
if (getRenderModeValue() !== nextRenderMode) {
129131
setRenderModeValue(nextRenderMode)
130132
}

src/modules/app-core/workspace-controllers-setup.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const createWorkspaceControllersSetup = ({
5656
workspaceTabsShell,
5757
workspaceTabAddWrap,
5858
setWorkspaceTabRenameState,
59-
allowedEntryTabFileNames,
59+
getAllowedEntryTabFileNames,
6060
getPathFileName,
6161
normalizeEntryTabPath,
6262
normalizeModuleTabPathForRename,
@@ -135,7 +135,8 @@ const createWorkspaceControllersSetup = ({
135135
},
136136
renderWorkspaceTabs: () => renderWorkspaceTabs(),
137137
setStatus,
138-
allowedEntryTabFileNames,
138+
getAllowedEntryTabFileNames,
139+
getRenderModeValue,
139140
getPathFileName,
140141
normalizeEntryTabPath,
141142
normalizeModuleTabPathForRename,

src/modules/app-core/workspace-tab-mutations-controller.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const createWorkspaceTabMutationsController = ({
44
setWorkspaceTabRenameState,
55
renderWorkspaceTabs,
66
setStatus,
7-
allowedEntryTabFileNames,
7+
getAllowedEntryTabFileNames,
8+
getRenderModeValue,
89
getPathFileName,
910
normalizeEntryTabPath,
1011
normalizeModuleTabPathForRename,
@@ -27,6 +28,41 @@ const createWorkspaceTabMutationsController = ({
2728
createWorkspaceTabId,
2829
getShouldShowEditedDesign,
2930
}) => {
31+
const defaultAllowedEntryTabFileNames = new Set(['app.tsx', 'app.js'])
32+
33+
const formatAllowedEntryTabNames = allowedEntryTabFileNames => {
34+
const displayNames = [...allowedEntryTabFileNames].map(fileName =>
35+
fileName.toLowerCase().startsWith('app.')
36+
? `App.${fileName.slice('app.'.length)}`
37+
: fileName,
38+
)
39+
40+
if (displayNames.length <= 1) {
41+
return displayNames[0] || 'App.tsx'
42+
}
43+
44+
if (displayNames.length === 2) {
45+
return `${displayNames[0]} or ${displayNames[1]}`
46+
}
47+
48+
const leading = displayNames.slice(0, -1).join(', ')
49+
return `${leading}, or ${displayNames[displayNames.length - 1]}`
50+
}
51+
52+
const resolveAllowedEntryTabFileNames = () => {
53+
if (typeof getAllowedEntryTabFileNames !== 'function') {
54+
return defaultAllowedEntryTabFileNames
55+
}
56+
57+
const resolved = getAllowedEntryTabFileNames({
58+
renderMode:
59+
typeof getRenderModeValue === 'function' ? getRenderModeValue() : undefined,
60+
})
61+
return resolved instanceof Set && resolved.size > 0
62+
? resolved
63+
: defaultAllowedEntryTabFileNames
64+
}
65+
3066
const moduleTabTemplates = {
3167
script: {
3268
basePath: 'src/components/module.tsx',
@@ -87,12 +123,16 @@ const createWorkspaceTabMutationsController = ({
87123

88124
const includesDirectory = /[\\/]/.test(normalizedNameInput)
89125
const nextFileName = getPathFileName(normalizedNameInput) || normalizedNameInput
126+
const allowedEntryTabFileNames = resolveAllowedEntryTabFileNames()
90127

91128
if (
92129
tab.role === 'entry' &&
93130
!allowedEntryTabFileNames.has(nextFileName.toLowerCase())
94131
) {
95-
setStatus('Entry tab name must be App.tsx or App.js.', 'error')
132+
setStatus(
133+
`Entry tab name must be ${formatAllowedEntryTabNames(allowedEntryTabFileNames)}.`,
134+
'error',
135+
)
96136
renderWorkspaceTabs()
97137
return
98138
}
@@ -103,6 +143,7 @@ const createWorkspaceTabMutationsController = ({
103143
preferredFileName: includesDirectory
104144
? getPathFileName(normalizedNameInput)
105145
: normalizedNameInput,
146+
allowedEntryTabFileNames,
106147
})
107148
: normalizeModuleTabPathForRename(tab.path, normalizedNameInput)
108149

0 commit comments

Comments
 (0)