mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Use Obsidian fuzzy search for wikilink suggestions
This commit is contained in:
parent
6ce9852d03
commit
41a96482c7
8 changed files with 121 additions and 36 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue