ui(alerts): MustacheEditor — completion consumes existing }} instead of duplicating
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:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
import { CompletionContext } from '@codemirror/autocomplete';
|
import { CompletionContext } from '@codemirror/autocomplete';
|
||||||
import { mustacheCompletionSource } from './mustache-completion';
|
import { mustacheCompletionSource } from './mustache-completion';
|
||||||
import { availableVariables } from './alert-variables';
|
import { availableVariables } from './alert-variables';
|
||||||
@@ -9,6 +10,28 @@ function makeContext(doc: string, pos: number): CompletionContext {
|
|||||||
return new CompletionContext(state, pos, true);
|
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', () => {
|
describe('mustacheCompletionSource', () => {
|
||||||
const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC'));
|
const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC'));
|
||||||
|
|
||||||
@@ -41,4 +64,19 @@ describe('mustacheCompletionSource', () => {
|
|||||||
// ROUTE_METRIC does not include exchange.* — expect no exchange. completions
|
// ROUTE_METRIC does not include exchange.* — expect no exchange. completions
|
||||||
expect(result.options).toHaveLength(0);
|
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}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ export function mustacheCompletionSource(variables: readonly AlertVariable[]) {
|
|||||||
? `${v.description} (may be null) · e.g. ${v.sampleValue}`
|
? `${v.description} (may be null) · e.g. ${v.sampleValue}`
|
||||||
: `${v.description} · e.g. ${v.sampleValue}`,
|
: `${v.description} · e.g. ${v.sampleValue}`,
|
||||||
// Inserting closes the Mustache tag; CM will remove the partial prefix.
|
// 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) => {
|
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}}}`;
|
const insert = `${v.path}}}`;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: completionFrom, to, insert },
|
changes: { from: completionFrom, to: to + consumeExisting, insert },
|
||||||
selection: { anchor: completionFrom + insert.length },
|
selection: { anchor: completionFrom + insert.length },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user