Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion e2e/helpers/electron-api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
Expand Down
16 changes: 14 additions & 2 deletions electron/main/ipc/search.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 一致で除外。
Expand Down Expand Up @@ -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));
Expand Down
98 changes: 47 additions & 51 deletions src/components/search/BacklinkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -117,57 +117,53 @@ export function BacklinkPanel({ workspacePath, onNavigate }: BacklinkPanelProps)
{!loading && backlinks.length === 0 && (
<p className="px-3 py-2 text-xs text-text-secondary">バックリンクはありません</p>
)}
{backlinks.map((src) => {
const fileName = basename(src.sourceFile);
const relativePath = toRelativePath(workspacePath, src.sourceFile);
return (
<div key={src.sourceFile} className="group">
<div className="flex items-center">
<button
type="button"
className="search-panel-file-header min-w-0 flex-1"
onClick={() => toggleCollapse(src.sourceFile)}
aria-expanded={!isCollapsed(src.sourceFile)}
>
<span className="search-panel-file-chevron">
{isCollapsed(src.sourceFile) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</span>
<FileText size={12} className="mr-1 shrink-0 text-text-secondary" />
<span className="search-panel-file-name truncate" title={relativePath}>
{fileName}
</span>
<span className="search-panel-file-count">{src.references.length}</span>
</button>
</div>
{!isCollapsed(src.sourceFile) && (
<div>
{src.references.map((reference) => (
<button
type="button"
key={`${reference.filePath}-${reference.lineNumber}-${reference.byteOffset}`}
className="search-panel-match"
onClick={() =>
onNavigate(reference.filePath, reference.lineNumber, targetPageName)
}
>
<span className="search-panel-line-number">{reference.lineNumber}</span>
<span
className="search-panel-line-content truncate"
title={reference.lineContent}
>
{reference.lineContent}
</span>
</button>
))}
</div>
)}
{backlinks.map((src) => (
<div key={src.sourceFile} className="group">
<div className="flex items-center">
<button
type="button"
className="search-panel-file-header min-w-0 flex-1"
onClick={() => toggleCollapse(src.sourceFile)}
aria-expanded={!isCollapsed(src.sourceFile)}
>
<span className="search-panel-file-chevron">
{isCollapsed(src.sourceFile) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</span>
<FileText size={12} className="mr-1 shrink-0 text-text-secondary" />
<span className="search-panel-file-name truncate" title={src.displayPath}>
{src.displayName}
</span>
<span className="search-panel-file-count">{src.references.length}</span>
</button>
</div>
);
})}
{!isCollapsed(src.sourceFile) && (
<div>
{src.references.map((reference) => (
<button
type="button"
key={`${reference.filePath}-${reference.lineNumber}-${reference.byteOffset}`}
className="search-panel-match"
onClick={() =>
onNavigate(reference.filePath, reference.lineNumber, targetPageName)
}
>
<span className="search-panel-line-number">{reference.lineNumber}</span>
<span
className="search-panel-line-content truncate"
title={reference.lineContent}
>
{reference.lineContent}
</span>
</button>
))}
</div>
)}
</div>
))}
</section>
</div>
);
Expand Down
45 changes: 26 additions & 19 deletions src/stores/backlink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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",
});

Expand All @@ -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",
Expand All @@ -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);
Expand All @@ -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<BacklinkSource[]>((r) => {
Expand All @@ -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<BacklinkSource[]>((r) => {
Expand Down
4 changes: 4 additions & 0 deletions src/types/wikilink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Loading