Skip to content

Commit 4318ef5

Browse files
feat: diagnostics jump-to with keyboard navigation.
1 parent ba0e7ab commit 4318ef5

8 files changed

Lines changed: 430 additions & 23 deletions

File tree

docs/next-steps.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ Focused follow-up work for `@knighted/develop`.
1616
- "Audit the current Biome lint rules used by `@knighted/develop`, propose and apply a refined rule profile for component/styles linting, and add/update Playwright coverage to keep diagnostics UX stable under the new rules. Preserve intentional suppressions only when justified and document the reasoning. Validate with `npm run lint`, `npm run build:esm`, and targeted lint diagnostics Playwright tests."
1717

1818
2. **In-browser component type checking**
19-
- Add editor-linked diagnostics navigation so each issue can jump to the exact line/column in the component source.
20-
- Surface line/column context directly in the diagnostics UI (not just message text) to speed up triage.
2119
- Prioritize first-run performance improvements in CDN/type graph hydration (request ordering, cache reuse, and avoiding redundant fetches) before deeper host refactors.
2220
- Continue improving warm-run typecheck performance for large sources while keeping the preview loop non-blocking.
2321

playwright/app.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ const setStylesEditorSource = async (page: Page, source: string) => {
2929
await editorContent.fill(source)
3030
}
3131

32+
const getActiveComponentEditorLineNumber = async (page: Page) => {
33+
return page
34+
.locator('#component-panel .cm-activeLineGutter')
35+
.first()
36+
.innerText()
37+
.then(text => text.trim())
38+
}
39+
3240
const runTypecheck = async (page: Page) => {
3341
await ensurePanelToolsVisible(page, 'component')
3442
await page.locator('#typecheck-button').click()
@@ -39,6 +47,19 @@ const runComponentLint = async (page: Page) => {
3947
await page.locator('#lint-component-button').click()
4048
}
4149

50+
const runStylesLint = async (page: Page) => {
51+
await ensurePanelToolsVisible(page, 'styles')
52+
await page.locator('#lint-styles-button').click()
53+
}
54+
55+
const getActiveStylesEditorLineNumber = async (page: Page) => {
56+
return page
57+
.locator('#styles-panel .cm-activeLineGutter')
58+
.first()
59+
.innerText()
60+
.then(text => text.trim())
61+
}
62+
4263
const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') =>
4364
page.locator(`#collapse-${panelName}`)
4465

@@ -750,6 +771,73 @@ test('typecheck error reports diagnostics count in button and details in drawer'
750771
await expect(page.locator('#diagnostics-component')).toContainText('TS')
751772
})
752773

