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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/features/editor/BacklinksPane.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="p-4 text-sm text-gray-500">Loading backlinks…</div>;
if (backlinks.length === 0) return <div className="p-4 text-sm text-gray-500">No backlinks</div>;

return (
<aside
className="flex w-64 flex-col border-l border-gray-800 bg-gray-950"
aria-label="Backlinks"
>
<div className="border-b border-gray-800 p-3 font-medium text-gray-300">Backlinks</div>
<ul className="flex-1 divide-y divide-gray-800 overflow-y-auto">
{backlinks.map((bl, i) => (
<li key={`${bl.sourcePath}-${bl.positionChar}-${i}`}>
<button
className="w-full px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-800 focus:ring-1 focus:ring-blue-600 focus:outline-none"
onClick={() => onOpenNote(bl.sourcePath)}
>
<div className="truncate font-medium">
{bl.sourcePath.replace(/\.md$/i, "").replace(/^Notes\//, "")}
</div>
<div className="text-xs text-gray-500">char {bl.positionChar}</div>
</button>
</li>
))}
</ul>
</aside>
);
}
95 changes: 95 additions & 0 deletions apps/desktop/src/features/editor/__tests__/BacklinksPane.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<IndexDriver["backlinks"]>(),
}));

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(<BacklinksPane {...props} />);
});
expect(screen.getByText("Loading backlinks…")).toBeInTheDocument();
});

it("shows empty state when no backlinks", async () => {
mockBacklinks.mockResolvedValue([]);
act(() => {
render(<BacklinksPane {...props} />);
});
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(<BacklinksPane {...props} />);
});
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(<BacklinksPane {...props} />);
});
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(<BacklinksPane {...props} />);
});
// 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(<BacklinksPane {...props} />);
});
await waitFor(() => screen.getByText("A"));
const aside = screen.getByLabelText("Backlinks");
expect(aside).toBeInTheDocument();
});
});
74 changes: 56 additions & 18 deletions apps/desktop/src/routes/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fsEventToVaultEvent,
joinPath,
Indexer,
findBlockPosition,
} from "@trachyte/core";
import { tauriDriver } from "../ipc/driver";
import { onFsEvent, startWatcher } from "../ipc/watcher";
Expand All @@ -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("");
Expand All @@ -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}`])),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 (
<div className="flex h-screen bg-gray-900 text-white">
Expand Down Expand Up @@ -177,7 +207,7 @@ export default function Home() {
))}
</ul>
</aside>
<main className="flex-1 overflow-hidden">
<main className="relative flex-1 overflow-hidden">
{currentRel === null ? (
<div className="flex h-full items-center justify-center text-gray-500">
Open a note to start editing
Expand All @@ -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 && (
<BacklinksPane
vaultPath={openedVault!}
targetRelPath={currentRel}
onOpenNote={(path) => void openNote(path)}
/>
)}
</main>
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/index/index.ts
Original file line number Diff line number Diff line change
@@ -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";
7 changes: 7 additions & 0 deletions packages/core/src/index/parser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
extractIndexContent,
extractHeadings,
extractLinks,
extractTags,
findBlockPosition,
} from "./md.js";
8 changes: 8 additions & 0 deletions packages/core/src/index/parser/md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
12 changes: 6 additions & 6 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading