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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/routes/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -179,7 +183,12 @@ export default function Home() {
Open a note to start editing
</div>
) : (
<CodeMirrorEditor value={content} onChange={setContent} />
<CodeMirrorEditor
value={content}
onChange={setContent}
links={linkIndex}
onOpenLink={(rel) => void openNote(rel)}
/>
)}
</main>
{doctorOpen && openedVault !== null && (
Expand Down
6 changes: 4 additions & 2 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "echo \"no tests yet\""
"test": "vitest run"
},
"dependencies": {
"react": "catalog:",
Expand All @@ -21,6 +21,8 @@
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "catalog:"
"@types/react": "catalog:",
"vitest": "^4",
"jsdom": "^29"
}
}
20 changes: 19 additions & 1 deletion packages/editor/src/CodeMirrorEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
onOpenLink?: (path: string) => void;
}

export function CodeMirrorEditor({ value, onChange }: Props) {
export function CodeMirrorEditor({ value, onChange, links, onOpenLink }: Props) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initialValueRef = useRef(value);
const onChangeRef = useRef(onChange);
const linksRef = useRef(links ?? new Map<string, string>());
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;

Expand All @@ -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());
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.css";
3 changes: 3 additions & 0 deletions packages/editor/src/extensions/blockrefs/blockRef.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.cm-blockref-def {
color: var(--cm-blockref-def, #f59e0b);
}
31 changes: 31 additions & 0 deletions packages/editor/src/extensions/blockrefs/blockRef.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
4 changes: 3 additions & 1 deletion packages/editor/src/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export {};
export { blockRefs } from "./blockrefs/blockRef.js";
export { parseLinkTarget, resolveWikilink } from "./resolver/resolver.js";
export { handleWikilinkClick, wikilinks } from "./wikilinks/wikilinks.js";
41 changes: 41 additions & 0 deletions packages/editor/src/extensions/resolver/__tests__/resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 23 additions & 0 deletions packages/editor/src/extensions/resolver/resolver.ts
Original file line number Diff line number Diff line change
@@ -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, string>,
): 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;
}
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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();
});
});
10 changes: 10 additions & 0 deletions packages/editor/src/extensions/wikilinks/wikilinks.css
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 71 additions & 0 deletions packages/editor/src/extensions/wikilinks/wikilinks.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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;
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.