From 1946ecffe0e1746766dda52f599929f9ffe36d9a Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 7 Jul 2026 21:09:48 +0900 Subject: [PATCH] Align tag behavior with Obsidian internals --- .../vault-note-candidate-provider.obsidian.ts | 22 +++++++++----- .../markdown-renderer.obsidian.ts | 30 ++++++++++++------- .../vault-note-candidate-provider.test.ts | 16 +++++++--- .../surface/message-stream-presenter.test.ts | 27 ++++++++--------- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts index 484edc1f..92c6ff37 100644 --- a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts +++ b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts @@ -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(); const normalizedTags: string[] = []; diff --git a/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts b/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts index 9002c70e..2162b92d 100644 --- a/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts +++ b/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts @@ -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(); @@ -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 { - 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 { diff --git a/tests/features/chat/host/vault-note-candidate-provider.test.ts b/tests/features/chat/host/vault-note-candidate-provider.test.ts index d7faab28..adaa33f7 100644 --- a/tests/features/chat/host/vault-note-candidate-provider.test.ts +++ b/tests/features/chat/host/vault-note-candidate-provider.test.ts @@ -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; fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string; headings?: Map; + getTags?: () => unknown; tags?: Map; 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; diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 968b0669..7794b0ef 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -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(); });