Support heading wikilink completion

This commit is contained in:
murashit 2026-06-03 22:21:36 +09:00
parent 19c461f52b
commit 78522e6355
6 changed files with 168 additions and 11 deletions

View file

@ -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();

View file

@ -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,
}));
}

View file

@ -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<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">): 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)

View file

@ -68,15 +68,43 @@ function model(name: string, efforts: ReasoningEffort[], overrides: Partial<Mode
describe("composer suggestions", () => {
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, [
{

View file

@ -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<string, TFile>;
linktexts?: Map<string, string>;
headings?: Map<string, { heading: string; level: number }[]>;
}): 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 ?? []),

View file

@ -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("^");