Align tag behavior with Obsidian internals

This commit is contained in:
murashit 2026-07-07 21:09:48 +09:00
parent 08ccb61737
commit 1946ecffe0
4 changed files with 59 additions and 36 deletions

View file

@ -1,5 +1,5 @@
import type { App, EventRef } from "obsidian";
import { getAllTags, stripHeadingForLink, TFile } from "obsidian";
import { stripHeadingForLink, TFile } from "obsidian";
import type { NoteCandidateProvider, WikiLinkMention } from "../../application/composer/note-context";
import type { NoteCandidate } from "../../application/composer/suggestions";
@ -18,6 +18,10 @@ interface EventSource {
offref?(ref: EventRef): void;
}
interface MetadataCacheWithTags {
getTags?: () => unknown;
}
export class VaultNoteCandidateProvider implements NoteCandidateProvider {
private readonly unregisterEvents: (() => void)[] = [];
private fileCandidatesCache: FileCandidate[] | null = null;
@ -60,13 +64,7 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider {
}
private readTags(): string[] {
const tags: string[] = [];
for (const file of this.app.vault.getMarkdownFiles()) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) continue;
tags.push(...(getAllTags(cache) ?? []));
}
return normalizedTags(tags);
return normalizedTags(metadataCacheTags(this.app.metadataCache));
}
resolveMention(target: string, sourcePath: string): WikiLinkMention | null {
@ -111,6 +109,14 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider {
}
}
function metadataCacheTags(metadataCache: App["metadataCache"]): string[] {
const tagIndex = (metadataCache as MetadataCacheWithTags).getTags?.();
if (!tagIndex) return [];
if (Array.isArray(tagIndex)) return tagIndex.filter((tag): tag is string => typeof tag === "string");
if (typeof tagIndex !== "object") return [];
return Object.keys(tagIndex);
}
function normalizedTags(tags: readonly string[]): string[] {
const seen = new Set<string>();
const normalizedTags: string[] = [];

View file

@ -16,6 +16,20 @@ interface StreamMarkdownRenderContext {
vaultPath: string;
}
interface ObsidianGlobalSearchPlugin {
openGlobalSearch?: (query: string, active?: boolean) => void;
}
interface ObsidianAppWithInternalPlugins extends App {
internalPlugins?: {
plugins?: {
"global-search"?: {
instance?: ObsidianGlobalSearchPlugin;
};
};
};
}
export class MarkdownMessageRenderer {
private readonly renderGenerations = new WeakMap<HTMLElement, number>();
@ -90,7 +104,7 @@ function bindRenderedTags(parent: HTMLElement, context: RenderedMarkdownLinkCont
event.preventDefault();
const tag = renderedTagName(link);
if (!tag) return;
void openTagSearch(context.app, tag);
openTagSearch(context.app, tag);
};
});
}
@ -103,18 +117,14 @@ function renderedTagName(link: HTMLAnchorElement): string | null {
return normalized.length > 0 ? normalized : null;
}
async function openTagSearch(app: App, tag: string): Promise<void> {
const leaf = app.workspace.getLeftLeaf(false);
if (!leaf) {
function openTagSearch(app: App, tag: string): void {
const query = `tag:#${tag}`;
const searchPlugin = (app as ObsidianAppWithInternalPlugins).internalPlugins?.plugins?.["global-search"]?.instance;
if (typeof searchPlugin?.openGlobalSearch !== "function") {
new Notice("Cannot open Obsidian search.");
return;
}
await leaf.setViewState({
type: "search",
active: true,
state: { query: `tag:#${tag}` },
});
await app.workspace.revealLeaf(leaf);
searchPlugin.openGlobalSearch(query, true);
}
function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownRenderContext): void {

View file

@ -102,20 +102,27 @@ describe("VaultNoteCandidateProvider", () => {
expect(fileToLinktext).toHaveBeenCalledTimes(2);
});
it("returns normalized Obsidian tag candidates from file metadata", () => {
it("returns normalized Obsidian tag candidates from the metadata tag index", () => {
const getTags = vi.fn(() => ({
"#project/codex": 2,
"#daily": 1,
"#web": 1,
}));
const app = appFixture({
files: [
{ basename: "Alpha", path: "Alpha.md", stat: { mtime: 1 } },
{ basename: "Beta", path: "Beta.md", stat: { mtime: 2 } },
],
getTags,
tags: new Map([
["Alpha.md", { inline: ["#project/codex"], frontmatter: ["daily"] }],
["Beta.md", ["#web", "#project/codex"]],
["Alpha.md", { inline: ["#should/not/use"], frontmatter: ["ignored"] }],
["Beta.md", ["#ignored"]],
]),
});
const provider = new VaultNoteCandidateProvider(app);
expect(provider.tags()).toEqual(["daily", "project/codex", "web"]);
expect(getTags).toHaveBeenCalledOnce();
});
it("invalidates cached candidates when the vault changes", () => {
@ -229,6 +236,7 @@ function appFixture(
linktexts?: Map<string, string>;
fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string;
headings?: Map<string, { heading: string; level: number }[]>;
getTags?: () => unknown;
tags?: Map<string, string[] | { inline?: string[]; frontmatter?: string[] }>;
activeFile?: TFile | null;
activeView?: unknown;
@ -263,6 +271,7 @@ function appFixture(
metadataCache: {
on: on("metadata"),
offref,
getTags: options.getTags,
getFirstLinkpathDest: options.getFirstLinkpathDest ?? (() => options.linkDestination ?? null),
fileToLinktext:
options.fileToLinktext ??
@ -278,7 +287,6 @@ function appFixture(
on: on("vault"),
offref,
getFiles: options.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 AppFixture;

View file

@ -198,14 +198,18 @@ describe("MessageStreamPresenter scroll pinning", () => {
cleanup();
});
it("opens Obsidian search when rendered tags are clicked", async () => {
const searchLeaf = {
setViewState: vi.fn().mockResolvedValue(undefined),
};
const revealLeaf = vi.fn().mockResolvedValue(undefined);
it("uses Obsidian global search when rendered tags are clicked", async () => {
const openGlobalSearch = vi.fn();
const context = markdownLinkContext();
context.app.workspace.getLeftLeaf = vi.fn(() => searchLeaf);
context.app.workspace.revealLeaf = revealLeaf;
Object.assign(context.app, {
internalPlugins: {
plugins: {
"global-search": {
instance: { openGlobalSearch },
},
},
},
});
const { link, cleanup } = await renderedTag(context, {
cls: "tag",
text: "#project/codex",
@ -215,13 +219,8 @@ describe("MessageStreamPresenter scroll pinning", () => {
link.click();
await Promise.resolve();
expect(searchLeaf.setViewState).toHaveBeenCalledWith({
type: "search",
active: true,
state: { query: "tag:#project/codex" },
});
expect(revealLeaf).toHaveBeenCalledWith(searchLeaf);
expect(context.app.workspace.getLeftLeaf).toHaveBeenCalledWith(false);
expect(openGlobalSearch).toHaveBeenCalledWith("tag:#project/codex", true);
expect(context.app.workspace.getLeftLeaf).not.toHaveBeenCalled();
cleanup();
});