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: {}