ui(alerts): MustacheEditor — completion consumes existing }} instead of duplicating
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m20s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

closeBrackets auto-inserts `}}` when the user types `{{`, so the buffer
already reads `{{<prefix>}}` 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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 09:12:56 +02:00
parent 1c4a98c0da
commit 6f78d0a513
2 changed files with 45 additions and 1 deletions

View File

@@ -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 `{{<prefix>}}` 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}}');
});
});
});

View File

@@ -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 },
});
},