diff --git a/src/features/chat/composer/obsidian-context.ts b/src/features/chat/composer/obsidian-context.ts index d030f64d..33447545 100644 --- a/src/features/chat/composer/obsidian-context.ts +++ b/src/features/chat/composer/obsidian-context.ts @@ -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; +} diff --git a/src/features/chat/composer/suggestions.ts b/src/features/chat/composer/suggestions.ts index b4683455..2a59236c 100644 --- a/src/features/chat/composer/suggestions.ts +++ b/src/features/chat/composer/suggestions.ts @@ -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, diff --git a/tests/features/chat/composer/composer-suggestions.test.ts b/tests/features/chat/composer/composer-suggestions.test.ts index bd770d48..681b5719 100644 --- a/tests/features/chat/composer/composer-suggestions.test.ts +++ b/tests/features/chat/composer/composer-suggestions.test.ts @@ -12,6 +12,7 @@ import { nextComposerSuggestionIndex, parseSlashCommand, } from "../../../../src/features/chat/composer/suggestions"; +import { userInputWithWikiLinkMentions } from "../../../../src/features/chat/composer/wikilink-context"; function expectPresent(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 { 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" }, ]); }); diff --git a/tests/features/chat/composer/obsidian-context.test.ts b/tests/features/chat/composer/obsidian-context.test.ts index a37230f2..3e4bff71 100644 --- a/tests/features/chat/composer/obsidian-context.test.ts +++ b/tests/features/chat/composer/obsidian-context.test.ts @@ -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; linktexts?: Map; }): 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 })); } diff --git a/tests/features/chat/composer/wikilink-context.test.ts b/tests/features/chat/composer/wikilink-context.test.ts index f1d065c6..9ff0c5de 100644 --- a/tests/features/chat/composer/wikilink-context.test.ts +++ b/tests/features/chat/composer/wikilink-context.test.ts @@ -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) => diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 329dc73d..58ce2eaa 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -801,6 +801,7 @@ async function chatView( }, vault: { on: vi.fn(() => ({})), + getFiles: vi.fn(() => []), getMarkdownFiles: vi.fn(() => []), }, }, diff --git a/tests/main.test.ts b/tests/main.test.ts index 46004fe0..bd9dd2cd 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -346,6 +346,7 @@ function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) { }, vault: { on: vi.fn(() => ({})), + getFiles: vi.fn(() => []), getMarkdownFiles: vi.fn(() => []), }, },