diff --git a/frontend/src/components/shared/editor-inline-scan.ts b/frontend/src/components/shared/editor-inline-scan.ts new file mode 100644 index 00000000..878b9af2 --- /dev/null +++ b/frontend/src/components/shared/editor-inline-scan.ts @@ -0,0 +1,50 @@ +import type { Node as PMNode } from '@tiptap/pm/model'; + +export interface InlineMatch { + from: number; + text: string; + to: number; +} + +// Find `regex` matches in each textblock's inline text, mapped to document positions. Unlike a per-text-node +// scan, this reunites a token split across text nodes by a mark (e.g. a user styles one brace of `{{.Var}}`, +// so ProseMirror splits it) — the brace and the rest sit in one block string again. ProseMirror positions are +// one per UTF-16 unit and mark-independent, so offset `i` in the block string maps to `blockStart + i`; a +// token never spans a non-text inline node (image/hard-break), so `to = from + length` holds. `regex` MUST be +// global (`/g`). Scanning per textblock — NOT over `doc.textContent` — keeps positions aligned across blocks. +export const collectInlineMatches = (doc: PMNode, regex: RegExp): InlineMatch[] => { + const matches: InlineMatch[] = []; + + doc.descendants((node, pos) => { + if (!node.isTextblock) { + return; // a container — recurse into it to reach its textblocks + } + + let text = ''; + const positions: number[] = []; + let inlinePos = pos + 1; + + node.forEach((child) => { + if (child.isText && child.text) { + for (let i = 0; i < child.text.length; i += 1) { + text += child.text[i]; + positions.push(inlinePos + i); + } + } + + inlinePos += child.nodeSize; + }); + + for (const match of text.matchAll(regex)) { + const from = positions[match.index ?? 0]; + + if (from !== undefined) { + matches.push({ from, text: match[0], to: from + match[0].length }); + } + } + + return false; // inline content handled — don't recurse into the text nodes + }); + + return matches; +}; diff --git a/frontend/src/components/shared/editor-tag-highlight.ts b/frontend/src/components/shared/editor-tag-highlight.ts index b9ce2864..cc535cc9 100644 --- a/frontend/src/components/shared/editor-tag-highlight.ts +++ b/frontend/src/components/shared/editor-tag-highlight.ts @@ -4,6 +4,8 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { collectInlineMatches } from './editor-inline-scan'; + // Highlights xml-like tags (, , ) // as VIEW-ONLY decorations — same byte-neutral approach as editor-variable-highlight. A node would // have to tell a structural tag from a tag NAME quoted inline as documentation (the prompts do both, @@ -14,23 +16,11 @@ const tagHighlightKey = new PluginKey('tagHighlight'); // `[^<>]` for the attribute span keeps the scan linear and stops at the next angle bracket. export const TAG_RE = /<\/?[a-zA-Z][\w-]*(?:\s[^<>]*)?\/?>/g; -const buildDecorations = (doc: PMNode): DecorationSet => { - const decorations: Decoration[] = []; - - doc.descendants((node, pos) => { - if (!node.isText || !node.text) { - return; - } - - for (const match of node.text.matchAll(TAG_RE)) { - const from = pos + (match.index ?? 0); - - decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-tag' })); - } - }); - - return DecorationSet.create(doc, decorations); -}; +const buildDecorations = (doc: PMNode): DecorationSet => + DecorationSet.create( + doc, + collectInlineMatches(doc, TAG_RE).map(({ from, to }) => Decoration.inline(from, to, { class: 'template-tag' })), + ); export const TagHighlight = Extension.create({ addProseMirrorPlugins() { diff --git a/frontend/src/components/shared/editor-variable-highlight.ts b/frontend/src/components/shared/editor-variable-highlight.ts index c5fc8863..3308fd97 100644 --- a/frontend/src/components/shared/editor-variable-highlight.ts +++ b/frontend/src/components/shared/editor-variable-highlight.ts @@ -4,55 +4,32 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; -// Highlights Go-template actions ({{.Var}}, {{- if .X}}, {{end}}, {{.A | upper}}, …) as -// VIEW-ONLY decorations. Decorations never touch the document, so getMarkdown() stays -// byte-identical — and {{ }} already round-trips verbatim (prosemirror-markdown esc() -// escapes only ` * \ ~ [ ] _, tiptap-markdown escapeHTML only < >; neither touches { }). -// A node/mark would gain nothing here and would break the variables side-panel, which -// inserts {{.X}} as plain text and cycles uses by regex over the serialized string. +import { collectInlineMatches } from './editor-inline-scan'; + +// Highlights Go-template actions ({{.Var}}, {{- if .X}}, {{end}}, {{.A | upper}}, …) as VIEW-ONLY +// decorations. Decorations never touch the document, so getMarkdown() stays byte-identical, and {{ }} +// already round-trips verbatim. A node/mark would gain nothing here and would break the variables +// side-panel, which inserts {{.X}} as plain text and finds its uses by scanning, not by node identity. const variableHighlightKey = new PluginKey('variableHighlight'); // `[^{}]` keeps the scan linear (no catastrophic backtracking); Go actions never nest braces. export const VARIABLE_RE = /\{\{[^{}]*\}\}/g; -// Scan per text node — NOT over doc.textContent — or inline positions misalign across blocks. -const buildDecorations = (doc: PMNode): DecorationSet => { - const decorations: Decoration[] = []; +// Matches one variable's `{{ … .Name … }}` use. Shared with settings-prompt.tsx (panel cycle + count) so the +// panel's "used" badge and the editor cycle agree on what counts as a use. +export const variableUseRegex = (variable: string): RegExp => + new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`, 'g'); - doc.descendants((node, pos) => { - if (!node.isText || !node.text) { - return; - } +const buildDecorations = (doc: PMNode): DecorationSet => + DecorationSet.create( + doc, + collectInlineMatches(doc, VARIABLE_RE).map(({ from, to }) => + Decoration.inline(from, to, { class: 'template-variable' }), + ), + ); - for (const match of node.text.matchAll(VARIABLE_RE)) { - const from = pos + (match.index ?? 0); - - decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-variable' })); - } - }); - - return DecorationSet.create(doc, decorations); -}; - -// Regex mirrors settings-prompt.tsx countVariableUses so the panel's "used" badge and this cycle agree. -export const findVariableOccurrences = (doc: PMNode, variable: string): { from: number; to: number }[] => { - const pattern = new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`, 'g'); - const occurrences: { from: number; to: number }[] = []; - - doc.descendants((node, pos) => { - if (!node.isText || !node.text) { - return; - } - - for (const match of node.text.matchAll(pattern)) { - const from = pos + (match.index ?? 0); - - occurrences.push({ from, to: from + match[0].length }); - } - }); - - return occurrences; -}; +export const findVariableOccurrences = (doc: PMNode, variable: string): { from: number; to: number }[] => + collectInlineMatches(doc, variableUseRegex(variable)).map(({ from, to }) => ({ from, to })); export const VariableHighlight = Extension.create({ addProseMirrorPlugins() { diff --git a/frontend/src/components/shared/markdown-editor-extensions.test.ts b/frontend/src/components/shared/markdown-editor-extensions.test.ts index ddacdd6e..5f27997a 100644 --- a/frontend/src/components/shared/markdown-editor-extensions.test.ts +++ b/frontend/src/components/shared/markdown-editor-extensions.test.ts @@ -158,4 +158,19 @@ describe('findVariableOccurrences — doc spans for the Available-variables cycl expect(findVariableOccurrences(doc, 'Enabled')).toHaveLength(1); }); + + it('finds a {{.Var}} split across text nodes by a mark (mark-boundary fix)', () => { + const doc = docOf('**{{**.Foo}} tail'); + + // sanity — the variable really is split: bolding a brace gives the textblock >1 inline child + expect(doc.firstChild?.childCount ?? 0).toBeGreaterThan(1); + + const foo = findVariableOccurrences(doc, 'Foo'); + + expect(foo).toHaveLength(1); + + for (const hit of foo) { + expect(doc.textBetween(hit.from, hit.to)).toBe('{{.Foo}}'); + } + }); }); diff --git a/frontend/src/pages/settings/settings-prompt.tsx b/frontend/src/pages/settings/settings-prompt.tsx index 8c6d205f..4c2fe70b 100644 --- a/frontend/src/pages/settings/settings-prompt.tsx +++ b/frontend/src/pages/settings/settings-prompt.tsx @@ -52,6 +52,7 @@ import { AppHeaderTitle, } from '@/components/layouts/app/app-header'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { VARIABLE_RE, variableUseRegex } from '@/components/shared/editor-variable-highlight'; import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -122,6 +123,23 @@ type HumanFormData = z.infer; type SystemFormData = z.infer; +// One pass over the `{{ … }}` blocks — the naive per-variable `.match` over the whole template is +// O(variables × length) on every keystroke. +function countVariableUses(template: string, variables: string[]): Record { + const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const); + const counts: Record = {}; + + for (const block of template.match(VARIABLE_RE) ?? []) { + for (const [variable, probe] of probes) { + if (probe.test(block)) { + counts[variable] = (counts[variable] ?? 0) + 1; + } + } + } + + return counts; +} + function FormCodeItem({ control, disabled, @@ -192,27 +210,6 @@ function FormTextareaItem({ ); } -// . used in any {{ }} form (bare, if/range/with, nested) — intentionally broader than bare {{.X}}. -// `[^{}]` (not `[^}]`) keeps a stray unclosed `{{` from driving quadratic backtracking across the whole template. -const variableActionRegex = (variable: string): RegExp => new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`); - -// One pass over the `{{ … }}` blocks — the naive per-variable `.match` over the whole template is -// O(variables × length) on every keystroke. -function countVariableUses(template: string, variables: string[]): Record { - const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const); - const counts: Record = {}; - - for (const block of template.match(/\{\{[^{}]*\}\}/g) ?? []) { - for (const [variable, probe] of probes) { - if (probe.test(block)) { - counts[variable] = (counts[variable] ?? 0) + 1; - } - } - } - - return counts; -} - // Pixel offset of `position` from the textarea content top, measured via a hidden mirror so // soft-wrapped lines count — a logical-line count undershoots the scroll badly for wrapped templates. const caretOffsetTop = (textarea: HTMLTextAreaElement, position: number): number => { @@ -320,7 +317,7 @@ function SettingsPrompt() { if (textarea) { const currentValue = field.value || ''; const variablePattern = `{{.${variable}}}`; - const matches = [...currentValue.matchAll(new RegExp(variableActionRegex(variable).source, 'g'))]; + const matches = [...currentValue.matchAll(variableUseRegex(variable))]; if (matches.length > 0) { const { selectionEnd, selectionStart } = textarea;