From 096981924da4da534acd556e3df1cea7e01078c7 Mon Sep 17 00:00:00 2001 From: Naki-ym <81948866+ymnao@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:46:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20BacklinkSource=20=E3=81=AB=20displa?= =?UTF-8?q?yName=20/=20displayPath=20field=20=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scanBacklinksImpl で workspacePath / sourceFile から 1 度だけ計算する scan-time hoist field を追加し、BacklinkPanel.tsx の毎-render basename / toRelativePath 呼び出しを削減 - main 側 toDisplayPath は entry-filter.ts:toRel と同じ Node 標準 relative + posix 正規化 pattern を採用 (renderer 側 toRelativePath の手書き再実装を回避) - e2e mock (electron-api-mock.ts:scanBacklinks) も対称に対応 - backlink.test.ts に mkSource helper を追加し 7 sites を集約 (将来の BacklinkSource field 追加時の touch point を 1 箇所化) Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/helpers/electron-api-mock.ts | 12 ++- electron/main/ipc/search.ts | 16 +++- src/components/search/BacklinkPanel.tsx | 98 ++++++++++++------------- src/stores/backlink.test.ts | 45 +++++++----- src/types/wikilink.ts | 4 + 5 files changed, 102 insertions(+), 73 deletions(-) diff --git a/e2e/helpers/electron-api-mock.ts b/e2e/helpers/electron-api-mock.ts index c439e51..22e6306 100644 --- a/e2e/helpers/electron-api-mock.ts +++ b/e2e/helpers/electron-api-mock.ts @@ -859,8 +859,18 @@ function installApiMock(opts: { } } } + // 本番 search.ts:toDisplayPath と同等を mock 内 inline で再現。e2e mock の filePath は + // 正規化済み posix 形なので `/` 区切り前提で OK。 + const wsPrefix = workspacePath.endsWith("/") ? workspacePath : `${workspacePath}/`; return Object.entries(map) - .map(([sourceFile, references]) => ({ sourceFile, references })) + .map(([sourceFile, references]) => ({ + sourceFile, + displayName: baseName(sourceFile), + displayPath: sourceFile.startsWith(wsPrefix) + ? sourceFile.slice(wsPrefix.length) + : sourceFile, + references, + })) .sort((a, b) => (a.sourceFile < b.sourceFile ? -1 : a.sourceFile > b.sourceFile ? 1 : 0)); }, cancelBacklinkScan: async (): Promise => { diff --git a/electron/main/ipc/search.ts b/electron/main/ipc/search.ts index 649648c..3163fd5 100644 --- a/electron/main/ipc/search.ts +++ b/electron/main/ipc/search.ts @@ -1,5 +1,5 @@ import { promises as fsp } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { basename, join, relative, resolve, sep } from "node:path"; import pLimit from "p-limit"; import type { SearchResult } from "../../../src/types/search"; import type { @@ -519,6 +519,13 @@ async function scanUnresolvedWikilinksImpl( return result; } +// main 側 entry-filter.ts:toRel と同じ pattern。Node 標準 relative + posix 正規化で +// workspacePath からの表示用相対 path にする(Windows でも表示は posix 形に統一)。 +function toDisplayPath(workspacePath: string, absolutePath: string): string { + const rel = relative(workspacePath, absolutePath); + return sep === "/" ? rel : rel.split(sep).join("/"); +} + // 指定ノートを `[[ファイル名]]` で参照しているノートを収集する(順引きと逆方向)。 // 解決ロジックは scanUnresolvedWikilinksImpl と同じ正規化(拡張子除去 + NFC + path-traversal 弾き)を // 通すため、ホバーで参照件数を出す機能と件数が一致する。self-reference は canonical path 一致で除外。 @@ -598,7 +605,12 @@ async function scanBacklinksImpl( const result: BacklinkSource[] = []; for (const [sourceFile, references] of map) { - result.push({ sourceFile, references }); + result.push({ + sourceFile, + displayName: basename(sourceFile), + displayPath: toDisplayPath(workspacePath, sourceFile), + references, + }); } // sourceFile の byte 比較で昇順(scanUnresolvedWikilinksImpl と同方針)。 result.sort((a, b) => (a.sourceFile < b.sourceFile ? -1 : a.sourceFile > b.sourceFile ? 1 : 0)); diff --git a/src/components/search/BacklinkPanel.tsx b/src/components/search/BacklinkPanel.tsx index 5ed28bb..9694a96 100644 --- a/src/components/search/BacklinkPanel.tsx +++ b/src/components/search/BacklinkPanel.tsx @@ -2,7 +2,7 @@ import { ChevronDown, ChevronRight, FileText, Loader2 } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { useCollapseToggle } from "../../hooks/useCollapseToggle"; import { cancelBacklinkScan } from "../../lib/commands"; -import { basename, isNewTabPath, toRelativePath } from "../../lib/path"; +import { basename, isNewTabPath } from "../../lib/path"; import { useBacklinkStore } from "../../stores/backlink"; import { useWorkspaceStore } from "../../stores/workspace"; @@ -117,57 +117,53 @@ export function BacklinkPanel({ workspacePath, onNavigate }: BacklinkPanelProps) {!loading && backlinks.length === 0 && (

