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:
parent
4b561d89a8
commit
bbb23958b2
2 changed files with 38 additions and 3 deletions
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue