diff --git a/CHANGELOG.md b/CHANGELOG.md
index adc1fbc..930f554 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## AI MANAGED
+
## [Unreleased]
### Added
@@ -31,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Search UI: `SearchPalette` modal (`Ctrl+Shift+F` + sidebar "Search" button) with a hand-rolled focus trap, `role=dialog`/`aria-modal="true"`, listbox/option semantics via `aria-activedescendant`/`aria-selected`, keyboard nav (arrows/`Enter`/`Escape`/`Tab` wrap) and submode badges; `useSearch` hook (150 ms debounce, stale-response guard, injectable `SearchService` for tests); desktop test harness (vitest + jsdom + testing-library + axe-core) with functional specs and an axe smoke test
+- Wikilinks + block refs in CM6: `wikilinks()` extension (`[[target]]` highlighted, title-first + `path/`/`.md` escape-hatch resolution, resolved vs faint-unresolved styling, click-to-open note via `handleWikilinkClick`); `blockRefs()` extension (`^id` line-end definitions decorated, `#^id` decoded in link targets); `CodeMirrorEditor` gains `links`/`onOpenLink` props (resolution data injected from the app — editor stays Tauri-free); `Home.tsx` builds the title→path index from `VaultManager.list()` and wires link navigation; first `@trachyte/editor` vitest harness (resolver + wikilink extension specs)
+
### Changed
- CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility
diff --git a/apps/desktop/src/routes/Home.tsx b/apps/desktop/src/routes/Home.tsx
index 6e0c0be..b0ec993 100644
--- a/apps/desktop/src/routes/Home.tsx
+++ b/apps/desktop/src/routes/Home.tsx
@@ -29,6 +29,10 @@ export default function Home() {
const [doctorOpen, setDoctorOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
+ const linkIndex = useMemo(
+ () => new Map(notes.map((n) => [n.replace(/\.md$/i, ""), `Notes/${n}`])),
+ [notes],
+ );
async function openVault() {
const raw = inputPath.trim();
if (!raw) return;
@@ -179,7 +183,12 @@ export default function Home() {
Open a note to start editing
) : (
-
+ void openNote(rel)}
+ />
)}
{doctorOpen && openedVault !== null && (
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 80ff579..9e6d703 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -7,7 +7,7 @@
"scripts": {
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
- "test": "echo \"no tests yet\""
+ "test": "vitest run"
},
"dependencies": {
"react": "catalog:",
@@ -21,6 +21,8 @@
"react": "^19.0.0"
},
"devDependencies": {
- "@types/react": "catalog:"
+ "@types/react": "catalog:",
+ "vitest": "^4",
+ "jsdom": "^29"
}
}
diff --git a/packages/editor/src/CodeMirrorEditor.tsx b/packages/editor/src/CodeMirrorEditor.tsx
index dd5604c..055e5ff 100644
--- a/packages/editor/src/CodeMirrorEditor.tsx
+++ b/packages/editor/src/CodeMirrorEditor.tsx
@@ -3,22 +3,35 @@ import { EditorView, ViewUpdate } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { EditorState } from "@codemirror/state";
+import { blockRefs, wikilinks } from "./extensions/index.js";
interface Props {
value: string;
onChange?: (value: string) => void;
+ links?: ReadonlyMap;
+ onOpenLink?: (path: string) => void;
}
-export function CodeMirrorEditor({ value, onChange }: Props) {
+export function CodeMirrorEditor({ value, onChange, links, onOpenLink }: Props) {
const editorRef = useRef(null);
const viewRef = useRef(null);
const initialValueRef = useRef(value);
const onChangeRef = useRef(onChange);
+ const linksRef = useRef(links ?? new Map());
+ const onOpenLinkRef = useRef(onOpenLink);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
+ useEffect(() => {
+ linksRef.current = links ?? new Map();
+ }, [links]);
+
+ useEffect(() => {
+ onOpenLinkRef.current = onOpenLink;
+ }, [onOpenLink]);
+
useEffect(() => {
if (!editorRef.current) return;
@@ -28,6 +41,11 @@ export function CodeMirrorEditor({ value, onChange }: Props) {
extensions: [
basicSetup,
markdown(),
+ wikilinks({
+ getIndex: () => linksRef.current,
+ onOpen: (path) => onOpenLinkRef.current?.(path),
+ }),
+ blockRefs(),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
onChangeRef.current?.(update.state.doc.toString());
diff --git a/packages/editor/src/css.d.ts b/packages/editor/src/css.d.ts
new file mode 100644
index 0000000..cbe652d
--- /dev/null
+++ b/packages/editor/src/css.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/packages/editor/src/extensions/blockrefs/blockRef.css b/packages/editor/src/extensions/blockrefs/blockRef.css
new file mode 100644
index 0000000..f5c85be
--- /dev/null
+++ b/packages/editor/src/extensions/blockrefs/blockRef.css
@@ -0,0 +1,3 @@
+.cm-blockref-def {
+ color: var(--cm-blockref-def, #f59e0b);
+}
diff --git a/packages/editor/src/extensions/blockrefs/blockRef.ts b/packages/editor/src/extensions/blockrefs/blockRef.ts
new file mode 100644
index 0000000..bcb6b4d
--- /dev/null
+++ b/packages/editor/src/extensions/blockrefs/blockRef.ts
@@ -0,0 +1,31 @@
+import {
+ Decoration,
+ MatchDecorator,
+ ViewPlugin,
+ type DecorationSet,
+ type EditorView,
+ type ViewUpdate,
+} from "@codemirror/view";
+import "./blockRef.css";
+
+const blockRefRe = /\^([a-zA-Z0-9_-]+)$/g;
+
+export function blockRefs() {
+ const matcher = new MatchDecorator({
+ regexp: blockRefRe,
+ decoration: () => Decoration.mark({ class: "cm-blockref-def" }),
+ });
+
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+ constructor(view: EditorView) {
+ this.decorations = matcher.createDeco(view);
+ }
+ update(update: ViewUpdate) {
+ this.decorations = matcher.updateDeco(update, this.decorations);
+ }
+ },
+ { decorations: (v) => v.decorations },
+ );
+}
diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts
index cb0ff5c..a678036 100644
--- a/packages/editor/src/extensions/index.ts
+++ b/packages/editor/src/extensions/index.ts
@@ -1 +1,3 @@
-export {};
+export { blockRefs } from "./blockrefs/blockRef.js";
+export { parseLinkTarget, resolveWikilink } from "./resolver/resolver.js";
+export { handleWikilinkClick, wikilinks } from "./wikilinks/wikilinks.js";
diff --git a/packages/editor/src/extensions/resolver/__tests__/resolver.spec.ts b/packages/editor/src/extensions/resolver/__tests__/resolver.spec.ts
new file mode 100644
index 0000000..4ec9f62
--- /dev/null
+++ b/packages/editor/src/extensions/resolver/__tests__/resolver.spec.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { parseLinkTarget, resolveWikilink } from "../resolver.js";
+
+const titleToPath = new Map([
+ ["Java", "Notes/Java.md"],
+ ["Welcome", "Notes/Welcome.md"],
+]);
+
+describe("parseLinkTarget", () => {
+ it("splits note and block id on #^", () => {
+ expect(parseLinkTarget("Java#^abc")).toEqual({ note: "Java", blockId: "abc" });
+ });
+ it("returns null blockId when absent", () => {
+ expect(parseLinkTarget("Java")).toEqual({ note: "Java", blockId: null });
+ });
+ it("trims surrounding whitespace", () => {
+ expect(parseLinkTarget(" Java ")).toEqual({ note: "Java", blockId: null });
+ });
+});
+
+describe("resolveWikilink", () => {
+ it("resolves by title first", () => {
+ expect(resolveWikilink("Java", titleToPath)).toBe("Notes/Java.md");
+ });
+ it("treats a path with '/' as a raw rel path (escape hatch)", () => {
+ expect(resolveWikilink("Notes/Java.md", new Map())).toBe("Notes/Java.md");
+ });
+ it("strips a leading ./ from escape-hatch paths", () => {
+ expect(resolveWikilink("./Notes/Java.md", new Map())).toBe("Notes/Java.md");
+ });
+ it("decodes a block ref then resolves by title", () => {
+ expect(resolveWikilink("Java#^abc", titleToPath)).toBe("Notes/Java.md");
+ });
+ it("returns null for an unknown title", () => {
+ expect(resolveWikilink("Nope", titleToPath)).toBeNull();
+ });
+ it("returns null for empty or whitespace input", () => {
+ expect(resolveWikilink("", titleToPath)).toBeNull();
+ expect(resolveWikilink(" ", titleToPath)).toBeNull();
+ });
+});
diff --git a/packages/editor/src/extensions/resolver/resolver.ts b/packages/editor/src/extensions/resolver/resolver.ts
new file mode 100644
index 0000000..5b0bf39
--- /dev/null
+++ b/packages/editor/src/extensions/resolver/resolver.ts
@@ -0,0 +1,23 @@
+export interface ParsedLinkTarget {
+ note: string;
+ blockId: string | null;
+}
+
+export function parseLinkTarget(raw: string): ParsedLinkTarget {
+ const target = raw.trim();
+ const hashIdx = target.indexOf("#^");
+ if (hashIdx === -1) return { note: target, blockId: null };
+ return { note: target.slice(0, hashIdx), blockId: target.slice(hashIdx + 2) };
+}
+
+export function resolveWikilink(
+ raw: string,
+ titleToPath: ReadonlyMap,
+): string | null {
+ const { note } = parseLinkTarget(raw);
+ if (note.length === 0) return null;
+ if (note.includes("/") || note.toLowerCase().endsWith(".md")) {
+ return note.replace(/^\.\//, "");
+ }
+ return titleToPath.get(note) ?? null;
+}
diff --git a/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts b/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts
new file mode 100644
index 0000000..fdb6f15
--- /dev/null
+++ b/packages/editor/src/extensions/wikilinks/__tests__/wikilinks.spec.ts
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from "vitest";
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { blockRefs } from "../../blockrefs/blockRef.js";
+import { handleWikilinkClick, wikilinks } from "../wikilinks.js";
+
+function makeView(doc: string, titleToPath: ReadonlyMap) {
+ const onOpen = vi.fn();
+ const view = new EditorView({
+ state: EditorState.create({
+ doc,
+ extensions: [wikilinks({ getIndex: () => titleToPath, onOpen }), blockRefs()],
+ }),
+ parent: document.body,
+ });
+ return { view, onOpen };
+}
+
+function insideLinkPos(view: EditorView, target: string): number {
+ const line = view.state.doc.line(1);
+ return line.from + line.text.indexOf(target) + 1;
+}
+
+describe("wikilinks extension", () => {
+ it("marks resolved links", () => {
+ const { view } = makeView("[[Java]]", new Map([["Java", "Notes/Java.md"]]));
+ expect(view.dom.querySelectorAll(".cm-wikilink").length).toBe(1);
+ expect(view.dom.querySelectorAll(".cm-wikilink-unresolved").length).toBe(0);
+ view.destroy();
+ });
+
+ it("marks unresolved links faint", () => {
+ const { view } = makeView("[[Nope]]", new Map());
+ expect(view.dom.querySelectorAll(".cm-wikilink-unresolved").length).toBe(1);
+ view.destroy();
+ });
+
+ it("decorates ^id line-end definitions", () => {
+ const { view } = makeView("Some paragraph ^block", new Map());
+ expect(view.dom.querySelectorAll(".cm-blockref-def").length).toBe(1);
+ view.destroy();
+ });
+
+ it("opens a resolved link on click", () => {
+ 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");
+ view.destroy();
+ });
+
+ it("opens an escape-hatch path on click", () => {
+ 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");
+ view.destroy();
+ });
+
+ it("does not open an unresolved link", () => {
+ const { view, onOpen } = makeView("[[Nope]]", new Map());
+ const path = handleWikilinkClick(view, insideLinkPos(view, "Nope"), {
+ getIndex: () => new Map(),
+ onOpen,
+ });
+ expect(path).toBeNull();
+ expect(onOpen).not.toHaveBeenCalled();
+ view.destroy();
+ });
+});
diff --git a/packages/editor/src/extensions/wikilinks/wikilinks.css b/packages/editor/src/extensions/wikilinks/wikilinks.css
new file mode 100644
index 0000000..ce6f614
--- /dev/null
+++ b/packages/editor/src/extensions/wikilinks/wikilinks.css
@@ -0,0 +1,10 @@
+.cm-wikilink {
+ color: var(--cm-wikilink, #7dd3fc);
+ text-decoration: underline;
+ cursor: pointer;
+}
+.cm-wikilink-unresolved {
+ color: var(--cm-wikilink-unresolved, rgba(148, 163, 184, 0.45));
+ text-decoration: none;
+ cursor: default;
+}
diff --git a/packages/editor/src/extensions/wikilinks/wikilinks.ts b/packages/editor/src/extensions/wikilinks/wikilinks.ts
new file mode 100644
index 0000000..eb6cf57
--- /dev/null
+++ b/packages/editor/src/extensions/wikilinks/wikilinks.ts
@@ -0,0 +1,71 @@
+import {
+ Decoration,
+ MatchDecorator,
+ ViewPlugin,
+ type DecorationSet,
+ type EditorView,
+ type ViewUpdate,
+} from "@codemirror/view";
+import { resolveWikilink } from "../resolver/resolver.js";
+import "./wikilinks.css";
+
+export interface WikilinkOptions {
+ getIndex: () => ReadonlyMap;
+ onOpen: (path: string) => void;
+}
+
+const linkRe = /\[\[([^\[\]\n]+)\]\]/g;
+
+export function wikilinks(opts: WikilinkOptions) {
+ const matcher = new MatchDecorator({
+ regexp: linkRe,
+ decoration: (match) =>
+ Decoration.mark({
+ class:
+ resolveWikilink(match[1]!, opts.getIndex()) === null
+ ? "cm-wikilink cm-wikilink-unresolved"
+ : "cm-wikilink",
+ }),
+ });
+
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+ constructor(view: EditorView) {
+ this.decorations = matcher.createDeco(view);
+ }
+ update(update: ViewUpdate) {
+ this.decorations = matcher.updateDeco(update, this.decorations);
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ eventHandlers: {
+ click: (event, view) => {
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
+ if (pos !== null) handleWikilinkClick(view, pos, opts);
+ },
+ },
+ },
+ );
+}
+
+export function handleWikilinkClick(
+ view: EditorView,
+ pos: number,
+ opts: WikilinkOptions,
+): string | null {
+ const line = view.state.doc.lineAt(pos);
+ const re = /\[\[([^\[\]\n]+)\]\]/g;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(line.text)) !== null) {
+ 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);
+ return path;
+ }
+ }
+ return null;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3512bf6..b8c3fda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -175,6 +175,12 @@ importers:
'@types/react':
specifier: 'catalog:'
version: 19.2.17
+ jsdom:
+ specifier: ^29
+ version: 29.1.1
+ vitest:
+ specifier: ^4
+ version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
packages/sync-git: {}