バックリンクはありません

)} - {backlinks.map((src) => { - const fileName = basename(src.sourceFile); - const relativePath = toRelativePath(workspacePath, src.sourceFile); - return ( -
-
- -
- {!isCollapsed(src.sourceFile) && ( -
- {src.references.map((reference) => ( - - ))} -
- )} + {backlinks.map((src) => ( +
+
+
- ); - })} + {!isCollapsed(src.sourceFile) && ( +
+ {src.references.map((reference) => ( + + ))} +
+ )} +
+ ))}
); diff --git a/src/stores/backlink.test.ts b/src/stores/backlink.test.ts index b83557c..391c13c 100644 --- a/src/stores/backlink.test.ts +++ b/src/stores/backlink.test.ts @@ -6,6 +6,16 @@ vi.mock("../lib/commands", () => ({ scanBacklinks: vi.fn(), })); +// test sourceFile はすべて `/workspace/xxx.md` の形なので displayName / displayPath は同じ basename。 +// 本 helper で BacklinkSource 拡張時の touch point を 1 箇所に集約する。 +const mkSource = ( + sourceFile: string, + references: BacklinkSource["references"] = [], +): BacklinkSource => { + const name = sourceFile.split("/").pop() ?? sourceFile; + return { sourceFile, displayName: name, displayPath: name, references }; +}; + describe("useBacklinkStore", () => { afterEach(() => { useBacklinkStore.setState({ @@ -24,19 +34,16 @@ describe("useBacklinkStore", () => { it("scan fetches backlinks and records currentTargetPath", async () => { const mockLinks: BacklinkSource[] = [ - { - sourceFile: "/workspace/source.md", - references: [ - { - filePath: "/workspace/source.md", - lineNumber: 1, - byteOffset: 5, - lineContent: "See [[target]]", - contextBefore: [], - contextAfter: [], - }, - ], - }, + mkSource("/workspace/source.md", [ + { + filePath: "/workspace/source.md", + lineNumber: 1, + byteOffset: 5, + lineContent: "See [[target]]", + contextBefore: [], + contextAfter: [], + }, + ]), ]; const { scanBacklinks } = await import("../lib/commands"); vi.mocked(scanBacklinks).mockResolvedValue(mockLinks); @@ -51,7 +58,7 @@ describe("useBacklinkStore", () => { it("changing target clears prior backlinks immediately to avoid stale display", async () => { useBacklinkStore.setState({ - backlinks: [{ sourceFile: "/workspace/old.md", references: [] }], + backlinks: [mkSource("/workspace/old.md")], currentTargetPath: "/workspace/foo.md", }); @@ -74,7 +81,7 @@ describe("useBacklinkStore", () => { }); it("same target re-scan keeps prior backlinks until new result arrives", async () => { - const existing: BacklinkSource[] = [{ sourceFile: "/workspace/old.md", references: [] }]; + const existing: BacklinkSource[] = [mkSource("/workspace/old.md")]; useBacklinkStore.setState({ backlinks: existing, currentTargetPath: "/workspace/foo.md", @@ -94,7 +101,7 @@ describe("useBacklinkStore", () => { expect(useBacklinkStore.getState().backlinks).toEqual(existing); expect(useBacklinkStore.getState().loading).toBe(true); - const fresh: BacklinkSource[] = [{ sourceFile: "/workspace/new.md", references: [] }]; + const fresh: BacklinkSource[] = [mkSource("/workspace/new.md")]; resolveScan?.(fresh); await scanPromise; expect(useBacklinkStore.getState().backlinks).toEqual(fresh); @@ -111,8 +118,8 @@ describe("useBacklinkStore", () => { it("stale scan result is discarded when newer scan is in flight", async () => { const { scanBacklinks } = await import("../lib/commands"); - const staleResult: BacklinkSource[] = [{ sourceFile: "/workspace/stale.md", references: [] }]; - const freshResult: BacklinkSource[] = [{ sourceFile: "/workspace/fresh.md", references: [] }]; + const staleResult: BacklinkSource[] = [mkSource("/workspace/stale.md")]; + const freshResult: BacklinkSource[] = [mkSource("/workspace/fresh.md")]; let resolveFirst: ((v: BacklinkSource[]) => void) | undefined; const firstPromise = new Promise((r) => { @@ -135,7 +142,7 @@ describe("useBacklinkStore", () => { it("reset clears all state and invalidates in-flight scans", async () => { const { scanBacklinks } = await import("../lib/commands"); - const oldResult: BacklinkSource[] = [{ sourceFile: "/workspace/old.md", references: [] }]; + const oldResult: BacklinkSource[] = [mkSource("/workspace/old.md")]; let resolveOld: ((v: BacklinkSource[]) => void) | undefined; const oldPromise = new Promise((r) => { diff --git a/src/types/wikilink.ts b/src/types/wikilink.ts index 1d1c843..315af09 100644 --- a/src/types/wikilink.ts +++ b/src/types/wikilink.ts @@ -14,7 +14,11 @@ export interface UnresolvedWikilink { // 対象ノートを参照している側のファイル 1 つと、その中の参照位置一覧。 // WikilinkReference.filePath は参照元 (= sourceFile と同値) を指す。 +// displayName / displayPath は scanBacklinksImpl で workspacePath / sourceFile から +// 1 度だけ計算する render-time hoist 済 field (renderer での毎 render allocation 削減)。 export interface BacklinkSource { sourceFile: string; + displayName: string; + displayPath: string; references: WikilinkReference[]; }