Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'

// The code view pulls in Monaco, which cannot run in jsdom.
vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({
VariablesCodeEditor: () => <div data-testid='variables-code-editor' />,
}))

vi.mock('@root/frontend/utils/feature-flags', () => ({
isDataTypeFilesEnabled: () => true,
}))

import { useOpenPLCStore } from '@root/frontend/store'

import { DataTypeEditor } from '../index'

/** Reproduce what a go-to-definition redirect leaves behind. */
function arriveFromGotoDefinition(name: string) {
const { editorActions } = useOpenPLCStore.getState()
editorActions.updateModelStructureForName(name, { display: 'code' })
editorActions.setEditorCursor(name, { lineNumber: 2, column: 3, offset: 0, target: 'data-type' })
}

describe('DataTypeEditor code view toggle', () => {
it('lets the user switch back to the table after a goto-definition jump', () => {
const created = useOpenPLCStore.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' })
expect(created.ok).toBe(true)

arriveFromGotoDefinition('Motor')
render(<DataTypeEditor dataTypeName='Motor' />)
expect(screen.getByTestId('variables-code-editor')).toBeTruthy()

fireEvent.click(screen.getByLabelText('Data type table visualization'))

// The jump's cursor is still on the model; it must not re-assert code
// mode and pin the tab there.
expect(screen.queryByTestId('variables-code-editor')).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'
import { ComponentPropsWithoutRef, useEffect, useMemo, useRef, useState } from 'react'

import type { PLCDataType } from '../../../../../middleware/shared/ports/types'
import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon'
import { TableIcon } from '../../../../assets/icons/interface/TableIcon'
import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot'
import { dtViewUri } from '../../../../services/st-lsp/types'
import { useOpenPLCStore } from '../../../../store'
import { extractSearchQuery } from '../../../../store/slices/search/utils'
import { cn } from '../../../../utils/cn'
Expand Down Expand Up @@ -152,6 +153,29 @@
commitCodeRef.current = commitCode
})

// Stable reference, or the child's cursor-jump effect re-fires every
// keystroke and re-selects the navigated line.
const codeCursorPosition = useMemo(
() =>
model?.cursorPosition?.target === 'data-type'
? {
lineNumber: model.cursorPosition.lineNumber,
column: model.cursorPosition.column,
target: 'data-type' as const,
}
: undefined,
[model?.cursorPosition?.target, model?.cursorPosition?.lineNumber, model?.cursorPosition?.column],
)

// Goto-definition can land here while the tab is still in table mode.
// Keyed on the cursor alone: including `display` would re-fire on the
// user's own switch back to table and pin the tab in code mode.
useEffect(() => {
if (!codeCursorPosition || display === 'code') return
updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeCursorPosition])

useEffect(() => {
if (display !== 'code') return

Expand Down Expand Up @@ -327,6 +351,8 @@
code={editorCode}
onCodeChange={setEditorCode}
shouldUseDarkMode={shouldUseDarkMode}
modelUri={dtViewUri(dataTypeName)}
cursorPosition={codeCursorPosition}
/>
</div>
{parseError && <p className='mt-2 shrink-0 text-xs text-red-500'>Error: {parseError}</p>}
Expand Down
12 changes: 10 additions & 2 deletions src/frontend/components/_organisms/variables-code-editor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import Editor, { OnMount } from '@monaco-editor/react'
import type { editor as MonacoEditor } from 'monaco-editor'
import * as monaco from 'monaco-editor'
Expand Down Expand Up @@ -41,6 +41,12 @@
* cross-routing.
*/
pouName?: string
/**
* Explicit Monaco model URI, for surfaces that route to the LSP but
* aren't POU variables (the data type `.dt` code view). Takes
* precedence over the URI derived from `pouName`.
*/
modelUri?: string
/**
* Programmatic cursor jump (e.g. compile-error click → vars-text
* view, or Go to Definition redirect for a variable declaration).
Expand All @@ -54,7 +60,7 @@
* (it's meant for the body editor). Undefined or
* `target === 'variables'` is honoured here.
*/
cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' }
cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' | 'data-type' }
}

const VariablesCodeEditor = ({
Expand All @@ -63,9 +69,11 @@
shouldUseDarkMode,
cursorPosition,
pouName,
modelUri,
language = 'st',
readOnly = false,
}: VariablesCodeEditorProps) => {
const resolvedModelUri = modelUri ?? (pouName ? pouVarsUri(pouName) : undefined)
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const [editorMounted, setEditorMounted] = useState(false)
Expand Down Expand Up @@ -146,7 +154,7 @@
height='100%'
width='100%'
language={language}
{...(pouName ? { path: pouVarsUri(pouName) } : {})}
{...(resolvedModelUri ? { path: resolvedModelUri } : {})}
defaultValue={''}
value={code}
onMount={handleEditorDidMount}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @jest-environment jsdom
*/
import { shiftSemanticTokensToBody } from '../internal/semantic-tokens-shift'

// One token per line of a synthesised datatypes document:
// 0 `TYPE` col 0
// 1 ` Colors : (RED);` col 2
// 2 ` Motor : STRUCT` col 2
// 3 ` speed : INT;` col 4
// 4 ` END_STRUCT;` col 2
const AGGREGATE = [0, 0, 4, 0, 0, 1, 2, 6, 0, 0, 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0]

describe('shiftSemanticTokensToBody', () => {
it('drops tokens before the window and rebases the rest to line 0 by default', () => {
expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 1, 2))).toEqual([0, 2, 6, 0, 0])
})

it('rebases onto outputStartLine so a view can render its own frame above the window', () => {
// `Motor` occupies lines 2..4; its `.dt` view renders them under a
// local `TYPE` line, so they land on local lines 1..3.
expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))).toEqual([
1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0,
])
})

