Suggest non-markdown vault links

This commit is contained in:
murashit 2026-05-27 13:22:37 +09:00
parent b39ff24bbd
commit ffccdef769
7 changed files with 170 additions and 12 deletions

View file

@ -11,11 +11,12 @@ 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) => ({
return app.vault.getFiles().map((file) => ({
basename: file.basename,
displayName: displayNameForFile(file),
path: file.path,
mtime: file.stat.mtime,
linktext: app.metadataCache.fileToLinktext(file, sourcePath, true),
linktext: linktextForFile(app, file, sourcePath),
recentIndex: recentPaths.get(file.path) ?? null,
}));
}
@ -30,3 +31,15 @@ export function resolveWikiLinkMention(app: App, target: string): WikiLinkMentio
if (abstractFile instanceof TFile) return { name: abstractFile.basename, path: abstractFile.path };
return null;
}
function linktextForFile(app: App, file: TFile, sourcePath: string): string {
const linktext = app.metadataCache.fileToLinktext(file, sourcePath, true);
const extension = file.extension.toLowerCase();
return extension === "md" || extension.length === 0 || linktext.toLowerCase().endsWith(`.${extension}`)
? linktext
: `${linktext}.${file.extension}`;
}
function displayNameForFile(file: TFile): string {
return file.extension === "md" ? file.basename : file.name;
}

View file

