From 6f78d0a513dc43640d1b90c94172eb073def922b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:12:56 +0200 Subject: [PATCH] =?UTF-8?q?ui(alerts):=20MustacheEditor=20=E2=80=94=20comp?= =?UTF-8?q?letion=20consumes=20existing=20`}}`=20instead=20of=20duplicatin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeBrackets auto-inserts `}}` when the user types `{{`, so the buffer already reads `{{}}` before a completion is accepted. The apply callback was unconditionally appending another `}}`, producing `{{path}}}}` (valid Mustache but obviously wrong). Fix: peek at the two characters immediately after the completion range and, when they're `}}`, extend the replacement range by two so the existing closing braces are overwritten rather than left in place. Added a regression test that drives `apply` through a real EditorView for both the bare-prefix (no trailing `}}`) and auto-closed (`{{prefix}}`) scenarios. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mustache-completion.test.ts | 38 +++++++++++++++++++ .../MustacheEditor/mustache-completion.ts | 8 +++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ui/src/components/MustacheEditor/mustache-completion.test.ts b/ui/src/components/MustacheEditor/mustache-completion.test.ts index 13ae845c..9858d190 100644 --- a/ui/src/components/MustacheEditor/mustache-completion.test.ts +++ b/ui/src/components/MustacheEditor/mustache-completion.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; import { CompletionContext } from '@codemirror/autocomplete'; import { mustacheCompletionSource } from './mustache-completion'; import { availableVariables } from './alert-variables'; @@ -9,6 +10,28 @@ function makeContext(doc: string, pos: number): CompletionContext { return new CompletionContext(state, pos, true); } +function applyCompletion(doc: string, pos: number, label: string): string { + // Drives the `apply` callback against a real EditorView so we exercise the + // same code path CodeMirror invokes when the user selects a completion. + const host = document.createElement('div'); + const view = new EditorView({ + parent: host, + state: EditorState.create({ doc }), + }); + view.dispatch({ selection: { anchor: pos } }); + const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC')); + const result = source(new CompletionContext(view.state, pos, true))!; + const option = result.options.find((o) => o.label === label); + if (!option || typeof option.apply !== 'function') { + view.destroy(); + throw new Error(`No completion with label ${label} (or missing apply)`); + } + option.apply(view, option, result.from, result.to); + const out = view.state.doc.toString(); + view.destroy(); + return out; +} + describe('mustacheCompletionSource', () => { const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC')); @@ -41,4 +64,19 @@ describe('mustacheCompletionSource', () => { // ROUTE_METRIC does not include exchange.* — expect no exchange. completions expect(result.options).toHaveLength(0); }); + + describe('apply', () => { + it('closes the Mustache tag when no closing braces follow', () => { + // Cursor at end of `{{ale`; completion closes with `}}`. + expect(applyCompletion('{{ale', 5, 'alert.firedAt')).toBe('{{alert.firedAt}}'); + }); + + it('does not duplicate `}}` that closeBrackets already inserted', () => { + // closeBrackets auto-inserts `}}` on `{{`, so the typed state before + // accepting a completion is usually `{{}}` with the cursor + // sitting right before the closing braces. Selecting a completion must + // consume the existing `}}` rather than appending another pair. + expect(applyCompletion('{{ale}}', 5, 'alert.firedAt')).toBe('{{alert.firedAt}}'); + }); + }); }); diff --git a/ui/src/components/MustacheEditor/mustache-completion.ts b/ui/src/components/MustacheEditor/mustache-completion.ts index 9ad74d83..60555377 100644 --- a/ui/src/components/MustacheEditor/mustache-completion.ts +++ b/ui/src/components/MustacheEditor/mustache-completion.ts @@ -24,10 +24,16 @@ export function mustacheCompletionSource(variables: readonly AlertVariable[]) { ? `${v.description} (may be null) · e.g. ${v.sampleValue}` : `${v.description} · e.g. ${v.sampleValue}`, // Inserting closes the Mustache tag; CM will remove the partial prefix. + // closeBrackets auto-inserts `}}` after the user types `{{`, so the + // cursor is usually already sitting before a `}}`. Consume it when + // present so we don't end up with `{{path}}}}`. apply: (view, _completion, completionFrom, to) => { + const docLen = view.state.doc.length; + const afterTo = view.state.doc.sliceString(to, Math.min(to + 2, docLen)); + const consumeExisting = afterTo === '}}' ? 2 : 0; const insert = `${v.path}}}`; view.dispatch({ - changes: { from: completionFrom, to, insert }, + changes: { from: completionFrom, to: to + consumeExisting, insert }, selection: { anchor: completionFrom + insert.length }, }); },