fix(completion): gate block headers behind a blank line

The popup offered [[feature]]/[[milestone]] on every fresh line — right
under the last property or straight after a header. Only volunteer them
once there's a blank line above (the "two new lines" convention); typing
a bare `[` still summons them anywhere. Property keys are unchanged.
This commit is contained in:
Julien Calixte
2026-07-02 09:36:53 +02:00
parent db77314a88
commit 449be7766e
2 changed files with 40 additions and 9 deletions

View File

@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
} }
describe("completion context", () => { describe("completion context", () => {
it("offers plan keys and block headers on an empty top-level line", () => { it("offers plan keys on an empty top-level line", () => {
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"]) // Headers wait for a blank line above; at the very top there's nothing above.
expect(labelsAt("|")).toEqual(["title", "start", "end"])
}) })
it("offers feature keys inside a [[feature]] block", () => { it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
expect(labelsAt("[[feature]]\n|")).toEqual([ expect(labelsAt("[[feature]]\n|")).toEqual([
"name", "name",
"start", "start",
@@ -28,14 +29,27 @@ describe("completion context", () => {
"status", "status",
"learning", "learning",
"note", "note",
"[[feature]]",
"[[milestone]]",
]) ])
}) })
it("offers milestone keys inside a [[milestone]] block", () => { it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual([ expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
"name", })
it("keeps offering remaining keys on a single new line, still without headers", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
})
it("volunteers block headers only after a blank line (two new lines)", () => {
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
// Directly under the last property: nothing left to add ⇒ no popup.
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
// A blank line signals a new block is wanted ⇒ offer the headers.
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
})
it("volunteers headers after a blank line even with keys still open", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
"week", "week",
"requires", "requires",
"[[feature]]", "[[feature]]",

View File

@@ -108,7 +108,16 @@ export function getCompletions(
const keys = const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
const taken = presentKeys(source, lineStart, block) const taken = presentKeys(source, lineStart, block)
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word) const remaining = keys.filter((k) => !taken.has(k.label))
// Volunteer a new block header only once there's a blank line above: a new
// [[…]] wants a gap before it, so offering one right under the last property
// (or straight after the header line) pops the menu up too eagerly. Typing a
// bare `[` still summons headers anywhere (handled above). With all keys set
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
const items = filter(
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
word,
)
return result(caret - word.length, caret, items) return result(caret - word.length, caret, items)
} }
@@ -119,6 +128,14 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : null return items.length ? { from, to, items } : null
} }
/** Is the line directly above the caret's line blank (empty or whitespace only)?
* Gates header suggestions to the "two new lines" rule — see the key branch. */
function blankLineAbove(source: string, lineStart: number): boolean {
if (lineStart === 0) return false // top of the document — nothing above
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
}
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see, /** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */ * not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
function todayYmd(): string { function todayYmd(): string {