mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Support heading wikilink completion
This commit is contained in:
parent
19c461f52b
commit
78522e6355
6 changed files with 168 additions and 11 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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("^");
|
||||
|
|
|
|||
Loading…
Reference in a new issue