diff --git a/src/components/completion.test.ts b/src/components/completion.test.ts index 9313079..a86164a 100644 --- a/src/components/completion.test.ts +++ b/src/components/completion.test.ts @@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] { } describe("completion context", () => { - it("offers plan keys and block headers on an empty top-level line", () => { - expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"]) + it("offers plan keys on an empty top-level line", () => { + // 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([ "name", "start", @@ -28,14 +29,27 @@ describe("completion context", () => { "status", "learning", "note", - "[[feature]]", - "[[milestone]]", ]) }) - it("offers milestone keys inside a [[milestone]] block", () => { - expect(labelsAt("[[milestone]]\n|")).toEqual([ - "name", + it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => { + expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"]) + }) + + 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", "requires", "[[feature]]", diff --git a/src/components/completion.ts b/src/components/completion.ts index de36880..9779926 100644 --- a/src/components/completion.ts +++ b/src/components/completion.ts @@ -108,7 +108,16 @@ export function getCompletions( const keys = block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS 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) } @@ -119,6 +128,14 @@ function result(from: number, to: number, items: Completion[]): CompletionContex 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, * not UTC's (which slips a day near midnight). Injectable keeps tests stable. */ function todayYmd(): string {