mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-28 22:16:37 +00:00
fix(editor): mark-boundary-safe inline scan shared by highlights + cycle
A {{.Var}} (or <tag>) split across text nodes by a mark — e.g. a user
styles one brace — was missed by the per-text-node scan: the cycle then
inserted a duplicate while the panel still counted it used, and the
highlight silently dropped on the fragment.
- new collectInlineMatches (editor-inline-scan.ts) scans each textblock's
concatenated inline text and maps offsets back to doc positions, so a
split token reunites in one block string.
- VariableHighlight, TagHighlight and findVariableOccurrences all use it.
- share one variableUseRegex + VARIABLE_RE between the editor and
settings-prompt (countVariableUses + the plain-mode cycle), replacing the
hand-synced duplicate regexes.
- test: a brace-styled {{.Foo}} is now found (was 0 before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
55f105ffa1
commit
69f86ae566
@@ -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;
|
||||
};
|
||||
@@ -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 (<container_environment>, </language_policy>, <specialist name="x">)
|
||||
// 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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof humanFormSchema>;
|
||||
|
||||
type SystemFormData = z.infer<typeof systemFormSchema>;
|
||||
|
||||
// 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<string, number> {
|
||||
const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const);
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
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<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
@@ -192,27 +210,6 @@ function FormTextareaItem<T extends FieldValues>({
|
||||
);
|
||||
}
|
||||
|
||||
// .<variable> 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<string, number> {
|
||||
const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const);
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user