diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab34402..ee92c16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Link/heading/tag data layer: MD parser extracts `[[wikilinks]]` (char-offset positions), `#headings`, and `#tags`; `Indexer` persists them to the `headings`/`tags`/`backlinks` tables via the new `IndexDriver.storeExtracted` method; `IndexDriver.backlinks(vaultPath, target)` query returns source notes + char positions (Rust `store_extracted`/`list_backlinks`, IPC `index_store_extracted`/`index_list_backlinks`, desktop wrappers); wikilink resolver relocated into core and shared with the editor
+- BacklinksPane: right-hand pane showing backlinks for the open note with click-to-navigate
+- Wikilink block references: `[[note#^blockId]]` now scrolls to the `^blockId` line in the target note
+- Editor `scrollToPos` prop for programmatic scrolling to character positions
+- Fixed duplicate `Ctrl+Shift+D` keydown handler in Home.tsx
+
### Changed
- CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility
diff --git a/apps/desktop/src/features/editor/BacklinksPane.tsx b/apps/desktop/src/features/editor/BacklinksPane.tsx
new file mode 100644
index 0000000..a2c5122
--- /dev/null
+++ b/apps/desktop/src/features/editor/BacklinksPane.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+import { tauriIndexDriver } from "../../ipc/index-driver";
+
+interface Props {
+ vaultPath: string;
+ targetRelPath: string;
+ onOpenNote: (relPath: string) => void;
+}
+
+export default function BacklinksPane({ vaultPath, targetRelPath, onOpenNote }: Props) {
+ const [backlinks, setBacklinks] = useState<{ sourcePath: string; positionChar: number }[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function load() {
+ setLoading(true);
+ const results = await tauriIndexDriver.backlinks(vaultPath, targetRelPath);
+ const sorted = [...results].sort(
+ (a, b) => a.sourcePath.localeCompare(b.sourcePath) || a.positionChar - b.positionChar,
+ );
+ if (!cancelled) {
+ setBacklinks(sorted);
+ setLoading(false);
+ }
+ }
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, [vaultPath, targetRelPath]);
+
+ if (loading) return
Loading backlinks…
;
+ if (backlinks.length === 0) return No backlinks
;
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/features/editor/__tests__/BacklinksPane.spec.tsx b/apps/desktop/src/features/editor/__tests__/BacklinksPane.spec.tsx
new file mode 100644
index 0000000..8f5c37b
--- /dev/null
+++ b/apps/desktop/src/features/editor/__tests__/BacklinksPane.spec.tsx
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import BacklinksPane from "../BacklinksPane";
+import type { IndexDriver } from "@trachyte/core";
+
+const { mockBacklinks } = vi.hoisted(() => ({
+ mockBacklinks: vi.fn(),
+}));
+
+vi.mock("../../../ipc/index-driver", () => ({
+ tauriIndexDriver: {
+ backlinks: mockBacklinks,
+ },
+}));
+
+describe("BacklinksPane", () => {
+ const mockOnOpenNote = vi.fn();
+ const props = {
+ vaultPath: "/test/vault",
+ targetRelPath: "Notes/Java.md",
+ onOpenNote: mockOnOpenNote,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows loading state initially", () => {
+ mockBacklinks.mockResolvedValue([]);
+ act(() => {
+ render();
+ });
+ expect(screen.getByText("Loading backlinks…")).toBeInTheDocument();
+ });
+
+ it("shows empty state when no backlinks", async () => {
+ mockBacklinks.mockResolvedValue([]);
+ act(() => {
+ render();
+ });
+ await screen.findByText("No backlinks");
+ });
+
+ it("renders backlinks with source name and position", async () => {
+ mockBacklinks.mockResolvedValue([
+ { sourcePath: "Notes/Foo.md", positionChar: 42 },
+ { sourcePath: "Notes/Bar.md", positionChar: 10 },
+ ]);
+ act(() => {
+ render();
+ });
+ await screen.findByText("Foo");
+ await screen.findByText("char 42");
+ await screen.findByText("Bar");
+ await screen.findByText("char 10");
+ });
+
+ it("calls onOpenNote when a backlink is clicked", async () => {
+ mockBacklinks.mockResolvedValue([{ sourcePath: "Notes/Foo.md", positionChar: 42 }]);
+ act(() => {
+ render();
+ });
+ await screen.findByText("Foo");
+ fireEvent.click(screen.getByText("Foo"));
+ expect(mockOnOpenNote).toHaveBeenCalledWith("Notes/Foo.md");
+ });
+
+ it("sorts backlinks by source path then position", async () => {
+ mockBacklinks.mockResolvedValue([
+ { sourcePath: "Notes/B.md", positionChar: 5 },
+ { sourcePath: "Notes/A.md", positionChar: 10 },
+ { sourcePath: "Notes/A.md", positionChar: 2 },
+ ]);
+ act(() => {
+ render();
+ });
+ // Direct findAllByRole - waits for buttons to appear
+ const items = await screen.findAllByRole("button");
+ // Component sorts: A (pos 2), A (pos 10), B (pos 5)
+ expect(items[0]).toHaveTextContent("A");
+ expect(items[1]).toHaveTextContent("A");
+ expect(items[2]).toHaveTextContent("B");
+ });
+
+ it("has accessible structure when backlinks exist", async () => {
+ mockBacklinks.mockResolvedValue([{ sourcePath: "Notes/A.md", positionChar: 1 }]);
+ act(() => {
+ render();
+ });
+ await waitFor(() => screen.getByText("A"));
+ const aside = screen.getByLabelText("Backlinks");
+ expect(aside).toBeInTheDocument();
+ });
+});
diff --git a/apps/desktop/src/routes/Home.tsx b/apps/desktop/src/routes/Home.tsx
index b0ec993..7325f04 100644
--- a/apps/desktop/src/routes/Home.tsx
+++ b/apps/desktop/src/routes/Home.tsx
@@ -7,6 +7,7 @@ import {
fsEventToVaultEvent,
joinPath,
Indexer,
+ findBlockPosition,
} from "@trachyte/core";
import { tauriDriver } from "../ipc/driver";
import { onFsEvent, startWatcher } from "../ipc/watcher";
@@ -15,6 +16,7 @@ import { tauriIndexDriver } from "../ipc/index-driver";
import DoctorPanel from "../features/doctor/DoctorPanel";
import SearchPalette from "../features/search/SearchPalette";
+import BacklinksPane from "../features/editor/BacklinksPane";
export default function Home() {
const [inputPath, setInputPath] = useState("");
@@ -28,6 +30,7 @@ export default function Home() {
const reloader = useMemo(() => new ExternalChangeReloader(manager.events, 100), [manager]);
const [doctorOpen, setDoctorOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
+ const [scrollToPos, setScrollToPos] = useState<{ from: number; to: number } | null>(null);
const linkIndex = useMemo(
() => new Map(notes.map((n) => [n.replace(/\.md$/i, ""), `Notes/${n}`])),
@@ -61,11 +64,23 @@ export default function Home() {
await startWatcher(path);
}
- async function openNote(rel: string) {
+ async function openNote(rel: string, blockId?: string) {
if (openedVault === null) return;
setCurrentRel(rel);
- setContent(await manager.getAdapter().readFile(joinPath(openedVault, rel)));
+ const text = await manager.getAdapter().readFile(joinPath(openedVault, rel));
+ setContent(text);
reloader.watch(rel);
+
+ if (blockId) {
+ const pos = findBlockPosition(text, blockId);
+ if (pos !== null) {
+ setScrollToPos({ from: pos, to: pos });
+ } else {
+ setScrollToPos(null);
+ }
+ } else {
+ setScrollToPos(null);
+ }
}
useEffect(() => {
@@ -108,29 +123,44 @@ export default function Home() {
e.preventDefault();
setDoctorOpen((v) => !v);
}
+ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "f") {
+ e.preventDefault();
+ setPaletteOpen((v) => !v);
+ }
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
+ // useEffect(() => {
+ // function onKeyDown(e: KeyboardEvent) {
+ // if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
+ // e.preventDefault();
+ // setDoctorOpen((v) => !v);
+ // }
+ // }
+ // window.addEventListener("keydown", onKeyDown);
+ // return () => window.removeEventListener("keydown", onKeyDown);
+ // }, []);
+
useEffect(() => {
return () => indexer?.dispose();
}, [indexer]);
- useEffect(() => {
- function onKeyDown(e: KeyboardEvent) {
- if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
- e.preventDefault();
- setDoctorOpen((v) => !v);
- }
- if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "f") {
- e.preventDefault();
- setPaletteOpen((v) => !v);
- }
- }
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, []);
+ // useEffect(() => {
+ // function onKeyDown(e: KeyboardEvent) {
+ // if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "d") {
+ // e.preventDefault();
+ // setDoctorOpen((v) => !v);
+ // }
+ // if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "f") {
+ // e.preventDefault();
+ // setPaletteOpen((v) => !v);
+ // }
+ // }
+ // window.addEventListener("keydown", onKeyDown);
+ // return () => window.removeEventListener("keydown", onKeyDown);
+ // }, []);
return (
@@ -177,7 +207,7 @@ export default function Home() {
))}
-
+
{currentRel === null ? (
Open a note to start editing
@@ -187,7 +217,15 @@ export default function Home() {
value={content}
onChange={setContent}
links={linkIndex}
- onOpenLink={(rel) => void openNote(rel)}
+ onOpenLink={(path, blockId) => void openNote(path, blockId)}
+ scrollToPos={scrollToPos}
+ />
+ )}
+ {currentRel && (
+
void openNote(path)}
/>
)}
diff --git a/packages/core/src/index/index.ts b/packages/core/src/index/index.ts
index 679d807..cf653b3 100644
--- a/packages/core/src/index/index.ts
+++ b/packages/core/src/index/index.ts
@@ -1,4 +1,10 @@
export { Indexer, type IndexerDeps } from "./indexer.js";
export { MemoryIndexDriver, type IndexDriver } from "./driver.js";
-export { extractIndexContent, extractHeadings, extractLinks, extractTags } from "./parser/md.js";
+export {
+ extractIndexContent,
+ extractHeadings,
+ extractLinks,
+ extractTags,
+ findBlockPosition,
+} from "./parser/md.js";
export { type IndexedFileMeta, type IndexEvent, type ExtractedNote } from "./types.js";
diff --git a/packages/core/src/index/parser/index.ts b/packages/core/src/index/parser/index.ts
new file mode 100644
index 0000000..d801745
--- /dev/null
+++ b/packages/core/src/index/parser/index.ts
@@ -0,0 +1,7 @@
+export {
+ extractIndexContent,
+ extractHeadings,
+ extractLinks,
+ extractTags,
+ findBlockPosition,
+} from "./md.js";
diff --git a/packages/core/src/index/parser/md.ts b/packages/core/src/index/parser/md.ts
index 133579f..f42dec7 100644
--- a/packages/core/src/index/parser/md.ts
+++ b/packages/core/src/index/parser/md.ts
@@ -51,3 +51,11 @@ export function extractTags(markdown: string): string[] {
}
return [...seen];
}
+
+export function findBlockPosition(markdown: string, blockId: string): number | null {
+ const re = new RegExp(`\\^${blockId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "gm");
+ const match = re.exec(markdown);
+ if (!match) return null;
+ const lineStart = markdown.lastIndexOf("\n", match.index) + 1;
+ return lineStart;
+}
diff --git a/packages/editor/package.json b/packages/editor/package.json
index ba18e1f..fec5225 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -10,20 +10,20 @@
"test": "vitest run"
},
"dependencies": {
- "react": "catalog:",
- "codemirror": "^6.0.2",
+ "@codemirror/commands": "^6.8.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.43.6",
- "@codemirror/commands": "^6.8.2",
- "@trachyte/core": "workspace:*"
+ "@trachyte/core": "workspace:*",
+ "codemirror": "^6.0.2",
+ "react": "catalog:"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "catalog:",
- "vitest": "^4",
- "jsdom": "^29"
+ "jsdom": "^29",
+ "vitest": "^4"
}
}
diff --git a/packages/editor/src/CodeMirrorEditor.tsx b/packages/editor/src/CodeMirrorEditor.tsx
index 055e5ff..e5e1981 100644
--- a/packages/editor/src/CodeMirrorEditor.tsx
+++ b/packages/editor/src/CodeMirrorEditor.tsx
@@ -9,10 +9,11 @@ interface Props {
value: string;
onChange?: (value: string) => void;
links?: ReadonlyMap;
- onOpenLink?: (path: string) => void;
+ onOpenLink?: (path: string, blockId?: string) => void;
+ scrollToPos?: { from: number; to: number } | null;
}
-export function CodeMirrorEditor({ value, onChange, links, onOpenLink }: Props) {
+export function CodeMirrorEditor({ value, onChange, links, onOpenLink, scrollToPos }: Props) {
const editorRef = useRef(null);
const viewRef = useRef(null);
const initialValueRef = useRef(value);
@@ -43,7 +44,7 @@ export function CodeMirrorEditor({ value, onChange, links, onOpenLink }: Props)
markdown(),
wikilinks({
getIndex: () => linksRef.current,
- onOpen: (path) => onOpenLinkRef.current?.(path),
+ onOpen: (path, blockId) => onOpenLinkRef.current?.(path, blockId),
}),
blockRefs(),
EditorView.updateListener.of((update: ViewUpdate) => {
@@ -60,11 +61,23 @@ export function CodeMirrorEditor({ value, onChange, links, onOpenLink }: Props)
return () => view.destroy();
}, []);
+ const scrollToPosRef = useRef(scrollToPos);
+ useEffect(() => {
+ scrollToPosRef.current = scrollToPos;
+ }, [scrollToPos]);
+
useEffect(() => {
const view = viewRef.current;
if (view && value !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
}
+ // NEW: handle scrollTo after content is set
+ if (view && scrollToPosRef.current) {
+ view.dispatch({
+ selection: { anchor: scrollToPosRef.current.from, head: scrollToPosRef.current.to },
+ scrollIntoView: true,
+ });
+ }
}, [value]);
return ;
diff --git a/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts b/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts
index fdb6f15..385842a 100644
--- a/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts
+++ b/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts
@@ -42,25 +42,36 @@ describe("wikilinks extension", () => {
view.destroy();
});
- it("opens a resolved link on click", () => {
+ it("opens a resolved link on click (no blockId)", () => {
const { view, onOpen } = makeView("[[Java]]", new Map([["Java", "Notes/Java.md"]]));
const path = handleWikilinkClick(view, insideLinkPos(view, "Java"), {
getIndex: () => new Map([["Java", "Notes/Java.md"]]),
onOpen,
});
expect(path).toBe("Notes/Java.md");
- expect(onOpen).toHaveBeenCalledWith("Notes/Java.md");
+ expect(onOpen).toHaveBeenCalledWith("Notes/Java.md", undefined);
view.destroy();
});
- it("opens an escape-hatch path on click", () => {
+ it("opens an escape-hatch path on click (no blockId)", () => {
const { view, onOpen } = makeView("[[Notes/Java.md]]", new Map());
const path = handleWikilinkClick(view, insideLinkPos(view, "Notes/Java.md"), {
getIndex: () => new Map(),
onOpen,
});
expect(path).toBe("Notes/Java.md");
- expect(onOpen).toHaveBeenCalledWith("Notes/Java.md");
+ expect(onOpen).toHaveBeenCalledWith("Notes/Java.md", undefined);
+ view.destroy();
+ });
+
+ it("opens a link with blockId on click", () => {
+ const { view, onOpen } = makeView("[[Java#^block123]]", new Map([["Java", "Notes/Java.md"]]));
+ const path = handleWikilinkClick(view, insideLinkPos(view, "Java#^block123"), {
+ getIndex: () => new Map([["Java", "Notes/Java.md"]]),
+ onOpen,
+ });
+ expect(path).toBe("Notes/Java.md");
+ expect(onOpen).toHaveBeenCalledWith("Notes/Java.md", "block123");
view.destroy();
});
diff --git a/packages/editor/src/extensions/wikilinks/wikilinks.ts b/packages/editor/src/extensions/wikilinks/wikilinks.ts
index eb6cf57..c7735f1 100644
--- a/packages/editor/src/extensions/wikilinks/wikilinks.ts
+++ b/packages/editor/src/extensions/wikilinks/wikilinks.ts
@@ -6,12 +6,12 @@ import {
type EditorView,
type ViewUpdate,
} from "@codemirror/view";
-import { resolveWikilink } from "../resolver/resolver.js";
+import { parseLinkTarget, resolveWikilink } from "../resolver/resolver.js";
import "./wikilinks.css";
export interface WikilinkOptions {
getIndex: () => ReadonlyMap;
- onOpen: (path: string) => void;
+ onOpen: (path: string, blockId?: string) => void;
}
const linkRe = /\[\[([^\[\]\n]+)\]\]/g;
@@ -62,8 +62,10 @@ export function handleWikilinkClick(
const from = line.from + match.index;
const to = from + match[0].length;
if (pos >= from && pos <= to) {
- const path = resolveWikilink(match[1]!, opts.getIndex());
- if (path !== null) opts.onOpen(path);
+ const raw = match[1]!;
+ const { note, blockId } = parseLinkTarget(raw);
+ const path = resolveWikilink(note, opts.getIndex());
+ if (path !== null) opts.onOpen(path, blockId ?? undefined);
return path;
}
}