From 78522e6355f6ac7bb4bad3acd27d671a450a1207 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 3 Jun 2026 22:21:36 +0900 Subject: [PATCH] Support heading wikilink completion --- src/features/chat/chat-composer-controller.ts | 6 +- .../chat/composer/obsidian-context.ts | 11 ++- src/features/chat/composer/suggestions.ts | 75 ++++++++++++++++++- .../composer/composer-suggestions.test.ts | 63 +++++++++++++++- .../chat/composer/obsidian-context.test.ts | 20 ++++- tests/mocks/obsidian.ts | 4 + 6 files changed, 168 insertions(+), 11 deletions(-) diff --git a/src/features/chat/chat-composer-controller.ts b/src/features/chat/chat-composer-controller.ts index cb84f5fa..5d0f5fc8 100644 --- a/src/features/chat/chat-composer-controller.ts +++ b/src/features/chat/chat-composer-controller.ts @@ -187,7 +187,7 @@ export class ChatComposerController { if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); - this.insertSuggestion(state.composerSuggestions[state.composerSuggestSelected]); + this.insertSuggestion(state.composerSuggestions[state.composerSuggestSelected], event.key === "Tab" ? "tab" : "enter"); return true; } @@ -252,12 +252,12 @@ export class ChatComposerController { this.dispatchSuggestions({ type: "composer/suggestions-set", suggestions: this.state.composerSuggestions, selected: index }, true); } - private insertSuggestion(suggestion: ComposerSuggestion | undefined): void { + private insertSuggestion(suggestion: ComposerSuggestion | undefined, activation: "enter" | "tab" = "enter"): void { if (!this.composer || !suggestion) return; const cursor = this.composer.selectionStart; const value = this.composer.value; - const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion); + const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion, { activation }); this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); this.options.onDraftChange(); diff --git a/src/features/chat/composer/obsidian-context.ts b/src/features/chat/composer/obsidian-context.ts index 33447545..11d2ebec 100644 --- a/src/features/chat/composer/obsidian-context.ts +++ b/src/features/chat/composer/obsidian-context.ts @@ -1,5 +1,5 @@ import type { App } from "obsidian"; -import { TFile } from "obsidian"; +import { stripHeadingForLink, TFile } from "obsidian"; import type { NoteCandidate } from "./suggestions"; @@ -17,6 +17,7 @@ export function noteCandidates(app: App): NoteCandidate[] { path: file.path, mtime: file.stat.mtime, linktext: linktextForFile(app, file, sourcePath), + headings: noteHeadings(app, file), recentIndex: recentPaths.get(file.path) ?? null, })); } @@ -43,3 +44,11 @@ function linktextForFile(app: App, file: TFile, sourcePath: string): string { function displayNameForFile(file: TFile): string { return file.extension === "md" ? file.basename : file.name; } + +function noteHeadings(app: App, file: TFile): NoteCandidate["headings"] { + return (app.metadataCache.getFileCache(file)?.headings ?? []).map((heading) => ({ + heading: heading.heading, + linkHeading: stripHeadingForLink(heading.heading), + level: heading.level, + })); +} diff --git a/src/features/chat/composer/suggestions.ts b/src/features/chat/composer/suggestions.ts index 01e930f9..2029f2fb 100644 --- a/src/features/chat/composer/suggestions.ts +++ b/src/features/chat/composer/suggestions.ts @@ -19,6 +19,8 @@ export interface ComposerSuggestion { replacement: string; start: number; appendSpaceOnInsert?: boolean; + tabCursorOffset?: number; + suffixOnInsert?: string; } export interface NoteCandidate { @@ -27,9 +29,16 @@ export interface NoteCandidate { path: string; mtime: number; linktext: string; + headings: NoteHeadingCandidate[]; recentIndex: number | null; } +export interface NoteHeadingCandidate { + heading: string; + linkHeading: string; + level: number; +} + interface NoteCandidateMatch { file: NoteCandidate; match: SearchResult; @@ -69,12 +78,16 @@ export function applyComposerSuggestionInsertion( value: string, cursor: number, suggestion: ComposerSuggestion, + options: { activation?: "enter" | "tab" } = {}, ): { value: string; cursor: number } { const suffix = value.slice(cursor); const appendSpace = suggestion.appendSpaceOnInsert === true && !suggestion.replacement.endsWith(" ") && !/^\s/.test(suffix); const replacement = `${suggestion.replacement}${appendSpace ? " " : ""}`; - const nextValue = `${value.slice(0, suggestion.start)}${replacement}${suffix}`; - return { value: nextValue, cursor: suggestion.start + replacement.length }; + const suffixStart = + cursor + (suggestion.suffixOnInsert && suffix.startsWith(suggestion.suffixOnInsert) ? suggestion.suffixOnInsert.length : 0); + const nextValue = `${value.slice(0, suggestion.start)}${replacement}${value.slice(suffixStart)}`; + const cursorOffset = options.activation === "tab" ? (suggestion.tabCursorOffset ?? 0) : 0; + return { value: nextValue, cursor: suggestion.start + replacement.length + cursorOffset }; } export function composerSuggestionNavigationDirection(event: Pick): 1 | -1 | null { @@ -105,6 +118,9 @@ export function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandi } export function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] { + const headingCompletion = wikiLinkHeadingCompletion(queryText, start, notes); + if (headingCompletion) return headingCompletion; + const query = queryText.toLowerCase().trim(); const suggestions = query.length === 0 ? emptyWikiLinkSuggestions(notes) : fuzzyWikiLinkSuggestions(query, notes); @@ -113,9 +129,64 @@ export function findWikiLinkSuggestions(queryText: string, start: number, notes: detail: file.path, replacement: `[[${file.linktext}]]`, start, + tabCursorOffset: -2, })); } +function wikiLinkHeadingCompletion(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] | null { + const blockIndex = queryText.indexOf("^"); + if (blockIndex !== -1) return []; + + const headingSeparator = queryText.indexOf("#"); + if (headingSeparator === -1) return null; + + const target = queryText.slice(0, headingSeparator).trim(); + if (!target) return []; + + const note = noteForWikiLinkTarget(target, notes); + if (!note) return []; + + const query = queryText + .slice(headingSeparator + 1) + .trim() + .toLowerCase(); + const headingMatches = query.length === 0 ? note.headings : fuzzyHeadingSuggestions(query, note.headings).map((item) => item.heading); + return headingMatches.slice(0, 8).map((heading) => ({ + display: heading.heading, + detail: `${"#".repeat(heading.level)} ${note.path}`, + replacement: `[[${note.linktext}#${heading.linkHeading}]]`, + start, + suffixOnInsert: "]]", + })); +} + +function noteForWikiLinkTarget(target: string, notes: NoteCandidate[]): NoteCandidate | null { + const normalized = target.toLowerCase(); + return notes.find((note) => wikiLinkTargetAliases(note).some((alias) => alias.toLowerCase() === normalized)) ?? null; +} + +function wikiLinkTargetAliases(note: NoteCandidate): string[] { + const aliases = [note.linktext, note.path, note.basename, note.displayName]; + if (note.path.toLowerCase().endsWith(".md")) aliases.push(note.path.slice(0, -3)); + return aliases; +} + +function fuzzyHeadingSuggestions( + query: string, + headings: readonly NoteHeadingCandidate[], +): { heading: NoteHeadingCandidate; match: SearchResult }[] { + const search = prepareFuzzySearch(query); + const results = headings + .map((heading) => { + const match = search(heading.heading); + return match ? { heading, match } : null; + }) + .filter((item): item is { heading: NoteHeadingCandidate; match: SearchResult } => item !== null); + + sortSearchResults(results); + return results; +} + function emptyWikiLinkSuggestions(notes: NoteCandidate[]): NoteCandidateMatch[] { return notes .filter((file) => file.recentIndex !== null) diff --git a/tests/features/chat/composer/composer-suggestions.test.ts b/tests/features/chat/composer/composer-suggestions.test.ts index 7f8daa69..9f18c113 100644 --- a/tests/features/chat/composer/composer-suggestions.test.ts +++ b/tests/features/chat/composer/composer-suggestions.test.ts @@ -68,15 +68,43 @@ function model(name: string, efforts: ReasoningEffort[], overrides: Partial { const notes = [ - { 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: "Alpha", + displayName: "Alpha", + path: "thoughts/Alpha.md", + mtime: 10, + linktext: "thoughts/Alpha", + headings: [], + recentIndex: 1, + }, + { + basename: "Alpha", + displayName: "Alpha", + path: "projects/Alpha.md", + mtime: 20, + linktext: "projects/Alpha", + headings: [], + recentIndex: 0, + }, + { + basename: "Beta Note", + displayName: "Beta Note", + path: "topics/Beta Note.md", + mtime: 30, + linktext: "Beta Note", + headings: [ + { heading: "Overview", linkHeading: "Overview", level: 1 }, + { heading: "Implementation Details", linkHeading: "Implementation Details", level: 2 }, + ], + recentIndex: null, + }, { basename: "Projects", displayName: "Projects.base", path: "Bases/Projects.base", mtime: 40, linktext: "Bases/Projects.base", + headings: [], recentIndex: null, }, { @@ -85,6 +113,7 @@ describe("composer suggestions", () => { path: "References/Paper.pdf", mtime: 50, linktext: "References/Paper.pdf", + headings: [], recentIndex: null, }, { @@ -93,6 +122,7 @@ describe("composer suggestions", () => { path: "Assets/Diagram.png", mtime: 60, linktext: "Assets/Diagram.png", + headings: [], recentIndex: 2, }, ]; @@ -166,6 +196,29 @@ describe("composer suggestions", () => { ]); }); + it("suggests headings inside a completed wikilink without suggesting block references", () => { + const heading = expectPresent(activeComposerSuggestions("[[Beta Note#impl", notes, [])[0]); + + expect(activeComposerSuggestions("[[Beta Note#", notes, []).map((suggestion) => suggestion.replacement)).toEqual([ + "[[Beta Note#Overview]]", + "[[Beta Note#Implementation Details]]", + ]); + expect(heading).toMatchObject({ + display: "Implementation Details", + detail: "## topics/Beta Note.md", + replacement: "[[Beta Note#Implementation Details]]", + }); + expect(applyComposerSuggestionInsertion("[[Beta Note#impl]]", 16, heading)).toEqual({ + value: "[[Beta Note#Implementation Details]]", + cursor: 36, + }); + expect(applyComposerSuggestionInsertion("[[Beta Note#impl next", 16, heading)).toEqual({ + value: "[[Beta Note#Implementation Details]] next", + cursor: 36, + }); + expect(activeComposerSuggestions("[[Beta Note#^", notes, [])).toEqual([]); + }); + it("uses one active suggestion family at a time", () => { expect(activeComposerSuggestions("[[bet", notes, [])[0]?.replacement).toBe("[[Beta Note]]"); expect(activeComposerSuggestions("/pla", notes, [])[0]?.replacement).toBe("/plan"); @@ -304,6 +357,10 @@ describe("composer suggestions", () => { expect(applyComposerSuggestionInsertion("/sta", 4, slash)).toEqual({ value: "/status ", cursor: 8 }); expect(applyComposerSuggestionInsertion("/sta then", 4, slash)).toEqual({ value: "/status then", cursor: 7 }); expect(applyComposerSuggestionInsertion("[[bet", 5, wikilink)).toEqual({ value: "[[Beta Note]]", cursor: 13 }); + expect(applyComposerSuggestionInsertion("[[bet", 5, wikilink, { activation: "tab" })).toEqual({ + value: "[[Beta Note]]", + cursor: 11, + }); expect( activeComposerSuggestions("$obsidian-dataview-read", notes, [ { diff --git a/tests/features/chat/composer/obsidian-context.test.ts b/tests/features/chat/composer/obsidian-context.test.ts index 3e4bff71..f271e3ff 100644 --- a/tests/features/chat/composer/obsidian-context.test.ts +++ b/tests/features/chat/composer/obsidian-context.test.ts @@ -24,17 +24,27 @@ describe("Obsidian composer context", () => { ["Assets/Diagram.png", "Assets/Diagram.png"], ["Attachments/LICENSE", "Attachments/LICENSE"], ]), + headings: new Map([["Beta.md", [{ heading: "Overview", level: 1 }]]]), }); expect(noteCandidates(app)).toEqual([ - { 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: "Alpha", displayName: "Alpha", path: "notes/Alpha.md", mtime: 100, linktext: "Alpha", headings: [], recentIndex: null }, + { + basename: "Beta", + displayName: "Beta", + path: "Beta.md", + mtime: 200, + linktext: "Beta", + headings: [{ heading: "Overview", linkHeading: "Overview", level: 1 }], + recentIndex: 0, + }, { basename: "Daily", displayName: "Daily", path: "Personal/Daily Notes/Daily.md", mtime: 300, linktext: "Personal/Daily Notes/Daily", + headings: [], recentIndex: null, }, { @@ -43,6 +53,7 @@ describe("Obsidian composer context", () => { path: "Bases/Projects.base", mtime: 400, linktext: "Bases/Projects.base", + headings: [], recentIndex: null, }, { @@ -51,6 +62,7 @@ describe("Obsidian composer context", () => { path: "References/Paper.pdf", mtime: 500, linktext: "References/Paper.pdf", + headings: [], recentIndex: null, }, { @@ -59,6 +71,7 @@ describe("Obsidian composer context", () => { path: "Assets/Diagram.png", mtime: 600, linktext: "Assets/Diagram.png", + headings: [], recentIndex: null, }, { @@ -67,6 +80,7 @@ describe("Obsidian composer context", () => { path: "Attachments/LICENSE", mtime: 700, linktext: "Attachments/LICENSE", + headings: [], recentIndex: null, }, ]); @@ -127,6 +141,7 @@ function appFixture(options: { files?: { basename: string; path: string; stat: { mtime: number } }[]; abstractFiles?: Map; linktexts?: Map; + headings?: Map; }): App { return { workspace: { @@ -137,6 +152,7 @@ function appFixture(options: { getFirstLinkpathDest: options.getFirstLinkpathDest ?? (() => options.linkDestination ?? null), fileToLinktext: (file: TFile, _sourcePath: string, omitMdExtension?: boolean) => options.linktexts?.get(file.path) ?? (omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path), + getFileCache: (file: TFile) => ({ headings: options.headings?.get(file.path) ?? [] }), }, vault: { getFiles: () => vaultFiles(options.files ?? []), diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 52964ef1..98397581 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -89,6 +89,10 @@ export function sortSearchResults(results: { match: { score: number } }[]): void results.sort((a, b) => b.match.score - a.match.score); } +export function stripHeadingForLink(heading: string): string { + return heading.trim(); +} + export function parseLinktext(linktext: string): { path: string; subpath: string } { const headingIndex = linktext.indexOf("#"); const blockIndex = linktext.indexOf("^");