@ -23,6 +23,7 @@ export interface ComposerSuggestion {
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
@ -108,7 +109,7 @@ export function findWikiLinkSuggestions(queryText: string, start: number, notes:
const suggestions = query.length === 0 ? emptyWikiLinkSuggestions(notes) : fuzzyWikiLinkSuggestions(query, notes);
return suggestions.slice(0, 8).map(({ file }) => ({
display: file.basename,
display: file.displayName,
detail: file.path,
replacement: `[[${file.linktext}]]`,
start,

View file

@ -12,6 +12,7 @@ import {
nextComposerSuggestionIndex,
parseSlashCommand,
} from "../../../../src/features/chat/composer/suggestions";
import { userInputWithWikiLinkMentions } from "../../../../src/features/chat/composer/wikilink-context";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
@ -67,9 +68,33 @@ function model(name: string, efforts: ReasoningEffort[], overrides: Partial<Mode
describe("composer suggestions", () => {
const notes = [
{ 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 },
{ basename: "Alpha", displayName: "Alpha", path: "thoughts/Alpha.md", mtime: 10, linktext: "thoughts/Alpha", recentIndex: 1 },
{ basename: "Alpha", displayName: "Alpha", path: "projects/Alpha.md", mtime: 20, linktext: "projects/Alpha", recentIndex: 0 },
{ basename: "Beta Note", displayName: "Beta Note", path: "topics/Beta Note.md", mtime: 30, linktext: "Beta Note", recentIndex: null },
{
basename: "Projects",
displayName: "Projects.base",
path: "Bases/Projects.base",
mtime: 40,
linktext: "Bases/Projects.base",
recentIndex: null,
},
{
basename: "Paper",
displayName: "Paper.pdf",
path: "References/Paper.pdf",
mtime: 50,
linktext: "References/Paper.pdf",
recentIndex: null,
},
{
basename: "Diagram",
displayName: "Diagram.png",
path: "Assets/Diagram.png",
mtime: 60,
linktext: "Assets/Diagram.png",
recentIndex: 2,
},
];
it("parses supported slash commands only", () => {
@ -105,6 +130,38 @@ describe("composer suggestions", () => {
expect(findWikiLinkSuggestions("", 0, notes).map((suggestion) => suggestion.replacement)).toEqual([
"[[projects/Alpha]]",
"[[thoughts/Alpha]]",
"[[Assets/Diagram.png]]",
]);
});
it("suggests non-markdown vault files when filtering wikilinks", () => {
expect(findWikiLinkSuggestions("projects", 0, notes)[0]).toMatchObject({
display: "Projects.base",
detail: "Bases/Projects.base",
replacement: "[[Bases/Projects.base]]",
});
expect(findWikiLinkSuggestions("paper", 0, notes)[0]).toMatchObject({
display: "Paper.pdf",
detail: "References/Paper.pdf",
replacement: "[[References/Paper.pdf]]",
});
});
it("keeps non-markdown wikilink completions compatible with mention parsing", () => {
const suggestion = expectPresent(findWikiLinkSuggestions("diagram", 0, notes)[0]);
const text = `Please inspect ${suggestion.replacement}`;
const input = userInputWithWikiLinkMentions(text, (target) =>
target === "Assets/Diagram.png" ? { name: "Diagram", path: "Assets/Diagram.png" } : null,
);
expect(suggestion).toMatchObject({
display: "Diagram.png",
detail: "Assets/Diagram.png",
replacement: "[[Assets/Diagram.png]]",
});
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "mention", name: "Diagram", path: "Assets/Diagram.png" },
]);
});

View file

@ -6,28 +6,69 @@ import { noteCandidates, resolveWikiLinkMention } from "../../../../src/features
describe("Obsidian composer context", () => {
it("builds note candidates from markdown files", () => {
const app = appFixture({
markdownFiles: [
files: [
{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } },
{ basename: "Beta", path: "Beta.md", stat: { mtime: 200 } },
{ basename: "Daily", path: "Personal/Daily Notes/Daily.md", stat: { mtime: 300 } },
{ basename: "Projects", path: "Bases/Projects.base", stat: { mtime: 400 } },
{ basename: "Paper", path: "References/Paper.pdf", stat: { mtime: 500 } },
{ basename: "Diagram", path: "Assets/Diagram.png", stat: { mtime: 600 } },
{ basename: "LICENSE", path: "Attachments/LICENSE", stat: { mtime: 700 } },
],
lastOpenFiles: ["Beta.md"],
linktexts: new Map([
["notes/Alpha.md", "Alpha"],
["Personal/Daily Notes/Daily.md", "Personal/Daily Notes/Daily"],
["Bases/Projects.base", "Bases/Projects"],
["References/Paper.pdf", "References/Paper"],
["Assets/Diagram.png", "Assets/Diagram.png"],
["Attachments/LICENSE", "Attachments/LICENSE"],
]),
});
expect(noteCandidates(app)).toEqual([
{ basename: "Alpha", path: "notes/Alpha.md", mtime: 100, linktext: "Alpha", recentIndex: null },
{ basename: "Beta", path: "Beta.md", mtime: 200, linktext: "Beta", recentIndex: 0 },
{ basename: "Alpha", displayName: "Alpha", path: "notes/Alpha.md", mtime: 100, linktext: "Alpha", recentIndex: null },
{ basename: "Beta", displayName: "Beta", path: "Beta.md", mtime: 200, linktext: "Beta", recentIndex: 0 },
{
basename: "Daily",
displayName: "Daily",
path: "Personal/Daily Notes/Daily.md",
mtime: 300,
linktext: "Personal/Daily Notes/Daily",
recentIndex: null,
},
{
basename: "Projects",
displayName: "Projects.base",
path: "Bases/Projects.base",
mtime: 400,
linktext: "Bases/Projects.base",
recentIndex: null,
},
{
basename: "Paper",
displayName: "Paper.pdf",
path: "References/Paper.pdf",
mtime: 500,
linktext: "References/Paper.pdf",
recentIndex: null,
},
{
basename: "Diagram",
displayName: "Diagram.png",
path: "Assets/Diagram.png",
mtime: 600,
linktext: "Assets/Diagram.png",
recentIndex: null,
},
{
basename: "LICENSE",
displayName: "LICENSE",
path: "Attachments/LICENSE",
mtime: 700,
linktext: "Attachments/LICENSE",
recentIndex: null,
},
]);
});
@ -64,6 +105,18 @@ describe("Obsidian composer context", () => {
expect(resolveWikiLinkMention(app, "Project")).toEqual({ name: "Project", path: "notes/Project.md" });
expect(getFirstLinkpathDest).toHaveBeenCalledWith("Project", "Daily/Today.md");
});
it("resolves non-markdown wikilinks through Obsidian metadata", () => {
const linked = tFile("Bases/Projects.base", "Projects");
const getFirstLinkpathDest = vi.fn(() => linked);
const app = appFixture({
activePath: "Daily/Today.md",
getFirstLinkpathDest,
});
expect(resolveWikiLinkMention(app, "Bases/Projects.base")).toEqual({ name: "Projects", path: "Bases/Projects.base" });
expect(getFirstLinkpathDest).toHaveBeenCalledWith("Bases/Projects.base", "Daily/Today.md");
});
});
function appFixture(options: {
@ -71,7 +124,7 @@ function appFixture(options: {
linkDestination?: TFile | null;
getFirstLinkpathDest?: (target: string, sourcePath: string) => TFile | null;
lastOpenFiles?: string[];
markdownFiles?: { basename: string; path: string; stat: { mtime: number } }[];
files?: { basename: string; path: string; stat: { mtime: number } }[];
abstractFiles?: Map<string, TFile>;
linktexts?: Map<string, string>;
}): App {
@ -86,12 +139,20 @@ function appFixture(options: {
options.linktexts?.get(file.path) ?? (omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path),
},
vault: {
getMarkdownFiles: () => options.markdownFiles ?? [],
getFiles: () => vaultFiles(options.files ?? []),
getMarkdownFiles: () => vaultFiles(options.files ?? []).filter((file) => file.path.toLowerCase().endsWith(".md")),
getAbstractFileByPath: (path: string) => options.abstractFiles?.get(path) ?? null,
},
} as unknown as App;
}
function tFile(path: string, basename: string): TFile {
return Object.assign(new TFile(), { path, basename });
const name = path.split("/").pop() ?? path;
const extensionStart = name.lastIndexOf(".");
const extension = extensionStart === -1 ? "" : name.slice(extensionStart + 1);
return Object.assign(new TFile(), { path, basename, extension, name });
}
function vaultFiles(files: { basename: string; path: string; stat: { mtime: number } }[]): TFile[] {
return files.map((file) => Object.assign(tFile(file.path, file.basename), { stat: file.stat }));
}

View file

@ -29,6 +29,30 @@ describe("wikilink context", () => {
expect(input).toHaveLength(2);
});
it("resolves aliases and subpaths from non-markdown wikilinks by target", () => {
const text = "Open [[Bases/Projects.base|Projects]], [[References/Paper.pdf]], and [[Assets/Diagram.png#crop|Diagram]].";
const input = userInputWithWikiLinkMentions(text, (target) => {
const mentions = new Map([
["Bases/Projects.base", { name: "Projects", path: "Bases/Projects.base" }],
["References/Paper.pdf", { name: "Paper", path: "References/Paper.pdf" }],
["Assets/Diagram.png", { name: "Diagram", path: "Assets/Diagram.png" }],
]);
return mentions.get(target) ?? null;
});
expect(parsedWikiLinks(text)).toEqual([
{ raw: "Bases/Projects.base|Projects", target: "Bases/Projects.base", subpath: "", display: "Projects" },
{ raw: "References/Paper.pdf", target: "References/Paper.pdf", subpath: "", display: "" },
{ raw: "Assets/Diagram.png#crop|Diagram", target: "Assets/Diagram.png", subpath: "#crop", display: "Diagram" },
]);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "mention", name: "Projects", path: "Bases/Projects.base" },
{ type: "mention", name: "Paper", path: "References/Paper.pdf" },
{ type: "mention", name: "Diagram", path: "Assets/Diagram.png" },
]);
});
it("deduplicates mentions by resolved path", () => {
const text = "Read [[Alpha]], [[Alpha#Heading]], and [[Alias|A]].";
const input = userInputWithWikiLinkMentions(text, (target) =>

View file

@ -801,6 +801,7 @@ async function chatView(
},
vault: {
on: vi.fn(() => ({})),
getFiles: vi.fn(() => []),
getMarkdownFiles: vi.fn(() => []),
},
},

View file

@ -346,6 +346,7 @@ function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) {
},
vault: {
on: vi.fn(() => ({})),
getFiles: vi.fn(() => []),
getMarkdownFiles: vi.fn(() => []),
},
},