fix(editor): match tag autocomplete on path segments

Tag completion filtered candidates with a full-path prefix test, so a
nested tag was only reachable by retyping its whole ancestry: `#Mem`
offered nothing for `software/hosted/Memos`.

Rank candidates instead — full-path prefix, then path-segment start,
then any other position — and keep that order by opting out of
CodeMirror's own fuzzy filtering.

Fixes #6190
This commit is contained in:
johnnyjoygh 2026-08-16 22:09:04 +08:00
parent 4b561d89a8
commit bbb23958b2
2 changed files with 38 additions and 3 deletions

View file

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

View file

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