diff --git a/.work/todo.jsonl b/.work/todo.jsonl index c348468..524f469 100644 --- a/.work/todo.jsonl +++ b/.work/todo.jsonl @@ -313,3 +313,6 @@ {"actor":"unknown","ev":"01M25RY5AMM31SSJHMEGCBEKSX","item":"01M25RY5AM6ZWRGEAJP34X06WN","op":"create","set":{"body":"Stamp CHANGELOG 0.6.5, lockstep Cargo.toml / Cargo.lock / tauri.conf, freeze the roadmap snapshot, tag and GitHub release after merge.","kind":"ops","level":"task","milestone":"v0.6.5","priority":"P2","status":"todo","title":"chore(release): v0.6.5 changelog, version bump, roadmap snapshot"},"ts":"2026-09-10T13:43:25Z"} {"actor":"unknown","ev":"01M25RYA4X739KYTP12MZ7T78A","item":"01M25RY5AM6ZWRGEAJP34X06WN","op":"update","set":{"status":"in_progress"},"ts":"2026-09-10T13:43:30Z"} {"actor":"unknown","ev":"01M25RZ3DETFBVRSQRJGJYS1YS","item":"01M25RY5AM6ZWRGEAJP34X06WN","op":"close","set":{"resolution":"v0.6.5 changelog, Cargo.toml / Cargo.lock / tauri.conf bump, roadmap snapshot, index plane re-rendered. Tag and GitHub release after merge.","status":"done"},"ts":"2026-09-10T13:43:56Z"} +{"actor":"unknown","ev":"01M3M9CAJ06T2QQRH16KJM32S3","item":"01M3M9CAJ08DJKX7T6E0EE8SP3","op":"create","set":{"body":"Clicking a markdown link that points at a file on disk loads that note in the editor. Directory paths like nested/note.md must survive TipTap's URI check. Web links stay web links. A miss does not navigate the window away.","kind":"feature","level":"task","milestone":"v0.6.6","priority":"P1","status":"todo","title":"Open on-disk markdown links in the editor"},"ts":"2026-09-28T15:15:50Z"} +{"actor":"unknown","ev":"01M3M9CF706C4JEMVXE0Z4ABMV","item":"01M3M9CAJ08DJKX7T6E0EE8SP3","op":"update","set":{"status":"in_progress"},"ts":"2026-09-28T15:15:55Z"} +{"actor":"unknown","ev":"01M3M9CF8FR5DR5MCD4YPYH4B7","item":"01M3M9CAJ08DJKX7T6E0EE8SP3","op":"close","set":{"resolution":"WYSIWYG click loads an in-folder markdown target. TipTap URI check no longer drops dir/note.md. Miss does not navigate. e2e/links.spec.ts 3 passed. Outside-folder switch is desktop-only and not dogfooded.","status":"done"},"ts":"2026-09-28T15:15:55Z"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b2a71..579f8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Motion are recorded here. Dates are UTC. ## Unreleased +### Added + +- **Note links.** Click a link in the editor and, if it points at a markdown + file on disk, that note opens. Relative paths resolve from the current note, + then from the folder. Web links stay web links. A link that is not a file + does not navigate the window away. + ## 0.6.5 — 2026-09-10 Header drag actually works on a Mac; the format toolbar stays put while the note diff --git a/docs/.index/_graph.json b/docs/.index/_graph.json index 38083d8..aeeb8af 100644 --- a/docs/.index/_graph.json +++ b/docs/.index/_graph.json @@ -925,6 +925,16 @@ "to": "release/v0.6.5", "type": "targets" }, + { + "from": "item/01M3M9CAJ08DJKX7T6E0EE8SP3", + "to": "pr/62", + "type": "lands-in" + }, + { + "from": "item/01M3M9CAJ08DJKX7T6E0EE8SP3", + "to": "release/v0.6.6", + "type": "targets" + }, { "from": "plan/2026-07-26-motion-next-phase", "to": "item/01KYFZ6RBH4J746JDANTQKYVSN", @@ -2159,6 +2169,11 @@ "status": "done", "title": "chore(release): v0.6.5 changelog, version bump, roadmap snapshot" }, + "item/01M3M9CAJ08DJKX7T6E0EE8SP3": { + "doc_type": "item", + "status": "done", + "title": "Open on-disk markdown links in the editor" + }, "plan/2026-07-26-motion-next-phase": { "doc_type": "plan", "source": "docs/plans/2026-07-26-motion-next-phase.md", @@ -2252,6 +2267,9 @@ "pr/61": { "doc_type": "pr" }, + "pr/62": { + "doc_type": "pr" + }, "release/v0.1.0": { "doc_type": "release" }, @@ -2276,6 +2294,9 @@ "release/v0.6.5": { "doc_type": "release" }, + "release/v0.6.6": { + "doc_type": "release" + }, "roadmap": { "doc_type": "roadmap", "source": "docs/roadmap.md", diff --git a/docs/.index/_inventory.json b/docs/.index/_inventory.json index 1e8f1cc..3216367 100644 --- a/docs/.index/_inventory.json +++ b/docs/.index/_inventory.json @@ -207,10 +207,10 @@ { "canonical_key": "roadmap", "doc_type": "roadmap", - "generated_at": "2026-09-10T13:43:56Z", + "generated_at": "2026-09-28T15:15:55Z", "slug": "roadmap", "source": "docs/roadmap.md", - "source_hash": "084d94a8", + "source_hash": "54c1f073", "title": "Roadmap", "truth_state": "current", "wiki": "https://github.com/SpillwaveSolutions/motion/wiki/Roadmap", diff --git a/docs/.index/item/01M3M9CAJ08DJKX7T6E0EE8SP3.yml b/docs/.index/item/01M3M9CAJ08DJKX7T6E0EE8SP3.yml new file mode 100644 index 0000000..0936a9a --- /dev/null +++ b/docs/.index/item/01M3M9CAJ08DJKX7T6E0EE8SP3.yml @@ -0,0 +1,2 @@ +code: + - {pr: 62} diff --git a/docs/.index/publish-manifest.json b/docs/.index/publish-manifest.json index 30a3471..95b73ef 100644 --- a/docs/.index/publish-manifest.json +++ b/docs/.index/publish-manifest.json @@ -44,7 +44,7 @@ "frozen": false, "page_name": "Index-Traceability", "render": "as-is", - "render_hash": "e34c8294bac6", + "render_hash": "5e42ca21ebac", "source": "docs/.index/rendered/traceability.md", "title": "Traceability Index", "truth_state": "current", @@ -240,6 +240,16 @@ "truth_state": "not tracked", "wiki_key": "pr/61" }, + { + "frozen": false, + "page_name": "PR-62", + "render": "as-is", + "render_hash": "61aee400c1d0", + "source": "docs/.index/rendered/prs/62.md", + "title": "PR #62", + "truth_state": "not tracked", + "wiki_key": "pr/62" + }, { "frozen": false, "page_name": "Release-v0.1.0+dogfood", @@ -320,6 +330,16 @@ "truth_state": "shipped", "wiki_key": "release/v0.6.5" }, + { + "frozen": false, + "page_name": "Release-v0.6.6", + "render": "as-is", + "render_hash": "3ee0e9f04a8f", + "source": "docs/.index/rendered/releases/v0.6.6.md", + "title": "Release v0.6.6", + "truth_state": "shipped", + "wiki_key": "release/v0.6.6" + }, { "frozen": false, "page_name": "Ticket-01KYDZ4A5HN1AZ7BN8Q2WV3XVN", @@ -1630,6 +1650,16 @@ "truth_state": "done", "wiki_key": "item/01M25RY5AM6ZWRGEAJP34X06WN" }, + { + "frozen": false, + "page_name": "Ticket-01M3M9CAJ08DJKX7T6E0EE8SP3", + "render": "as-is", + "render_hash": "52f730224dd8", + "source": "docs/.index/rendered/tickets/01M3M9CAJ08DJKX7T6E0EE8SP3.md", + "title": "Open on-disk markdown links in the editor", + "truth_state": "done", + "wiki_key": "item/01M3M9CAJ08DJKX7T6E0EE8SP3" + }, { "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-07-29T23:00:00Z. Historical snapshots are linked from [[Index-Releases]].", "frozen": false, @@ -1719,11 +1749,11 @@ "wiki_key": "plan/2026-09-03-editor-surface-zoom-layout-icons" }, { - "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-09-10T13:43:56Z. Historical snapshots are linked from [[Index-Releases]].", + "banner": "> **Current** \u2014 this is the living version; regenerated at 2026-09-28T15:15:55Z. Historical snapshots are linked from [[Index-Releases]].", "frozen": false, "page_name": "Roadmap", "render": "doc+banner", - "render_hash": "5ae901bdd074", + "render_hash": "62ddeedd6b15", "source": "docs/roadmap.md", "title": "Roadmap", "truth_state": "current", @@ -1811,7 +1841,7 @@ "frozen": false, "page_name": "User-Guide", "render": "doc+banner", - "render_hash": "da2058074062", + "render_hash": "02928d595135", "source": "docs/user_guide/user-guide.md", "title": "User Guide", "truth_state": "current", diff --git a/docs/.index/rendered/prs/62.md b/docs/.index/rendered/prs/62.md new file mode 100644 index 0000000..395e936 --- /dev/null +++ b/docs/.index/rendered/prs/62.md @@ -0,0 +1,13 @@ +# PR #62 + +`pr/62` · status: **not tracked** + +Changed files: not tracked. Test/Review status: not tracked (see the deferred PR live-metadata sync item). + +## Linked Tickets + +- [[Ticket-01M3M9CAJ08DJKX7T6E0EE8SP3]] Open on-disk markdown links in the editor — Clicking a markdown link that points at a file on disk loads that note in the editor. + +## Related Releases + +- [[Release-v0.6.6]] diff --git a/docs/.index/rendered/releases/v0.6.6.md b/docs/.index/rendered/releases/v0.6.6.md new file mode 100644 index 0000000..72548c8 --- /dev/null +++ b/docs/.index/rendered/releases/v0.6.6.md @@ -0,0 +1,17 @@ +# Release v0.6.6 + +`release/v0.6.6` · **shipped** + +1 item(s) shipped. + +## Change Log + +- [[Ticket-01M3M9CAJ08DJKX7T6E0EE8SP3]] Open on-disk markdown links in the editor — Clicking a markdown link that points at a file on disk loads that note in the editor. + +## Release Tree + +- [[Ticket-01M3M9CAJ08DJKX7T6E0EE8SP3]] Open on-disk markdown links in the editor (task) + +## Related PRs + +- [[PR-62]] diff --git a/docs/.index/rendered/tickets/01M3M9CAJ08DJKX7T6E0EE8SP3.md b/docs/.index/rendered/tickets/01M3M9CAJ08DJKX7T6E0EE8SP3.md new file mode 100644 index 0000000..b3154b4 --- /dev/null +++ b/docs/.index/rendered/tickets/01M3M9CAJ08DJKX7T6E0EE8SP3.md @@ -0,0 +1,13 @@ +# Open on-disk markdown links in the editor + +`01M3M9CAJ08DJKX7T6E0EE8SP3` · task/feature · **done** + +Clicking a markdown link that points at a file on disk loads that note in the editor. + +## Linked PRs + +- [[PR-62]] + +## Release + +- [[Release-v0.6.6]] diff --git a/docs/.index/rendered/traceability.md b/docs/.index/rendered/traceability.md index 2a18602..a04ecca 100644 --- a/docs/.index/rendered/traceability.md +++ b/docs/.index/rendered/traceability.md @@ -2,6 +2,11 @@ _The evidence chain: plan → item → ticket → code → release, forward and backward. Generated from `docs/.index/_graph.json`; do not edit._ +### Open on-disk markdown links in the editor +`01M3M9CAJ08DJKX7T6E0EE8SP3` · status: done +- lands-in: pr/62 +- targets: release/v0.6.6 + ### chore(release): v0.6.5 changelog, version bump, roadmap snapshot `01M25RY5AM6ZWRGEAJP34X06WN` · status: done - lands-in: pr/61 diff --git a/docs/roadmap.md b/docs/roadmap.md index 8dcef4e..3db74d5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,8 @@ wiki_key: roadmap doc_type: roadmap truth_state: current -source_hash: 084d94a8 -generated_at: 2026-09-10T13:43:56Z +source_hash: 54c1f073 +generated_at: 2026-09-28T15:15:55Z --- diff --git a/docs/user_guide/user-guide.md b/docs/user_guide/user-guide.md index 12697c2..2dc95f8 100644 --- a/docs/user_guide/user-guide.md +++ b/docs/user_guide/user-guide.md @@ -52,7 +52,12 @@ opens the app. 2. The sidebar is a **collapsible directory tree** of every `.md` underneath the workspace. Use **Search notes** (⌘K / Ctrl+K) to filter by filename or by text inside the notes. A content hit shows a short snippet. -3. Click a note to open it. +3. Click a note to open it. In the note itself, click a link and Motion opens + that file when it is markdown on disk. `getting-started.md`, `./notes/topic.md`, + `../welcome.md`, a name without `.md`, and a folder (its `README.md` or + `index.md`) all work. A web address is not opened as a note. A link that + does not match a file leaves you where you are. On the Mac app, an absolute + path to a markdown file outside the open folder opens that file's folder. 4. Edit, then press **⌘S** (Ctrl+S) or click the **Save** icon in the header. The status area shows **Saving…** / **Saved** / **Save failed**. diff --git a/e2e/links.spec.ts b/e2e/links.spec.ts new file mode 100644 index 0000000..d683109 --- /dev/null +++ b/e2e/links.spec.ts @@ -0,0 +1,41 @@ +/** + * A markdown link to a file on disk loads that note. A miss must not + * navigate the webview (the page guard treats the resulting 404 as a failure). + */ +import { test, expect, gotoApp } from "./fixtures"; + +test("clicking a relative markdown link opens that note", async ({ page }) => { + await gotoApp(page, "/?open=scratch-links.md"); + await expect(page.locator(".ProseMirror")).toContainText("Links"); + + await page.locator(".ProseMirror a", { hasText: "Getting started" }).click(); + + await expect(page.getByRole("treeitem", { name: "getting-started.md" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.locator(".ProseMirror")).toContainText("Getting started"); + await expect(page.getByRole("heading", { name: "Getting started" })).toBeVisible(); +}); + +test("clicking a nested markdown link opens that note", async ({ page }) => { + await gotoApp(page, "/?open=scratch-links.md"); + await page.locator(".ProseMirror a", { hasText: "Deeper" }).click(); + + await expect(page.getByRole("treeitem", { name: "deeper.md" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.locator(".ProseMirror")).toContainText("Deeper"); +}); + +test("a link that is not on disk stays on the note", async ({ page }) => { + await gotoApp(page, "/?open=scratch-links.md"); + await page.locator(".ProseMirror a", { hasText: "Missing" }).click(); + + await expect(page.getByRole("treeitem", { name: "scratch-links.md" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.locator(".ProseMirror")).toContainText("Links"); +}); diff --git a/e2e/workspace.ts b/e2e/workspace.ts index 738fabc..a613aff 100644 --- a/e2e/workspace.ts +++ b/e2e/workspace.ts @@ -36,7 +36,8 @@ export const SEED_FILES: Record = { "# Scratch: commands\n\nThe quick brown fox jumps over the lazy dog.\n\n| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n", // Tight GFM pipes (no padding). The serializer rewrites these to // `| Name | Role |` — switching views must not treat that as an edit. - "scratch-dirty.md": "# Tight pipes\n\n|Name|Role|\n|---|---|\n|Ada|Engineer|\n", + "scratch-links.md": + "# Links\n\nSee [Getting started](getting-started.md).\n\nSee [Deeper](nested/deeper.md).\n\nSee [Missing](does-not-exist.md).\n", }; export function createWorkspace(): string { diff --git a/src/App.tsx b/src/App.tsx index 79e83cf..20bb428 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { import { storage, rememberWorkspaceRoot, relativeToWorkspace, isTauri } from "./lib/storage"; import { synthesizeWorkspace } from "./lib/workspaceSynthesis"; import { parseOpenQuery, resolveOpenQuery } from "./lib/openFile"; +import type { NoteLinkAction } from "./lib/noteLink"; import { loadPersistedWorkspace, persistWorkspace } from "./lib/workspaceMemory"; import { buildCopyPayload, writeCopyPayload } from "./lib/copyNote"; import { useZoom } from "./lib/useZoom"; @@ -456,6 +457,26 @@ function App() { setNotesOpen(false); }; + const handleOpenLink = useCallback( + (action: Extract) => { + if (action.action === "switch") { + // Browser mode's workspace is fixed by the server. Only the + // desktop app can follow a markdown file outside the folder. + if (isTauri()) void activateWorkspace(action.workspace, action.file); + return; + } + setCurrentFilePath(action.path); + setNotesOpen(false); + setExpanded((prev) => { + if (prev === null || !workspacePath) return prev; + const next = new Set(prev); + for (const folder of ancestorFolders([action.path], workspacePath)) next.add(folder); + return next; + }); + }, + [activateWorkspace, workspacePath], + ); + const handleNewNote = async () => { if (!workspacePath) { alert("Open a folder first to create a new note."); @@ -1052,6 +1073,9 @@ function App() { onMarkdownChange={handleMarkdownChange} splitRatio={splitRatio} onSplitRatioChange={setSplitRatio} + workspaceRoot={workspacePath} + files={files} + onOpenLink={handleOpenLink} /> diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index c536ede..e375f89 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -21,6 +21,7 @@ import { ImageGenExtension } from "./extensions/ImageGenExtension"; import { DiagramGenExtension } from "./extensions/DiagramGenExtension"; import { tableKit } from "./extensions/tableKit"; import { htmlToMarkdown, markdownToHtml } from "./markdown"; +import { isNoteLinkUri, resolveNoteLink, type NoteLinkAction, type NoteLinkContext } from "../../lib/noteLink"; import { storage } from "../../lib/storage"; import { escapeHtmlText, sanitizeHtml } from "../../lib/sanitize"; import { @@ -70,6 +71,11 @@ interface EditorProps { onMarkdownChange?: (markdown: string) => void; splitRatio?: number; onSplitRatioChange?: (ratio: number) => void; + /** Open folder, so a relative link can be resolved. */ + workspaceRoot?: string | null; + /** Markdown files in that folder. The click handler will not open anything else in-place. */ + files?: readonly string[]; + onOpenLink?: (action: Extract) => void; } interface SlashMenuState { @@ -183,6 +189,9 @@ function Editor({ onMarkdownChange, splitRatio = SPLIT_DEFAULT, onSplitRatioChange, + workspaceRoot = null, + files = [], + onOpenLink, }: EditorProps) { const [rawMarkdown, setRawMarkdown] = useState(""); const rawMarkdownRef = useRef(""); @@ -205,6 +214,10 @@ function Editor({ viewModeRef.current = viewMode; const filePathRef = useRef(filePath); filePathRef.current = filePath; + const linkCtxRef = useRef({ currentFile: filePath, workspaceRoot, files }); + linkCtxRef.current = { currentFile: filePath, workspaceRoot, files }; + const onOpenLinkRef = useRef(onOpenLink); + onOpenLinkRef.current = onOpenLink; const [slashMenu, setSlashMenu] = useState(null); const [inTable, setInTable] = useState(false); @@ -276,6 +289,16 @@ function Editor({ extensions: [ StarterKit.configure({ codeBlock: false, + link: { + openOnClick: false, + protocols: ["file"], + isAllowedUri: (url) => isNoteLinkUri(url), + HTMLAttributes: { + target: null, + rel: "noopener noreferrer", + class: null, + }, + }, }), tableKit, MermaidExtension, @@ -334,6 +357,29 @@ function Editor({ } return false; }, + // A markdown link to a file on disk loads that note. Web links + // open outside. Anything else must not navigate the webview — + // a relative href would otherwise leave the app. + handleClick: (_view, _pos, event) => { + if (event.button !== 0) return false; + const node = event.target; + const el = node instanceof Element ? node : node instanceof Node ? node.parentElement : null; + const anchor = el?.closest("a"); + if (!(anchor instanceof HTMLAnchorElement)) return false; + const href = anchor.getAttribute("href"); + if (!href) return false; + const decision = resolveNoteLink(href, linkCtxRef.current); + if (decision.action === "ignore") return false; + event.preventDefault(); + if (decision.action === "external") { + window.open(decision.url, "_blank", "noopener,noreferrer"); + return true; + } + if (decision.action === "open" || decision.action === "switch") { + onOpenLinkRef.current?.(decision); + } + return true; + }, }, // Keep rawMarkdown in sync with WYSIWYG/split edits as they happen, so // switching to markdown mode (or the split pane) never shows stale diff --git a/src/index.css b/src/index.css index e5cfac2..f4c589e 100644 --- a/src/index.css +++ b/src/index.css @@ -641,6 +641,7 @@ pre { .tiptap-editor a { color: var(--color-text-link); text-decoration: underline; + cursor: pointer; } /* GFM tables (wireframes/tables.md) */ diff --git a/src/lib/noteLink.test.ts b/src/lib/noteLink.test.ts new file mode 100644 index 0000000..ef335a9 --- /dev/null +++ b/src/lib/noteLink.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { isNoteLinkUri, resolveNoteLink } from "./noteLink"; + +const root = "/tmp/ws"; +const files = [ + "/tmp/ws/welcome.md", + "/tmp/ws/getting-started.md", + "/tmp/ws/nested/deeper.md", + "/tmp/ws/nested/README.md", + "/tmp/ws/My Note.md", + "/tmp/ws/a/readme.md", + "/tmp/ws/b/readme.md", +]; + +function open(href: string, currentFile: string | null) { + return resolveNoteLink(href, { currentFile, workspaceRoot: root, files }); +} + +describe("resolveNoteLink", () => { + test("opens a sibling relative link", () => { + expect(open("getting-started.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/getting-started.md", + }); + expect(open("./getting-started.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/getting-started.md", + }); + }); + + test("opens a nested link and a link back up", () => { + expect(open("nested/deeper.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/nested/deeper.md", + }); + expect(open("../welcome.md", "/tmp/ws/nested/deeper.md")).toEqual({ + action: "open", + path: "/tmp/ws/welcome.md", + }); + }); + + test("falls back to the workspace root when the note-relative path misses", () => { + expect(open("welcome.md", "/tmp/ws/nested/deeper.md")).toEqual({ + action: "open", + path: "/tmp/ws/welcome.md", + }); + }); + + test("opens absolute and file URLs inside the folder", () => { + expect(open("/tmp/ws/welcome.md", "/tmp/ws/nested/deeper.md")).toEqual({ + action: "open", + path: "/tmp/ws/welcome.md", + }); + expect(open("file:///tmp/ws/nested/deeper.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/nested/deeper.md", + }); + }); + + test("ignores a heading hash but still opens the file", () => { + expect(open("getting-started.md#install", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/getting-started.md", + }); + expect(open("#install", "/tmp/ws/welcome.md")).toEqual({ action: "ignore" }); + }); + + test("appends .md and opens a folder README", () => { + expect(open("getting-started", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/getting-started.md", + }); + expect(open("nested/", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/nested/README.md", + }); + }); + + test("matches a unique basename and decodes spaces, but not an ambiguous name", () => { + expect(open("My%20Note.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/My Note.md", + }); + expect(open("readme.md", "/tmp/ws/welcome.md")).toEqual({ action: "missing" }); + expect(open("a/readme.md", "/tmp/ws/welcome.md")).toEqual({ + action: "open", + path: "/tmp/ws/a/readme.md", + }); + }); + + test("matches case-insensitively when there is one file", () => { + expect(open("WELCOME.MD", "/tmp/ws/nested/deeper.md")).toEqual({ + action: "open", + path: "/tmp/ws/welcome.md", + }); + }); + + test("web, mail, and script URLs are not notes", () => { + expect(open("https://example.com/welcome.md", "/tmp/ws/welcome.md")).toEqual({ + action: "external", + url: "https://example.com/welcome.md", + }); + expect(open("mailto:ada@example.com", "/tmp/ws/welcome.md")).toEqual({ + action: "external", + url: "mailto:ada@example.com", + }); + expect(open("javascript:alert(1)", "/tmp/ws/welcome.md")).toEqual({ action: "ignore" }); + }); + + test("a local miss stays a miss", () => { + expect(open("does-not-exist.md", "/tmp/ws/welcome.md")).toEqual({ action: "missing" }); + expect(open("", "/tmp/ws/welcome.md")).toEqual({ action: "ignore" }); + }); + + test("an absolute markdown path outside the folder asks to switch workspace", () => { + expect(open("file:///Users/rick/notes/topic.md", "/tmp/ws/welcome.md")).toEqual({ + action: "switch", + file: "/Users/rick/notes/topic.md", + workspace: "/Users/rick/notes", + }); + }); + + test("returns the listing's own path string", () => { + const winFiles = ["C:\\notes\\a.md"]; + expect( + resolveNoteLink("file:///C:/notes/a.md", { + currentFile: "C:\\notes\\a.md", + workspaceRoot: "C:\\notes", + files: winFiles, + }), + ).toEqual({ action: "open", path: "C:\\notes\\a.md" }); + }); + + test("keeps directory links that TipTap's default check would drop", () => { + expect(isNoteLinkUri("nested/deeper.md")).toBe(true); + expect(isNoteLinkUri("./getting-started.md")).toBe(true); + expect(isNoteLinkUri("file:///tmp/ws/welcome.md")).toBe(true); + expect(isNoteLinkUri("https://example.com")).toBe(true); + expect(isNoteLinkUri("javascript:alert(1)")).toBe(false); + expect(isNoteLinkUri("data:text/html,hi")).toBe(false); + }); +}); diff --git a/src/lib/noteLink.ts b/src/lib/noteLink.ts new file mode 100644 index 0000000..48e126d --- /dev/null +++ b/src/lib/noteLink.ts @@ -0,0 +1,211 @@ +/** + * Decide what a clicked markdown link should do. + * + * Relative links resolve from the note that contains them, then from the + * workspace root. A hit must be a markdown file we already know about (the + * open folder's listing) unless it is an absolute path outside that folder, + * which the desktop app can open by switching workspace. Web URLs are not + * notes. + */ + +const NOTE_EXTS = ["md", "markdown", "mdown", "mkd", "mdx"] as const; + +export type NoteLinkAction = + | { action: "open"; path: string } + | { action: "switch"; file: string; workspace: string } + | { action: "external"; url: string } + | { action: "missing" } + | { action: "ignore" }; + +export interface NoteLinkContext { + currentFile: string | null; + workspaceRoot: string | null; + files: readonly string[]; +} + +/** TipTap's default check rejects `dir/note.md` (a slash after a letter). */ +export function isNoteLinkUri(url: string): boolean { + if (!url) return true; + return !/^(javascript|data|vbscript):/i.test(url.trim()); +} + +export function resolveNoteLink(href: string, ctx: NoteLinkContext): NoteLinkAction { + const raw = href.trim(); + if (!raw) return { action: "ignore" }; + if (/^(javascript|data|vbscript):/i.test(raw)) return { action: "ignore" }; + if (raw.startsWith("#")) return { action: "ignore" }; + if (/^(https?:|mailto:|tel:)/i.test(raw) || raw.startsWith("//")) { + return { action: "external", url: raw }; + } + + const pathPart = extractLocalPath(raw); + if (pathPart == null || !pathPart.trim()) return { action: "ignore" }; + + const dirHint = /[/\\]$/.test(pathPart); + const decoded = decodePath(pathPart).replace(/\\/g, "/"); + const root = ctx.workspaceRoot ? stripTrail(normalizePath(ctx.workspaceRoot)) : null; + const candidates = pathCandidates(decoded, dirHint, ctx, root); + const hit = matchFile(candidates, ctx.files); + if (hit) return { action: "open", path: hit }; + + if (!decoded.includes("/") && !isAbsolutePath(decoded)) { + const byName = uniqueBasename(decoded, ctx.files); + if (byName) return { action: "open", path: byName }; + } + + const absolute = candidates.find((c) => isAbsolutePath(c) && looksLikeNote(c)); + if (absolute && (!root || !isInside(absolute, root))) { + const workspace = dirnameOf(absolute); + if (workspace) return { action: "switch", file: absolute, workspace }; + } + + return { action: "missing" }; +} + +function extractLocalPath(raw: string): string | null { + if (/^file:/i.test(raw)) { + try { + const url = new URL(raw); + let path = decodeURIComponent(url.pathname); + if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + return path; + } catch { + return null; + } + } + let cut = raw.length; + const hash = raw.indexOf("#"); + const query = raw.indexOf("?"); + if (hash >= 0) cut = Math.min(cut, hash); + if (query >= 0) cut = Math.min(cut, query); + return raw.slice(0, cut); +} + +function pathCandidates( + decoded: string, + dirHint: boolean, + ctx: NoteLinkContext, + root: string | null, +): string[] { + const bases: string[] = []; + if (isAbsolutePath(decoded)) { + bases.push(normalizePath(decoded)); + } else { + const rel = decoded.replace(/^\/+/, ""); + if (ctx.currentFile) { + const dir = dirnameOf(normalizePath(ctx.currentFile)); + if (dir) bases.push(normalizePath(joinPath(dir, rel))); + } + if (root) bases.push(normalizePath(joinPath(root, rel))); + } + const out: string[] = []; + for (const base of bases) { + for (const extra of expand(base, dirHint)) out.push(extra); + } + return out; +} + +function expand(path: string, dirHint: boolean): string[] { + const clean = path.replace(/\/+$/, ""); + const extFirst = NOTE_EXTS.map((ext) => `${clean}.${ext}`); + const indexFirst = [`${clean}/README.md`, `${clean}/index.md`]; + if (looksLikeNote(clean)) return [clean]; + if (dirHint) return [...indexFirst, clean, ...extFirst]; + return [clean, ...extFirst, ...indexFirst]; +} + +function matchFile(candidates: string[], files: readonly string[]): string | null { + const exact = new Map(); + for (const file of files) exact.set(normalizePath(file), file); + for (const candidate of candidates) { + const hit = exact.get(normalizePath(candidate)); + if (hit) return hit; + } + const groups = new Map(); + for (const file of files) { + const key = normalizePath(file).toLowerCase(); + const list = groups.get(key) ?? []; + list.push(file); + groups.set(key, list); + } + for (const candidate of candidates) { + const list = groups.get(normalizePath(candidate).toLowerCase()); + if (list?.length === 1) return list[0]!; + } + return null; +} + +function uniqueBasename(hrefPath: string, files: readonly string[]): string | null { + const name = hrefPath.split("/").pop()?.toLowerCase(); + if (!name) return null; + const names = new Set([name]); + if (!looksLikeNote(name)) { + for (const ext of NOTE_EXTS) names.add(`${name}.${ext}`); + } + const hits = files.filter((file) => names.has((file.split(/[/\\]/).pop() ?? "").toLowerCase())); + return hits.length === 1 ? hits[0]! : null; +} + +function looksLikeNote(path: string): boolean { + const base = path.split("/").pop()?.toLowerCase() ?? ""; + return NOTE_EXTS.some((ext) => base.endsWith(`.${ext}`)); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:\//.test(path); +} + +function isInside(path: string, root: string): boolean { + const p = normalizePath(path); + const r = stripTrail(normalizePath(root)); + return p === r || p.startsWith(`${r}/`); +} + +function joinPath(dir: string, rel: string): string { + if (!dir || dir === "/") return `/${rel.replace(/^\/+/, "")}`; + return `${stripTrail(dir)}/${rel.replace(/^\/+/, "")}`; +} + +function dirnameOf(path: string): string { + const n = stripTrail(path.replace(/\\/g, "/")); + const i = n.lastIndexOf("/"); + if (i < 0) return ""; + if (i === 0) return "/"; + if (/^[A-Za-z]:$/.test(n.slice(0, i))) return `${n.slice(0, i)}/`; + return n.slice(0, i); +} + +function stripTrail(path: string): string { + if (path === "/") return path; + return path.replace(/\/+$/, ""); +} + +function decodePath(path: string): string { + if (!path.includes("%")) return path; + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +function normalizePath(path: string): string { + const slash = path.replace(/\\/g, "/"); + const drive = slash.match(/^([A-Za-z]:)(?=\/|$)/)?.[1]; + const body = drive ? slash.slice(drive.length) : slash; + const abs = body.startsWith("/") || Boolean(drive); + const parts: string[] = []; + for (const seg of body.split("/")) { + if (!seg || seg === ".") continue; + if (seg === "..") { + if (parts.length > 0) parts.pop(); + else if (!abs) parts.push(".."); + continue; + } + parts.push(seg); + } + const joined = parts.join("/"); + if (drive) return joined ? `${drive}/${joined}` : `${drive}/`; + if (abs) return `/${joined}`; + return joined; +} diff --git a/wireframes/REVIEW-note-links.md b/wireframes/REVIEW-note-links.md new file mode 100644 index 0000000..64ba347 --- /dev/null +++ b/wireframes/REVIEW-note-links.md @@ -0,0 +1,25 @@ +# Adversarial Review: Note links + +**Wireframe:** `wireframes/editor.md` (Note link row and the three new acceptance lines) +**Verdict:** PASS WITH NOTES + +## Criteria Results + +- [x] Clicking a markdown link to a file in the open folder loads that note and selects it in the tree. — PASS (`e2e/links.spec.ts`: `getting-started.md` and `nested/deeper.md` from `scratch-links.md`; treeitem `aria-selected`) +- [x] Clicking a link that does not resolve to a file does not navigate away from the current note. — PASS (same spec clicks `does-not-exist.md`; still on `scratch-links.md`; page guard saw no 404) +- [x] A web link is not loaded as a note. — PASS as a classifier (`resolveNoteLink` returns `external` for `https:` and `mailto:`). Not clicked in Playwright, so a popup cannot fail the network gate. +- [x] Directory hrefs such as `nested/deeper.md` stay links. — PASS. TipTap's default `isAllowedUri` rejects a slash after a letter and the e2e snapshot showed "See Deeper." as plain text. `isNoteLinkUri` allows it; the nested click then passed. + +## Evidence + +- `bun run typecheck` +- `bun run guard:client` (59 modules, no `Bun.`) +- `bun test src` (288, then `noteLink.test.ts` 13 after the URI check) +- Playwright `CI=1`: `e2e/links.spec.ts` 3 passed + +## Notes + +- Markdown source mode is a textarea. Links there are not clickable. WYSIWYG and the editor half of Split are. +- An absolute markdown path outside the open folder is classified as `switch`. The desktop app calls `activateWorkspace`. Browser mode does not, because the dev server's workspace is fixed. That path is unit-tested only. A Mac `.app` has not clicked it. +- `javascript:` and `data:` are not opened. +- Cmd-click is treated as a normal click so a relative href cannot leave the webview. diff --git a/wireframes/editor.md b/wireframes/editor.md index 4c6a269..f5a2f5c 100644 --- a/wireframes/editor.md +++ b/wireframes/editor.md @@ -51,6 +51,7 @@ Highlighted source -- see markdown-source.md. | Markdown source | textarea | aria-label Markdown source. Highlighted (headings, emphasis, code, links, lists). Placeholder Write your markdown here... See markdown-source.md. | | Split preview | pre | Right pane is live highlighted markdown (read-only, aria-label Markdown preview). A `role=separator` divider between the panes is draggable; see layout.md. The editor surface has **no** max-width cap — it fills the main column. | | Find in note | bar | ⌘/Ctrl+F opens. Matches the current note (WYSIWYG selection or markdown source). Enter next, Shift+Enter previous, Escape closes. aria-label Find in note. | +| Note link | anchor in the document | WYSIWYG and the editor side of Split. A click loads another markdown file when the href points at one on disk (relative to the current note, then the folder root; `.md` optional; a folder link opens `README.md` or `index.md`). `http` / `https` / `mailto` open outside the editor. A miss does not navigate the window. Same-document `#anchors` are left alone. Markdown source is text, so links there are not clickable. On the desktop app, an absolute markdown path outside the open folder opens that file's folder. | | Loading | copy | Loading editor... before TipTap mounts | ## States @@ -78,6 +79,9 @@ Highlighted source -- see markdown-source.md. - [ ] Markdown textarea is labeled Markdown source and is syntax-highlighted. - [ ] With no file selected, the welcome document (not a blank pane) is shown. - [ ] A failed file load shows an error inside the editor, not a blank page. +- [ ] Clicking a markdown link to a file in the open folder loads that note and selects it in the tree. +- [ ] Clicking a link that does not resolve to a file does not navigate away from the current note. +- [ ] A web link is not loaded as a note. - [ ] Insert buttons create Table, Mermaid, Dataset, Query, AI Diagram, and AI Image blocks. - [ ] Slash menu includes Ask AI first, then Table; toolbar insert buttons do not include Ask AI. - [ ] Refine previews before replacing the document; failure is in the panel, not an alert.