diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index 91c837c..8857a63 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { POU } from '../shared/types.js'; import { Token, tokenizeLines, TokenKind } from './highlight.js'; +import { LiveValueSnapshot } from '../shared/live-values.js'; const COLORS: Record = { keyword: 'cyan', @@ -23,14 +24,38 @@ function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement ); } +/** + * Find the first var name on the line that has a live value. + * + * Match rule: scan the raw line for identifiers, return the value of the + * first one that's a key in liveValues. Operates on the original line text + * (not on highlighter tokens) because the highlighter's uppercase-only + * identifier splitter shreds mixed-case names like `bRunning` into + * `b` + `R` + `unning`, which would never match a `bRunning` key. + */ +function findOverlayValue( + line: string, + liveValues: Record +): string | null { + const re = /[A-Za-z_][A-Za-z0-9_]*/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + const v = liveValues[m[0]]; + if (v) return v.value; + } + return null; +} + export interface ViewerProps { pou: POU | null; text: string | null; scrollTop: number; visibleRows: number; + /** Optional bare-name → live-value map. When set, lines with a matching var get an inline overlay. */ + liveValues?: Record; } -export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement { +export function Viewer({ pou, text, scrollTop, visibleRows, liveValues }: ViewerProps): React.ReactElement { if (!pou || text == null) { return ( @@ -48,12 +73,17 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac {pou.name}.st ({pou.kind}, {pou.loc} L) - {sliceTokens.map((tokens, i) => ( - - {String(scrollTop + i + 1).padStart(4, ' ')} - - - ))} + {sliceTokens.map((tokens, i) => { + const lineNo = scrollTop + i; + const overlay = liveValues ? findOverlayValue(lines[lineNo] ?? '', liveValues) : null; + return ( + + {String(lineNo + 1).padStart(4, ' ')} + + {overlay !== null && ◀ live: {overlay}} + + ); + })} ); } diff --git a/tests/tui/Viewer.test.tsx b/tests/tui/Viewer.test.tsx new file mode 100644 index 0000000..a46f9e1 --- /dev/null +++ b/tests/tui/Viewer.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Viewer } from '../../src/tui/browser/Viewer.tsx'; +import { POU } from '../../src/tui/shared/types.ts'; + +const pou: POU = { + name: 'PLC_PRG', + kind: 'PRG', + relPath: 'PLC_PRG.st', + absPath: '/abs/PLC_PRG.st', + loc: 4, + mtimeMs: 0, +}; + +const text = [ + 'PROGRAM PLC_PRG', + 'VAR', + ' counter : INT := 0;', + ' bRunning : BOOL;', + 'END_VAR', +].join('\n'); + +describe('', () => { + it('renders without overlay when liveValues is absent', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).not.toContain('live:'); + }); + + it('overlays "◀ live: " on the line whose token matches a key', () => { + const { lastFrame } = render( + + ); + const out = lastFrame()!; + expect(out).toMatch(/counter\b.*◀ live: 47/); + expect(out).toMatch(/bRunning\b.*◀ live: TRUE/); + }); + + it('does not overlay vars that are not in liveValues', () => { + const { lastFrame } = render( + + ); + const out = lastFrame()!; + expect(out).toMatch(/counter\b.*◀ live: 47/); + // bRunning line must not gain an overlay + const bRunningLine = out.split('\n').find((l) => l.includes('bRunning'))!; + expect(bRunningLine).not.toContain('live:'); + }); +});