Use Obsidian fuzzy search for wikilink suggestions

This commit is contained in:
murashit 2026-05-26 16:11:37 +09:00
parent 6ce9852d03
commit 41a96482c7
8 changed files with 121 additions and 36 deletions

View file

@ -33,7 +33,7 @@ export interface ChatComposerControllerOptions {
export class ChatComposerController {
private composer: HTMLTextAreaElement | null = null;
private suggestionsEl: HTMLElement | null = null;
private noteCandidatesCache: NoteCandidate[] | null = null;
private noteCandidatesCache: { sourcePath: string; notes: NoteCandidate[] } | null = null;
private noteEventsRegistered = false;
constructor(private readonly options: ChatComposerControllerOptions) {}
@ -256,7 +256,10 @@ export class ChatComposerController {
}
private noteCandidates(): NoteCandidate[] {
this.noteCandidatesCache ??= appNoteCandidates(this.options.app);
return this.noteCandidatesCache;
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
if (this.noteCandidatesCache?.sourcePath !== sourcePath) {
this.noteCandidatesCache = { sourcePath, notes: appNoteCandidates(this.options.app) };
}
return this.noteCandidatesCache.notes;
}
}

View file

@ -9,10 +9,14 @@ export interface WikiLinkMention {
}
export function noteCandidates(app: App): NoteCandidate[] {
const sourcePath = app.workspace.getActiveFile()?.path ?? "";
const recentPaths = new Map(app.workspace.getLastOpenFiles().map((path, index) => [path, index]));
return app.vault.getMarkdownFiles().map((file) => ({
basename: file.basename,
path: file.path,
mtime: file.stat.mtime,
linktext: app.metadataCache.fileToLinktext(file, sourcePath, true),
recentIndex: recentPaths.get(file.path) ?? null,
}));
}

View file

@ -1,6 +1,7 @@
import type { Model } from "../../../generated/app-server/v2/Model";
import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata";
import type { Thread } from "../../../generated/app-server/v2/Thread";
import { prepareFuzzySearch, sortSearchResults, type SearchResult } from "obsidian";
import {
findModelByIdOrName,
isReasoningEffort,
@ -24,6 +25,16 @@ export interface NoteCandidate {
basename: string;
path: string;
mtime: number;
linktext: string;
recentIndex: number | null;
}
interface NoteCandidateMatch {
file: NoteCandidate;
match: SearchResult;
mtime: number;
basename: string;
path: string;
}
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
@ -94,37 +105,55 @@ export function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandi
export function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] {
const query = queryText.toLowerCase().trim();
const basenameCounts = new Map<string, number>();
for (const file of notes) {
basenameCounts.set(file.basename, (basenameCounts.get(file.basename) ?? 0) + 1);
}
const suggestions = query.length === 0 ? emptyWikiLinkSuggestions(notes) : fuzzyWikiLinkSuggestions(query, notes);
return suggestions.slice(0, 8).map(({ file }) => ({
display: file.basename,
detail: file.path,
replacement: `[[${file.linktext}]]`,
start,
}));
}
function emptyWikiLinkSuggestions(notes: NoteCandidate[]): NoteCandidateMatch[] {
return notes
.filter((file) => file.recentIndex !== null)
.map((file) => ({
file,
match: { score: 0, matches: [] },
mtime: -(file.recentIndex ?? 0),
basename: file.basename,
path: file.path,
}))
.sort(compareWikiLinkSuggestionTiebreakers);
}
function fuzzyWikiLinkSuggestions(query: string, notes: NoteCandidate[]): NoteCandidateMatch[] {
const search = prepareFuzzySearch(query);
const results = notes
.map((file) => {
const basename = file.basename.toLowerCase();
const path = file.path.toLowerCase();
const score = query.length === 0 ? 3 : basename.startsWith(query) ? 0 : basename.includes(query) ? 1 : path.includes(query) ? 2 : -1;
return { file, score };
const basenameMatch = search(file.basename);
const pathMatch = search(file.path);
const match = bestSearchResult(basenameMatch, pathMatch);
return match ? { file, match, mtime: file.mtime, basename: file.basename, path: file.path } : null;
})
.filter((item) => item.score !== -1)
.sort(
(a, b) =>
a.score - b.score ||
b.file.mtime - a.file.mtime ||
a.file.basename.localeCompare(b.file.basename) ||
a.file.path.localeCompare(b.file.path),
)
.slice(0, 8)
.map(({ file }) => {
const duplicateBasename = (basenameCounts.get(file.basename) ?? 0) > 1;
const target = duplicateBasename ? file.path.replace(/\.md$/i, "") : file.basename;
return {
display: file.basename,
detail: file.path,
replacement: `[[${target}]]`,
start,
};
});
.filter((item): item is NoteCandidateMatch => item !== null);
sortSearchResults(results);
return results.sort(compareWikiLinkSuggestionTiebreakers);
}
function bestSearchResult(a: SearchResult | null, b: SearchResult | null): SearchResult | null {
if (!a) return b;
if (!b) return a;
const ranked = [{ match: a }, { match: b }];
sortSearchResults(ranked);
return ranked[0]?.match ?? a;
}
function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCandidateMatch): number {
if (a.match.score !== b.match.score) return 0;
return b.mtime - a.mtime || a.basename.localeCompare(b.basename) || a.path.localeCompare(b.path);
}
export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {

View file

@ -67,9 +67,9 @@ function model(name: string, efforts: ReasoningEffort[], overrides: Partial<Mode
describe("composer suggestions", () => {
const notes = [
{ basename: "Alpha", path: "thoughts/Alpha.md", mtime: 10 },
{ basename: "Alpha", path: "projects/Alpha.md", mtime: 20 },
{ basename: "Beta Note", path: "topics/Beta Note.md", mtime: 30 },
{ basename: "Alpha", path: "thoughts/Alpha.md", mtime: 10, linktext: "thoughts/Alpha", recentIndex: 1 },
{ basename: "Alpha", path: "projects/Alpha.md", mtime: 20, linktext: "projects/Alpha", recentIndex: 0 },
{ basename: "Beta Note", path: "topics/Beta Note.md", mtime: 30, linktext: "Beta Note", recentIndex: null },
];
it("parses supported slash commands only", () => {
@ -88,13 +88,24 @@ describe("composer suggestions", () => {
expect(parseSlashCommand("/unknown")).toBeNull();
});
it("ranks wikilinks and disambiguates duplicate basenames", () => {
it("ranks wikilinks with Obsidian fuzzy search and uses Obsidian linktext", () => {
const suggestions = findWikiLinkSuggestions("alp", 0, notes);
expect(suggestions[0]).toMatchObject({
display: "Alpha",
detail: "projects/Alpha.md",
replacement: "[[projects/Alpha]]",
});
expect(findWikiLinkSuggestions("btnt", 0, notes)[0]).toMatchObject({
display: "Beta Note",
replacement: "[[Beta Note]]",
});
});
it("uses recent files only for empty wikilink suggestions", () => {
expect(findWikiLinkSuggestions("", 0, notes).map((suggestion) => suggestion.replacement)).toEqual([
"[[projects/Alpha]]",
"[[thoughts/Alpha]]",
]);
});
it("uses one active suggestion family at a time", () => {

View file

@ -10,11 +10,12 @@ describe("Obsidian composer context", () => {
{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } },
{ basename: "Beta", path: "Beta.md", stat: { mtime: 200 } },
],
lastOpenFiles: ["Beta.md"],
});
expect(noteCandidates(app)).toEqual([
{ basename: "Alpha", path: "notes/Alpha.md", mtime: 100 },
{ basename: "Beta", path: "Beta.md", mtime: 200 },
{ basename: "Alpha", path: "notes/Alpha.md", mtime: 100, linktext: "notes/Alpha", recentIndex: null },
{ basename: "Beta", path: "Beta.md", mtime: 200, linktext: "Beta", recentIndex: 0 },
]);
});
@ -57,15 +58,19 @@ function appFixture(options: {
activePath?: string;
linkDestination?: TFile | null;
getFirstLinkpathDest?: (target: string, sourcePath: string) => TFile | null;
lastOpenFiles?: string[];
markdownFiles?: { basename: string; path: string; stat: { mtime: number } }[];
abstractFiles?: Map<string, TFile>;
}): App {
return {
workspace: {
getActiveFile: () => (options.activePath ? { path: options.activePath } : null),
getLastOpenFiles: () => options.lastOpenFiles ?? [],
},
metadataCache: {
getFirstLinkpathDest: options.getFirstLinkpathDest ?? (() => options.linkDestination ?? null),
fileToLinktext: (file: TFile, _sourcePath: string, omitMdExtension?: boolean) =>
omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path,
},
vault: {
getMarkdownFiles: () => options.markdownFiles ?? [],

View file

@ -791,6 +791,7 @@ async function chatView(
app: {
workspace: {
getActiveFile: vi.fn(() => null),
getLastOpenFiles: vi.fn(() => []),
on: vi.fn((eventName: string, callback: (leaf: unknown) => void) => {
if (eventName === "active-leaf-change") options.activeLeafChangeListeners?.push(callback);
return {};

View file

@ -339,6 +339,7 @@ function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) {
app: {
workspace: {
getActiveFile: vi.fn(() => null),
getLastOpenFiles: vi.fn(() => []),
on: vi.fn(() => ({})),
openLinkText: vi.fn(),
requestSaveLayout: vi.fn(),

View file

@ -58,6 +58,37 @@ export class Notice {
}
}
export function prepareFuzzySearch(query: string): (text: string) => { score: number; matches: unknown[] } | null {
const normalizedQuery = query.toLowerCase();
return (text: string) => {
const normalizedText = text.toLowerCase();
if (normalizedQuery.length === 0) return { score: 0, matches: [] };
const startsAt = normalizedText.indexOf(normalizedQuery);
if (startsAt !== -1) {
return { score: 10_000 - startsAt * 10 - normalizedText.length, matches: [[startsAt, startsAt + normalizedQuery.length]] };
}
let textIndex = 0;
let firstMatch = -1;
let lastMatch = -1;
for (const char of normalizedQuery) {
const foundAt = normalizedText.indexOf(char, textIndex);
if (foundAt === -1) return null;
if (firstMatch === -1) firstMatch = foundAt;
lastMatch = foundAt;
textIndex = foundAt + 1;
}
const spread = lastMatch - firstMatch;
return { score: 5_000 - firstMatch * 10 - spread - normalizedText.length, matches: [] };
};
}
export function sortSearchResults(results: { match: { score: number } }[]): void {
results.sort((a, b) => b.match.score - a.match.score);
}
export class Modal {
readonly contentEl: HTMLElement;