diff --git a/web/src/components/MemoEditor/Editor/tagAutocomplete.ts b/web/src/components/MemoEditor/Editor/tagAutocomplete.ts index 0574ac37..73c0d36a 100644 --- a/web/src/components/MemoEditor/Editor/tagAutocomplete.ts +++ b/web/src/components/MemoEditor/Editor/tagAutocomplete.ts @@ -3,6 +3,23 @@ import type { Extension } from "@codemirror/state"; import { isTagIntroducerAt } from "@/utils/tag-grammar"; import { isLiteralTagPosition, tagMatchBefore } from "./markdownTagRanges"; +/** + * Ranks a candidate tag against the typed text (both already lower-cased). + * Lower sorts first; `undefined` means the candidate does not match. + * + * Nested tags are paths, so a match inside the path counts: typing `mem` has to + * reach `software/hosted/Memos`, which a full-path prefix test cannot do. The + * tiers keep that from turning into noise — a segment start outranks an + * incidental hit in the middle of a word. + */ +const matchRank = (tag: string, typed: string): number | undefined => { + const index = tag.indexOf(typed); + if (index < 0) return undefined; + if (index === 0) return 0; // Full-path prefix. + if (tag[index - 1] === "/") return 1; // Path-segment start. + return 2; // Anywhere else. +}; + export function makeTagCompletionSource(getTags: () => string[]) { return (ctx: CompletionContext): CompletionResult | null => { const match = tagMatchBefore(ctx.state, ctx.pos); @@ -13,10 +30,15 @@ export function makeTagCompletionSource(getTags: () => string[]) { const typed = (match?.value ?? "").toLowerCase(); const options = getTags() - .filter((tag) => tag.toLowerCase().startsWith(typed)) - .map((tag) => ({ label: tag, type: "keyword" })); + .map((tag) => ({ tag, rank: matchRank(tag.toLowerCase(), typed) })) + .filter((candidate): candidate is { tag: string; rank: number } => candidate.rank !== undefined) + // Stable sort, so tags keep their incoming order within a tier. + .sort((a, b) => a.rank - b.rank) + .map(({ tag }) => ({ label: tag, type: "keyword" })); if (options.length === 0) return null; - return { from: match ? match.from + 1 : ctx.pos, options }; + // `filter: false` keeps this ranking: CodeMirror would otherwise re-filter + // and re-score the options with its own fuzzy matcher. + return { from: match ? match.from + 1 : ctx.pos, options, filter: false }; }; } diff --git a/web/tests/editor-tag-autocomplete.test.ts b/web/tests/editor-tag-autocomplete.test.ts index 16e36ef3..a801c546 100644 --- a/web/tests/editor-tag-autocomplete.test.ts +++ b/web/tests/editor-tag-autocomplete.test.ts @@ -17,6 +17,19 @@ describe("tag autocomplete", () => { expect(result?.options.map((o) => o.label)).toEqual(["todo", "today"]); }); + it("matches a segment of a nested tag path", () => { + expect(complete("hello #Mem", 10, ["software/hosted/Memos"])?.options.map((o) => o.label)).toEqual(["software/hosted/Memos"]); + }); + + it("ranks full-path prefixes above segment starts above loose substrings", () => { + const result = complete("#work", 5, ["home/paperwork", "team/work-log", "work/project"]); + expect(result?.options.map((o) => o.label)).toEqual(["work/project", "team/work-log", "home/paperwork"]); + }); + + it("keeps its own ranking instead of CodeMirror's fuzzy filter", () => { + expect(complete("#work", 5, ["work"])?.filter).toBe(false); + }); + it("returns null on a bare # with nothing typed", () => { expect(complete("hello #", 7, ["todo"])).toBeNull(); });