774+
test('component diagnostics rows navigate editor to reported line', async ({ page }) => {
775+
await waitForInitialRender(page)
776+
777+
await setComponentEditorSource(
778+
page,
779+
[
780+
"const brokenCount: number = 'oops'",
781+
'const App = () => <button>{brokenCount.toUpperCase()}</button>',
782+
].join('\n'),
783+
)
784+
785+
await runTypecheck(page)
786+
787+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
788+
/diagnostics-toggle--error/,
789+
)
790+
await page.locator('#diagnostics-toggle').click()
791+
792+
const targetDiagnostic = page
793+
.locator('#diagnostics-component .diagnostic-line-button[data-diagnostic-line="2"]')
794+
.first()
795+
await expect(targetDiagnostic).toBeVisible()
796+
797+
await targetDiagnostic.click()
798+
await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/)
799+
await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2')
800+
})
801+
802+
test('component diagnostics support arrow navigation and enter jump', async ({
803+
page,
804+
}) => {
805+
await waitForInitialRender(page)
806+
807+
await setComponentEditorSource(
808+
page,
809+
[
810+
"const broken: number = 'oops'",
811+
'const App = () => <button>{missingName}</button>',
812+
].join('\n'),
813+
)
814+
815+
await runTypecheck(page)
816+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
817+
/diagnostics-toggle--error/,
818+
)
819+
820+
await page.locator('#diagnostics-toggle').click()
821+
822+
const firstDiagnostic = page
823+
.locator('#diagnostics-component .diagnostic-line-button')
824+
.first()
825+
const secondDiagnostic = page
826+
.locator('#diagnostics-component .diagnostic-line-button')
827+
.nth(1)
828+
829+
await expect(firstDiagnostic).toBeVisible()
830+
await expect(secondDiagnostic).toBeVisible()
831+
832+
await firstDiagnostic.focus()
833+
await firstDiagnostic.press('ArrowDown')
834+
await expect(secondDiagnostic).toBeFocused()
835+
836+
await secondDiagnostic.press('Enter')
837+
await expect(secondDiagnostic).toHaveClass(/diagnostic-line-button--active/)
838+
await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2')
839+
})
840+
753841
test('component lint error reports diagnostics count and details', async ({ page }) => {
754842
await waitForInitialRender(page)
755843

@@ -772,6 +860,32 @@ test('component lint error reports diagnostics count and details', async ({ page
772860
)
773861
})
774862

863+
test('styles diagnostics rows navigate editor to reported line', async ({ page }) => {
864+
await waitForInitialRender(page)
865+
866+
await ensurePanelToolsVisible(page, 'styles')
867+
await setStylesEditorSource(
868+
page,
869+
['.card {', ' color: red', ' color: blue;', '}'].join('\n'),
870+
)
871+
872+
await runStylesLint(page)
873+
874+
await expect(page.locator('#diagnostics-toggle')).toHaveClass(
875+
/diagnostics-toggle--error/,
876+
)
877+
await page.locator('#diagnostics-toggle').click()
878+
879+
const targetDiagnostic = page
880+
.locator('#diagnostics-styles .diagnostic-line-button[data-diagnostic-line="3"]')
881+
.first()
882+
await expect(targetDiagnostic).toBeVisible()
883+
884+
await targetDiagnostic.click()
885+
await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/)
886+
await expect.poll(() => getActiveStylesEditorLineNumber(page)).toBe('3')
887+
})
888+
775889
test('clear component diagnostics resets rendered lint-issue status pill', async ({
776890
page,
777891
}) => {

src/app.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,88 @@ const togglePanelCollapse = panelName => {
280280
applyPanelCollapseState()
281281
}
282282

283+
const toTextareaOffset = (source, line, column = 1) => {
284+
if (typeof source !== 'string' || source.length === 0) {
285+
return 0
286+
}
287+
288+
const targetLine = Number.isFinite(line) ? Math.max(1, Number(line)) : 1
289+
const targetColumn = Number.isFinite(column) ? Math.max(1, Number(column)) : 1
290+
291+
let currentLine = 1
292+
let lineStartOffset = 0
293+
294+
for (let index = 0; index < source.length; index += 1) {
295+
if (currentLine === targetLine) {
296+
lineStartOffset = index
297+
break
298+
}
299+
300+
if (source[index] === '\n') {
301+
currentLine += 1
302+
lineStartOffset = index + 1
303+
}
304+
}
305+
306+
const nextNewlineOffset = source.indexOf('\n', lineStartOffset)
307+
const lineEndOffset = nextNewlineOffset === -1 ? source.length : nextNewlineOffset
308+
return Math.min(lineStartOffset + targetColumn - 1, lineEndOffset)
309+
}
310+
311+
const navigateToComponentDiagnostic = ({ line, column }) => {
312+
if (jsxCodeEditor && typeof jsxCodeEditor.revealPosition === 'function') {
313+
jsxCodeEditor.revealPosition({ line, column })
314+
return
315+
}
316+
317+
if (!(jsxEditor instanceof HTMLTextAreaElement)) {
318+
return
319+
}
320+
321+
const source = jsxEditor.value
322+
const offset = toTextareaOffset(source, line, column)
323+
jsxEditor.focus()
324+
jsxEditor.setSelectionRange(offset, offset)
325+
}
326+
327+
const navigateToStylesDiagnostic = ({ line, column }) => {
328+
if (cssCodeEditor && typeof cssCodeEditor.revealPosition === 'function') {
329+
cssCodeEditor.revealPosition({ line, column })
330+
return
331+
}
332+
333+
if (!(cssEditor instanceof HTMLTextAreaElement)) {
334+
return
335+
}
336+
337+
const source = cssEditor.value
338+
const offset = toTextareaOffset(source, line, column)
339+
cssEditor.focus()
340+
cssEditor.setSelectionRange(offset, offset)
341+
}
342+
283343
const diagnosticsUi = createDiagnosticsUiController({
284344
diagnosticsToggle,
285345
diagnosticsDrawer,
286346
diagnosticsComponent,
287347
diagnosticsStyles,
288348
statusNode,
349+
onNavigateDiagnostic: diagnostic => {
350+
if (diagnostic?.scope === 'component') {
351+
navigateToComponentDiagnostic({
352+
line: diagnostic.line,
353+
column: diagnostic.column,
354+
})
355+
return
356+
}
357+
358+
if (diagnostic?.scope === 'styles') {
359+
navigateToStylesDiagnostic({
360+
line: diagnostic.line,
361+
column: diagnostic.column,
362+
})
363+
}
364+
},
289365
})
290366

291367
const {

0 commit comments

Comments
 (0)