it('never emits the line above the window, whose columns can overrun a short frame line', () => {
const out = Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))
// The `Colors` token (length 6) would not fit on the 4-character `TYPE` line.
expect(out).not.toContain(6)
})

it('keeps everything from startLine when no end is given', () => {
expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 3))).toEqual([0, 4, 5, 0, 0, 1, 2, 10, 0, 0])
})

it('returns an empty stream when the window selects nothing', () => {
expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 9, 12))).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand All @@ -7,10 +7,15 @@
* 1. Decode deltas to absolute (line, col) positions.
* 2. Keep tokens whose line is in `[startLine, endLineExclusive)`.
* Both ends are LSP coordinates.
* 3. Subtract `startLine` from each surviving token's line so the
* 3. Rebase each surviving token onto `outputStartLine` so the
* output is Monaco-relative.
* 4. Re-encode as a delta stream Monaco can consume directly.
*
* `outputStartLine` exists so a view that renders its own framing
* above the window doesn't have to widen the window to compensate:
* doing that drags in the preceding line's tokens, whose columns can
* overrun the shorter frame line ("end character > line length").
*
* Used in two modes by the ST LSP today:
* - **Body view**: `startLine = bodyLineOffset` (preamble line count),
* `endLineExclusive = ∞` — drop the preamble, keep everything
Expand All @@ -29,6 +34,7 @@
data: number[],
startLine: number,
endLineExclusive: number = Number.POSITIVE_INFINITY,
outputStartLine: number = 0,
): Uint32Array {
// Decode to absolute positions.
const abs: Array<{ line: number; col: number; len: number; type: number; mods: number }> = []
Expand All @@ -51,7 +57,7 @@
for (const t of abs) {
if (t.line < startLine) continue
if (t.line >= endLineExclusive) continue
const shiftedLine = t.line - startLine
const shiftedLine = t.line - startLine + outputStartLine
const dLine = shiftedLine - prevLine
const dStart = dLine === 0 ? t.col - prevCol : t.col
out.push(dLine, dStart, t.len, t.type, t.mods)
Expand Down
8 changes: 5 additions & 3 deletions src/frontend/services/lsp-shared/semantic-tokens.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand Down Expand Up @@ -39,12 +39,14 @@
* default keeps everything from `[lineOffset, +∞)` — drop the
* preamble, keep the rest. ST's variables-text view overrides this
* to clip the end at the body line so only VAR-block tokens render.
* `outputStartLine` rebases the kept window for views that render
* their own framing above it (the data type `.dt` code view).
*/
export type ResolveSemanticTokensViewport = (
lspUri: string,
modelUri: string,
lineOffset: number,
) => { startLine: number; endLineExclusive: number }
) => { startLine: number; endLineExclusive: number; outputStartLine?: number }

const defaultViewport: ResolveSemanticTokensViewport = (_lspUri, _modelUri, lineOffset) => ({
startLine: lineOffset,
Expand Down Expand Up @@ -87,10 +89,10 @@
textDocument: { uri: lspUri },
})
if (!result) return null
const { startLine, endLineExclusive } = resolveViewport(lspUri, modelUri, lineOffset)
const { startLine, endLineExclusive, outputStartLine } = resolveViewport(lspUri, modelUri, lineOffset)
return {
...(result.resultId ? { resultId: result.resultId } : {}),
data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive),
data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive, outputStartLine),
}
},
releaseDocumentSemanticTokens() {
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/services/lsp-shared/start-language-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand Down Expand Up @@ -84,6 +84,14 @@
changeDocument(uri: string, content: string, version?: number): void
/** Send `textDocument/didClose`. */
closeDocument(uri: string): void
/**
* Ask Monaco to re-query semantic tokens for every model in this
* language. Needed when a model's tokens derive from a *different*
* document (the datatype `.dt` view reads the aggregate doc), where
* a change to that document leaves the model's text untouched and
* therefore triggers no re-query of its own.
*/
refreshSemanticTokens(): void
/** Tear down providers + transport. */
dispose(): void
}
Expand Down Expand Up @@ -232,6 +240,7 @@
openDocument: () => undefined,
changeDocument: () => undefined,
closeDocument: () => undefined,
refreshSemanticTokens: () => undefined,
dispose: () => undefined,
}
}
Expand Down Expand Up @@ -333,6 +342,10 @@
return {
ready,

refreshSemanticTokens() {
semanticTokensRegistration?.refresh()
},

openDocument(uri, content) {
if (disposed) return
const existing = documents.get(uri)
Expand Down
99 changes: 99 additions & 0 deletions src/frontend/services/st-lsp/__tests__/dtview-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* @jest-environment jsdom
*/
import type { Diagnostic } from 'vscode-languageserver-protocol'

import type { PLCDataType } from '../../../../middleware/shared/ports/types'
import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from '../dtview-context'

const enumType = (name: string): PLCDataType => ({
name,
derivation: 'enumerated',
values: [{ description: 'RED' }],
initialValue: 'RED',
})

const structType = (name: string, fields: string[]): PLCDataType => ({
name,
derivation: 'structure',
variable: fields.map((field) => ({
name: field,
type: { definition: 'base-type', value: 'INT' },
})),
})

// Aggregate document, LSP (0-indexed) lines:
// 0 TYPE
// 1 Colors : (RED) := RED;
// 2 Motor : STRUCT
// 3 speed : INT;
// 4 END_STRUCT;
// 5 END_TYPE
const DATA_TYPES: PLCDataType[] = [enumType('Colors'), structType('Motor', ['speed'])]

const diagnosticAt = (line: number): Diagnostic => ({
range: { start: { line, character: 0 }, end: { line, character: 4 } },
message: `line ${line}`,
})

describe('dtViewSpan', () => {
it('returns the entry span for a type in the document', () => {
expect(dtViewSpan(DATA_TYPES, 'Motor')).toEqual({ start: 2, length: 3 })
})

it('returns null for a name the document has no entry for', () => {
expect(dtViewSpan(DATA_TYPES, 'Missing')).toBeNull()
})

it('returns null for every name when the document is empty', () => {
expect(dtViewSpan([], 'Colors')).toBeNull()
})
})

describe('dtViewLineOffset', () => {
it('is zero for the first entry — both frames open with their own TYPE line', () => {
const span = dtViewSpan(DATA_TYPES, 'Colors')
expect(span && dtViewLineOffset(span)).toBe(0)
})

it('shifts a later entry by its distance down the document', () => {
const span = dtViewSpan(DATA_TYPES, 'Motor')
expect(span && dtViewLineOffset(span)).toBe(1)
})

it('shifts the last entry of a longer document', () => {
const dataTypes = [enumType('A'), enumType('B'), structType('C', ['x', 'y'])]
const span = dtViewSpan(dataTypes, 'C')
expect(span && dtViewLineOffset(span)).toBe(2)
})
})

describe('dtViewWindow', () => {
it('covers the entry lines only, never the frame line above them', () => {
const span = dtViewSpan(DATA_TYPES, 'Motor')
expect(span && dtViewWindow(span)).toEqual({ startLine: 2, endLineExclusive: 5 })
})

it('is a single line for a one-line entry', () => {
const span = dtViewSpan(DATA_TYPES, 'Colors')
expect(span && dtViewWindow(span)).toEqual({ startLine: 1, endLineExclusive: 2 })
})
})

describe('diagnosticsInSpan', () => {
it('keeps only what falls inside the entry', () => {
const span = dtViewSpan(DATA_TYPES, 'Motor')
const kept = span ? diagnosticsInSpan([diagnosticAt(1), diagnosticAt(3), diagnosticAt(5)], span) : []
expect(kept.map((d) => d.message)).toEqual(['line 3'])
})

it('excludes the line directly above the entry — that belongs to the previous type', () => {
const span = dtViewSpan(DATA_TYPES, 'Motor')
expect(span && diagnosticsInSpan([diagnosticAt(1)], span)).toEqual([])
})

it('returns nothing when the entry is clean', () => {
const span = dtViewSpan(DATA_TYPES, 'Colors')
expect(span && diagnosticsInSpan([diagnosticAt(3)], span)).toEqual([])
})
})
Loading
Loading