diff --git a/package-lock.json b/package-lock.json index e70d255..ab80210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@chainsafe/libp2p-gossipsub": "^14.1.2", "@chainsafe/libp2p-noise": "^16.0.0", "@chainsafe/libp2p-yamux": "^7.0.1", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", @@ -486,9 +487,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.20.2", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", - "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", diff --git a/package.json b/package.json index 9d9c1e1..bf86695 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@chainsafe/libp2p-gossipsub": "^14.1.2", "@chainsafe/libp2p-noise": "^16.0.0", "@chainsafe/libp2p-yamux": "^7.0.1", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/src/App.tsx b/src/App.tsx index db44378..373fd19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { UserIdentityProvider } from './auth/UserIdentityContext'; import { AppBottomBar } from './components/AppBottomBar'; import { AppSidebar } from './components/AppSidebar'; +import { WikiLinkVaultProvider } from './wikilinks/WikiLinkVaultContext'; import { AppTitleBar } from './components/AppTitleBar'; import { SettingsModal } from './components/SettingsModal'; import type { FileSystemProvider } from './filesystem/types'; @@ -416,82 +417,89 @@ function AppShell() { hotkeyManager={hotkeyManager} hotkeyStore={hotkeyStore} > -
- setSidebarOpen((o) => !o)} - workspaceKind={workspaceKind} - onWorkspaceKindChange={setWorkspaceKind} - onSplitRight={handleSplitRight} - onSplitDown={handleSplitDown} - /> - -
- setVaultManagerOpen(true)} - onOpenEntry={handleOpenEntry} - activeDocument={activeDocument} - onOpenInSplit={handleOpenInSplit} - unsyncedVaults={unsyncedVaults} - onSyncRemote={setSyncTarget} - /> -
- - p.id)} - selectedVaultId={selectedProviderId} - openRequestId={openRequestId} - openRequest={openRequest} - resolveProvider={resolveProvider} - tilingHandleRef={tilingHandleRef} - onNewFile={handleNewFile} - onOpenEntry={handleOpenEntry} - onActiveDocumentChange={setActiveDocument} - /> - } - /> - } /> - -
-
-
- setSettingsOpen(false)} - theme={theme} - onToggleTheme={() => - setTheme((t) => (t === 'light' ? 'dark' : 'light')) - } - workspaceKind={workspaceKind} - onWorkspaceKindChange={setWorkspaceKind} - /> - setVaultManagerOpen(false)} - onOpenVault={handleOpenVault} - onOpenSettings={() => setSettingsOpen(true)} - /> - setSyncTarget(null)} - onConfirm={handleSyncRemoteConfirm} - /> -
+ {/* Indexes each vault's files so `[[wikilinks]]` can resolve, and routes + activated links back into the same open channel the sidebar uses. */} + +
+ setSidebarOpen((o) => !o)} + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} + onSplitRight={handleSplitRight} + onSplitDown={handleSplitDown} + /> + +
+ setVaultManagerOpen(true)} + onOpenEntry={handleOpenEntry} + activeDocument={activeDocument} + onOpenInSplit={handleOpenInSplit} + unsyncedVaults={unsyncedVaults} + onSyncRemote={setSyncTarget} + /> +
+ + p.id)} + selectedVaultId={selectedProviderId} + openRequestId={openRequestId} + openRequest={openRequest} + resolveProvider={resolveProvider} + tilingHandleRef={tilingHandleRef} + onNewFile={handleNewFile} + onOpenEntry={handleOpenEntry} + onActiveDocumentChange={setActiveDocument} + /> + } + /> + } /> + +
+
+
+ setSettingsOpen(false)} + theme={theme} + onToggleTheme={() => + setTheme((t) => (t === 'light' ? 'dark' : 'light')) + } + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} + /> + setVaultManagerOpen(false)} + onOpenVault={handleOpenVault} + onOpenSettings={() => setSettingsOpen(true)} + /> + setSyncTarget(null)} + onConfirm={handleSyncRemoteConfirm} + /> +
+
); } diff --git a/src/editors/DocumentPane.tsx b/src/editors/DocumentPane.tsx index e8bbf5d..6d72475 100644 --- a/src/editors/DocumentPane.tsx +++ b/src/editors/DocumentPane.tsx @@ -7,7 +7,8 @@ export type DocumentPaneProps = Omit & { handle: DocumentHandle; }; -// `mode` and `onOpenLink` flow through the `...editorProps` spread below. +// `mode`, `onOpenLink`, `wikiLinkVault` and `onOpenWikiLink` all flow through +// the `...editorProps` spread below. /** * Workspace-facing editor mount. Resolves a registered `Editor` by binding diff --git a/src/editors/DocumentSlotView.tsx b/src/editors/DocumentSlotView.tsx index df4509e..21dcb59 100644 --- a/src/editors/DocumentSlotView.tsx +++ b/src/editors/DocumentSlotView.tsx @@ -1,6 +1,7 @@ import type { DocumentSlotState } from '../documents/useDocumentSlot'; import { resolveDocumentLink } from '../documents/resolveDocumentLink'; import type { OpenDocRequest } from '../workspaces/workspace'; +import { useWikiLinkDocument } from '../wikilinks/WikiLinkVaultContext'; import { DocumentPane } from './DocumentPane'; import type { EditorMode } from './types'; @@ -32,6 +33,13 @@ function SlotPlaceholder({ message }: { message: string }) { /** * Renders a document slot's empty, loading, error, or open states. * Workspaces compose this inside their layout chrome. + * + * Both kinds of internal navigation attach here rather than in each workspace, + * for the same reason: this is the one place that knows which document is open + * *and* which provider it came from, which is exactly what resolving a link + * target needs. Relative markdown links resolve through `resolveDocumentLink`; + * `[[wikilinks]]` resolve vault-wide through `useWikiLinkDocument`. Every + * workspace that renders a slot gets both without its own wiring. */ export function DocumentSlotView({ state, @@ -41,6 +49,14 @@ export function DocumentSlotView({ mode, onOpenEntry, }: DocumentSlotViewProps) { + // Hooks must run before the early returns below (rules of hooks). Only the + // `empty` state lacks a request — and it has no document to link from. + const slotRequest = state.kind === 'empty' ? null : state.request; + const { vault, openWikiLink } = useWikiLinkDocument( + slotRequest?.providerId ?? null, + slotRequest?.entryId ?? null, + ); + if (state.kind === 'empty') { return ; } @@ -83,6 +99,8 @@ export function DocumentSlotView({ className={className} mode={mode} onOpenLink={onOpenLink} + wikiLinkVault={vault} + onOpenWikiLink={openWikiLink} /> ); } diff --git a/src/editors/automerge/automergeDocumentEditor.tsx b/src/editors/automerge/automergeDocumentEditor.tsx index 63dc2db..a80efb9 100644 --- a/src/editors/automerge/automergeDocumentEditor.tsx +++ b/src/editors/automerge/automergeDocumentEditor.tsx @@ -6,8 +6,6 @@ import { keymap, placeholder, } from '@codemirror/view'; -import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; -import { GFM } from '@lezer/markdown'; import { defaultKeymap, indentWithTab, @@ -19,9 +17,12 @@ import { classHighlighter } from '@lezer/highlight'; import type { EditorProps } from '../types'; import { getAutomergePayload } from './automergeEditorBinding'; +import { textileMarkdown } from './markdownExtensions'; import { readDocContent, updateTextContent } from '../../vault/utils/text-diff'; import { livePreview } from './livePreview'; import { editorModeFacet } from './livePreview/mode'; +import { wikiLinkVaultFacet } from './livePreview/wikiLinkVault'; +import { wikiLinkCompletion } from './livePreview/wikiLinkComplete'; import { listEditingKeymap } from './livePreview/listKeymap'; import './automergeEditor.css'; @@ -48,6 +49,8 @@ export function AutomergeDocumentEditor({ placeholder: placeholderText, mode = 'edit', onOpenLink, + wikiLinkVault = null, + onOpenWikiLink, }: EditorProps) { const { getDoc, applyChange, subscribeToChanges } = getAutomergePayload(binding); @@ -60,8 +63,13 @@ export function AutomergeDocumentEditor({ // extension reads the current callback through this ref (mirrors applyChange). const onOpenLinkRef = useRef(onOpenLink); onOpenLinkRef.current = onOpenLink; + const onOpenWikiLinkRef = useRef(onOpenWikiLink); + onOpenWikiLinkRef.current = onOpenWikiLink; const modeCompartment = useRef(new Compartment()); + // The vault index arrives asynchronously and changes as files come and go, + // so it lives in its own compartment rather than being baked in at mount. + const wikiLinkCompartment = useRef(new Compartment()); // Current content as a string, used for diff computation. const contentRef = useRef(''); @@ -150,22 +158,23 @@ export function AutomergeDocumentEditor({ // the default handlers elsewhere. listEditingKeymap, keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), - // GFM markdown (strikethrough, tables, task lists). - // addKeymap: false — we supply our own list keymap (listEditingKeymap) - // instead of the built-in one, which turns lists "loose" (inserting a - // blank line between items). Ours keeps continuation tight. - markdown({ - base: markdownLanguage, - extensions: [GFM], - addKeymap: false, - }), + // GFM markdown (strikethrough, tables, task lists) plus `[[wikilinks]]`. + textileMarkdown(), + // Note-name completion for `[[`, sourced from the vault compartment + // below. Binds Enter above listEditingKeymap — see the module comment. + wikiLinkCompletion, // Live preview: hide markdown syntax markers, apply CSS classes. The - // link handler reads the current onOpenLink through the ref so it stays + // link handlers read the current callbacks through refs so they stay // fresh across renders without rebuilding the editor state. - livePreview(() => onOpenLinkRef.current), + livePreview({ + getOnOpenLink: () => onOpenLinkRef.current, + getOnOpenWikiLink: () => onOpenWikiLinkRef.current, + }), // Mode compartment: read-only + the mode facet, reconfigured at runtime // by the [mode] effect below. Seeded with the mount-time mode. modeCompartment.current.of(modeExtension(mode)), + // Vault index for wikilink resolution, reconfigured by the effect below. + wikiLinkCompartment.current.of(wikiLinkVaultFacet.of(wikiLinkVault)), // Change listener updateListener, ], @@ -203,6 +212,18 @@ export function AutomergeDocumentEditor({ }); }, [mode]); + // Swap the vault index when it loads or the vault's files change. Same + // reconfigure trick as [mode]: no remount, so cursor/scroll/history survive. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: wikiLinkCompartment.current.reconfigure( + wikiLinkVaultFacet.of(wikiLinkVault), + ), + }); + }, [wikiLinkVault]); + return (
ul` — three classes, (0,3,1). Dropping `.cm-editor` leaves us at (0,2,1) and + * CM silently wins, which is exactly how the monospace font survived the first + * attempt at this. `.cm-editor` is a real ancestor: with no `parent` configured, + * CM appends tooltips straight into `view.dom`. Each rule below is written to + * *strictly exceed* its CM counterpart rather than tie it, since ties would be + * resolved by stylesheet injection order, which we do not control. + */ +.automerge-editor-container .cm-editor .cm-tooltip.cm-tooltip-autocomplete { + background: var(--semantic-bg); + border: 1px solid var(--semantic-border); + border-radius: 6px; + box-shadow: 0 6px 16px rgb(0 0 0 / 12%); + /* Deliberately no `overflow: hidden` — the completion info pane is a child + positioned *outside* this box, so clipping here would hide it entirely if + options ever gain an `info` field. The list's own padding keeps rows off + the rounded corners instead. */ +} + +/* (0,4,1) beats CM's (0,3,1). */ +.automerge-editor-container + .cm-editor + .cm-tooltip.cm-tooltip-autocomplete + > ul { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + max-height: 16em; + padding: 2px 0; +} + +/* (0,4,2) beats CM's (0,3,2). */ +.automerge-editor-container + .cm-editor + .cm-tooltip.cm-tooltip-autocomplete + > ul + > li { + padding: 3px 8px; + color: var(--semantic-fg); +} + +/* + * Kill transitions across the whole popup subtree, not just the rows. + * + * The label is a `` inside the `li` (with + * `.cm-completionMatchedText` nested in turn), so it only *inherits* `color`. + * The `*` rule in `index.css` gives every one of those descendants its own + * 320ms colour transition, so when selection moved the row's background + * snapped instantly while its text crawled between the two colours — reading + * as the text darkening to black and back rather than tracking the highlight. + * + * The universal selector is deliberate: it has to reach elements CM creates, + * and matching `*` inside one small popup costs nothing. Specificity is not in + * play for `transition` — the only competing declaration is that `@layer base` + * rule, and any unlayered rule outranks a layered one. + */ +.automerge-editor-container .cm-editor .cm-tooltip.cm-tooltip-autocomplete, +.automerge-editor-container .cm-editor .cm-tooltip.cm-tooltip-autocomplete * { + transition: none; +} + +/* (0,5,2) beats CM's `&light …` at (0,3,2). */ +.automerge-editor-container + .cm-editor + .cm-tooltip.cm-tooltip-autocomplete + > ul + > li[aria-selected] { + background: var(--semantic-accent); + color: var(--semantic-primary-fg); +} + +/* The containing folder, shown after the note name. CM italicises this at + (0,2,0), which our un-prefixed selector merely tied; (0,3,0) settles it. */ +.automerge-editor-container .cm-editor .cm-completionDetail { + margin-left: 0.75em; + font-style: normal; + opacity: 0.6; +} + /* Code */ .automerge-editor-container .cm-inline-code { background: rgba(175, 184, 193, 0.2); diff --git a/src/editors/automerge/livePreview/context.ts b/src/editors/automerge/livePreview/context.ts index 7c471d8..425f878 100644 --- a/src/editors/automerge/livePreview/context.ts +++ b/src/editors/automerge/livePreview/context.ts @@ -196,10 +196,24 @@ export function shouldRebuild(update: ViewUpdate): boolean { ); } +export interface DecoExtensionOptions { + /** + * Extra rebuild trigger, OR-ed with `shouldRebuild`. Needed by builders that + * read a facet other than `editorModeFacet`: a compartment reconfigure sets + * none of the flags `shouldRebuild` looks at. Kept per-builder rather than + * folded into `shouldRebuild` so one builder's dependency doesn't force the + * other seven to rebuild too. + */ + rebuildOn?: (update: ViewUpdate) => boolean; +} + /** * Wrap a pure builder in a ViewPlugin + optional atomicRanges provider. */ -export function decorationExtension(build: DecoBuilder): Extension { +export function decorationExtension( + build: DecoBuilder, + options: DecoExtensionOptions = {}, +): Extension { const plugin = ViewPlugin.fromClass( class { decorations: DecorationSet; @@ -210,7 +224,7 @@ export function decorationExtension(build: DecoBuilder): Extension { this.atomic = r.atomic ?? Decoration.none; } update(update: ViewUpdate) { - if (!shouldRebuild(update)) return; + if (!shouldRebuild(update) && !options.rebuildOn?.(update)) return; const r = build(update.view.state, iterRanges(update.view)); this.decorations = r.decorations; this.atomic = r.atomic ?? Decoration.none; diff --git a/src/editors/automerge/livePreview/index.ts b/src/editors/automerge/livePreview/index.ts index ed6c119..215741e 100644 --- a/src/editors/automerge/livePreview/index.ts +++ b/src/editors/automerge/livePreview/index.ts @@ -19,7 +19,10 @@ import { blockquoteDecoration } from './blockquoteDecoration'; import { hrDecoration } from './hrDecoration'; import { listDecoration } from './listDecoration'; import { escapeDecoration } from './escapeDecoration'; +import { wikiLinkDecoration, wikiLinkAt } from './wikiLinkDecoration'; +import { wikiLinkVaultFacet } from './wikiLinkVault'; import { editorModeFacet } from './mode'; +import type { WikiLinkActivation } from '../../types'; /** Open a URL in the system browser, via the Electron bridge when present. */ function openExternal(url: string): void { @@ -36,8 +39,16 @@ function isExternal(target: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(target); } -/** Getter for the current internal-link callback (kept fresh by the editor). */ -type OnOpenLinkGetter = () => ((target: string) => void) | undefined; +/** + * Getters for the current navigation callbacks. Getters rather than values + * because the editor builds its CM state once on mount and reads through refs. + */ +export interface LivePreviewOptions { + getOnOpenLink?: () => ((target: string) => void) | undefined; + getOnOpenWikiLink?: () => + | ((activation: WikiLinkActivation) => void) + | undefined; +} /** * Activate a rendered link: @@ -46,11 +57,16 @@ type OnOpenLinkGetter = () => ((target: string) => void) | undefined; * - Edit mode: cmd/ctrl-click opens it, while a plain click positions the cursor * and reveals the raw markdown for editing. * - * External targets (with a URI scheme) open in the system browser; internal - * (schemeless) targets are handed to `onOpenLink` for the workspace to resolve - * against the current document and open in the active tile. + * Wikilinks are checked first: a `[[…]]` can sit inside a markdown link label + * (`[a [[B]] c](u)` parses as both), and the inner one is what was clicked. + * They are resolved here, against the vault facet, because the editor knows + * which document is open and the workspace does not. + * + * For ordinary links, external targets (with a URI scheme) open in the system + * browser; internal (schemeless) targets are handed to `onOpenLink` for the + * workspace to resolve against the current document. */ -function makeLinkClickHandler(getOnOpenLink: OnOpenLinkGetter): Extension { +function makeLinkClickHandler(options: LivePreviewOptions): Extension { return EditorView.domEventHandlers({ mousedown(event, view) { const viewMode = view.state.facet(editorModeFacet) === 'view'; @@ -60,31 +76,48 @@ function makeLinkClickHandler(getOnOpenLink: OnOpenLinkGetter): Extension { if (!shouldOpen) return false; const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); if (pos == null) return false; + + const wiki = wikiLinkAt(view.state, pos); + if (wiki) { + const vault = view.state.facet(wikiLinkVaultFacet); + // No vault index yet, or a same-document `[[#Heading]]` ref (which + // needs the scroll-to-heading follow-up before it can do anything). + if (!vault || !wiki.target) return false; + const resolution = vault.resolve(wiki.target); + if (!resolution) return false; + event.preventDefault(); + options.getOnOpenWikiLink?.()?.({ + ...resolution, + anchor: wiki.anchor, + rawTarget: wiki.target, + }); + return true; + } + const url = linkUrlAt(view.state, pos); if (!url) return false; event.preventDefault(); if (isExternal(url)) { openExternal(url); } else { - getOnOpenLink()?.(url); + options.getOnOpenLink?.()?.(url); } return true; }, }); } -export function livePreview( - getOnOpenLink: OnOpenLinkGetter = () => undefined, -): Extension { +export function livePreview(options: LivePreviewOptions = {}): Extension { return [ headingDecoration, inlineFormatDecoration, codeDecoration, linkDecoration, + wikiLinkDecoration, blockquoteDecoration, hrDecoration, listDecoration, escapeDecoration, - makeLinkClickHandler(getOnOpenLink), + makeLinkClickHandler(options), ]; } diff --git a/src/editors/automerge/livePreview/livePreview.test.ts b/src/editors/automerge/livePreview/livePreview.test.ts index a3004e9..3473b06 100644 --- a/src/editors/automerge/livePreview/livePreview.test.ts +++ b/src/editors/automerge/livePreview/livePreview.test.ts @@ -1,9 +1,4 @@ import { describe, it, expect } from 'vitest'; -import { EditorState } from '@codemirror/state'; -import { type DecorationSet } from '@codemirror/view'; -import { ensureSyntaxTree } from '@codemirror/language'; -import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; -import { GFM } from '@lezer/markdown'; import { buildHeadingDecorations } from './headingDecoration'; import { buildInlineFormatDecorations } from './inlineFormatDecoration'; @@ -13,73 +8,13 @@ import { buildBlockquoteDecorations } from './blockquoteDecoration'; import { buildHrDecorations } from './hrDecoration'; import { buildListDecorations } from './listDecoration'; import { buildEscapeDecorations } from './escapeDecoration'; -import { type BuildResult } from './context'; -import { editorModeFacet } from './mode'; - -interface FlatDeco { - from: number; - to: number; - cls: string; - text: string; - style: string; - widget: boolean; -} - -function makeState(doc: string, cursor = 0): EditorState { - const state = EditorState.create({ - doc, - selection: { anchor: cursor }, - extensions: [markdown({ base: markdownLanguage, extensions: [GFM] })], - }); - // Force a complete parse so syntaxTree(state) is populated headlessly. - ensureSyntaxTree(state, doc.length, 5000); - return state; -} - -/** Like `makeState`, but in `view` mode — the cursor should be ignored. */ -function makeViewState(doc: string, cursor = 0): EditorState { - const state = EditorState.create({ - doc, - selection: { anchor: cursor }, - extensions: [ - markdown({ base: markdownLanguage, extensions: [GFM] }), - editorModeFacet.of('view'), - ], - }); - ensureSyntaxTree(state, doc.length, 5000); - return state; -} - -function collect(state: EditorState, set: DecorationSet): FlatDeco[] { - const out: FlatDeco[] = []; - const iter = set.iter(); - while (iter.value) { - out.push({ - from: iter.from, - to: iter.to, - // spec is typed `any` upstream. - cls: (iter.value.spec as { class?: string }).class ?? '', - text: state.doc.sliceString(iter.from, iter.to), - style: - (iter.value.spec as { attributes?: { style?: string } }).attributes - ?.style ?? '', - widget: (iter.value.spec as { widget?: unknown }).widget != null, - }); - iter.next(); - } - return out; -} - -const decos = (state: EditorState, r: BuildResult) => - collect(state, r.decorations); -const atomic = (state: EditorState, r: BuildResult) => - collect( - state, - r.atomic ?? - (r.decorations.update({ filter: () => false }) as DecorationSet), - ); -const withClass = (list: FlatDeco[], cls: string) => - list.filter((d) => d.cls.split(/\s+/).includes(cls)); +import { + atomic, + decos, + makeState, + makeViewState, + withClass, +} from './testSupport'; describe('headingDecoration', () => { it('styles the line and hides the prefix when cursor is elsewhere', () => { diff --git a/src/editors/automerge/livePreview/testSupport.ts b/src/editors/automerge/livePreview/testSupport.ts new file mode 100644 index 0000000..65f68ca --- /dev/null +++ b/src/editors/automerge/livePreview/testSupport.ts @@ -0,0 +1,82 @@ +/** + * Shared headless helpers for the live-preview builder tests. + * + * Not a test file itself (vitest collects `*.test.ts` only). Lives here so + * every builder test parses with the *same* configuration the editor uses — + * `textileMarkdown()` — rather than a hand-rolled `markdown({...})` that can + * silently drift out of sync with the real parser extensions. + */ +import { EditorState, type Extension } from '@codemirror/state'; +import { type DecorationSet } from '@codemirror/view'; +import { ensureSyntaxTree } from '@codemirror/language'; + +import { textileMarkdown } from '../markdownExtensions'; +import { type BuildResult } from './context'; +import { editorModeFacet } from './mode'; + +export interface FlatDeco { + from: number; + to: number; + cls: string; + text: string; + style: string; + widget: boolean; +} + +export function makeState( + doc: string, + cursor = 0, + extra: Extension = [], +): EditorState { + const state = EditorState.create({ + doc, + selection: { anchor: cursor }, + extensions: [textileMarkdown(), extra], + }); + // Force a complete parse so syntaxTree(state) is populated headlessly. + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +/** Like `makeState`, but in `view` mode — the cursor should be ignored. */ +export function makeViewState( + doc: string, + cursor = 0, + extra: Extension = [], +): EditorState { + return makeState(doc, cursor, [editorModeFacet.of('view'), extra]); +} + +export function collect(state: EditorState, set: DecorationSet): FlatDeco[] { + const out: FlatDeco[] = []; + const iter = set.iter(); + while (iter.value) { + out.push({ + from: iter.from, + to: iter.to, + // spec is typed `any` upstream. + cls: (iter.value.spec as { class?: string }).class ?? '', + text: state.doc.sliceString(iter.from, iter.to), + style: + (iter.value.spec as { attributes?: { style?: string } }).attributes + ?.style ?? '', + widget: (iter.value.spec as { widget?: unknown }).widget != null, + }); + iter.next(); + } + return out; +} + +export const decos = (state: EditorState, r: BuildResult): FlatDeco[] => + collect(state, r.decorations); + +export const atomic = (state: EditorState, r: BuildResult): FlatDeco[] => + collect( + state, + r.atomic ?? + (r.decorations.update({ filter: () => false }) as DecorationSet), + ); + +/** Decorations carrying `cls` among their space-separated classes. */ +export const withClass = (list: FlatDeco[], cls: string): FlatDeco[] => + list.filter((d) => d.cls.split(/\s+/).includes(cls)); diff --git a/src/editors/automerge/livePreview/wikiLink.test.ts b/src/editors/automerge/livePreview/wikiLink.test.ts new file mode 100644 index 0000000..1e4ab59 --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLink.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { type EditorState } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; + +import { buildWikiLinkDecorations, wikiLinkAt } from './wikiLinkDecoration'; +import { wikiLinkVaultFacet } from './wikiLinkVault'; +import { + atomic, + decos, + makeState, + makeViewState, + withClass, +} from './testSupport'; +import type { WikiLinkVaultView } from '../../types'; + +/** Flatten the syntax tree to `Name="text"` pairs for shape assertions. */ +function nodes(state: EditorState): string[] { + const out: string[] = []; + syntaxTree(state).iterate({ + enter(n) { + if (n.name === 'Document' || n.name === 'Paragraph') return; + out.push(`${n.name}="${state.doc.sliceString(n.from, n.to)}"`); + }, + }); + return out; +} + +/** + * A vault where every listed name resolves and everything else does not. + * A fake, not a mock: the builder's behaviour is asserted through the classes + * it emits, not through calls made on this object. + */ +function fakeVault(existing: string[]): WikiLinkVaultView { + const set = new Set(existing.map((n) => n.toLowerCase())); + return { + resolve: (target) => ({ + entryId: `/vault/${target}.md`, + title: `${target}.md`, + exists: set.has(target.toLowerCase()), + }), + candidates: () => [], + }; +} + +const vaultOf = (existing: string[]) => + wikiLinkVaultFacet.of(fakeVault(existing)); + +describe('wikilink parser', () => { + it('parses a bare target', () => { + expect(nodes(makeState('a [[Foo Bar]] b'))).toEqual([ + 'WikiLink="[[Foo Bar]]"', + 'WikiLinkMark="[["', + 'WikiLinkTarget="Foo Bar"', + 'WikiLinkMark="]]"', + ]); + }); + + it('splits target, anchor and alias', () => { + expect(nodes(makeState('[[Foo#Head|Bar]]'))).toEqual([ + 'WikiLink="[[Foo#Head|Bar]]"', + 'WikiLinkMark="[["', + 'WikiLinkTarget="Foo"', + 'WikiLinkMark="#"', + 'WikiLinkAnchor="Head"', + 'WikiLinkMark="|"', + 'WikiLinkAlias="Bar"', + 'WikiLinkMark="]]"', + ]); + }); + + it('treats a `#` after the pipe as alias text, not an anchor', () => { + const names = nodes(makeState('[[Foo|Bar#Baz]]')); + expect(names).toContain('WikiLinkAlias="Bar#Baz"'); + expect(names.some((n) => n.startsWith('WikiLinkAnchor'))).toBe(false); + }); + + it('produces NO inner Link node — so linkDecoration needs no guard', () => { + // This is the regression that lets `buildLinkDecorations` stay untouched. + expect(nodes(makeState('[[Foo]]')).some((n) => n.startsWith('Link'))).toBe( + false, + ); + }); + + it('leaves wikilinks inside inline code alone', () => { + expect(nodes(makeState('`[[Foo]]`'))).toEqual([ + 'InlineCode="`[[Foo]]`"', + 'CodeMark="`"', + 'CodeMark="`"', + ]); + }); + + it('leaves wikilinks inside a fenced code block alone', () => { + const names = nodes(makeState('```\n[[Foo]]\n```')); + expect(names.some((n) => n.startsWith('WikiLink'))).toBe(false); + }); + + it('honours a backslash escape', () => { + const names = nodes(makeState('\\[[Foo]]')); + expect(names).toContain('Escape="\\["'); + expect(names.some((n) => n.startsWith('WikiLink'))).toBe(false); + }); + + it('ignores an unclosed `[[`', () => { + expect(nodes(makeState('a [[Foo b'))).toEqual([]); + }); + + it('ignores an empty target', () => { + expect(nodes(makeState('[[]]')).some((n) => n.startsWith('WikiLink'))).toBe( + false, + ); + }); + + it('does not span newlines', () => { + const names = nodes(makeState('[[Foo\nBar]]')); + expect(names.some((n) => n.startsWith('WikiLink'))).toBe(false); + }); + + it('stops at the first `]]`', () => { + expect(nodes(makeState('x [[a]]] y'))[0]).toBe('WikiLink="[[a]]"'); + }); + + it('claims `![[…]]`, keeping the `!` inside the opening mark', () => { + expect(nodes(makeState('![[Foo]]'))).toEqual([ + 'WikiLink="![[Foo]]"', + 'WikiLinkMark="![["', + 'WikiLinkTarget="Foo"', + 'WikiLinkMark="]]"', + ]); + }); + + it('coexists with a normal markdown link on the same line', () => { + const names = nodes(makeState('- [[A]] and [x](y)')); + expect(names).toContain('WikiLink="[[A]]"'); + expect(names).toContain('Link="[x](y)"'); + }); + + it('parses a wikilink nested inside a markdown link label', () => { + // Why the click handler must try wikiLinkAt before linkUrlAt. + const names = nodes(makeState('[a [[B]] c](u)')); + expect(names).toContain('Link="[a [[B]] c](u)"'); + expect(names).toContain('WikiLink="[[B]]"'); + }); +}); + +describe('buildWikiLinkDecorations', () => { + it('styles the target and hides the brackets', () => { + const state = makeState('see [[Foo]] here', 0, vaultOf(['Foo'])); + const r = buildWikiLinkDecorations(state); + expect(withClass(decos(state, r), 'cm-wikilink')).toMatchObject([ + { text: 'Foo' }, + ]); + expect(atomic(state, r).map((d) => d.text)).toEqual(['[[', ']]']); + }); + + it('shows the alias and hides the target it stands in for', () => { + const state = makeState('x [[Foo|Bar]]', 0, vaultOf(['Foo'])); + const r = buildWikiLinkDecorations(state); + expect(withClass(decos(state, r), 'cm-wikilink')).toMatchObject([ + { text: 'Bar' }, + ]); + // `[[`, the target, the `|`, and `]]` all hide; only `Bar` remains visible. + expect( + atomic(state, r) + .map((d) => d.text) + .sort(), + ).toEqual(['Foo', ']]', '[[', '|'].sort()); + }); + + it('renders target#anchor as the display text when there is no alias', () => { + const state = makeState('x [[Foo#Head]]', 0, vaultOf(['Foo'])); + const r = buildWikiLinkDecorations(state); + expect(withClass(decos(state, r), 'cm-wikilink')).toMatchObject([ + { text: 'Foo#Head' }, + ]); + }); + + it('reveals the raw source when the cursor is inside', () => { + const doc = 'see [[Foo]] here'; + const state = makeState(doc, doc.indexOf('Foo'), vaultOf(['Foo'])); + const r = buildWikiLinkDecorations(state); + expect(atomic(state, r)).toEqual([]); + // The link styling stays, so it doesn't flicker colour while editing. + expect(withClass(decos(state, r), 'cm-wikilink')).toHaveLength(1); + }); + + it('keeps markers hidden in view mode regardless of the cursor', () => { + const doc = 'see [[Foo]] here'; + const state = makeViewState(doc, doc.indexOf('Foo'), vaultOf(['Foo'])); + const r = buildWikiLinkDecorations(state); + expect(atomic(state, r).map((d) => d.text)).toEqual(['[[', ']]']); + }); + + describe('resolved vs unresolved styling', () => { + /** Cursor at 0 — outside the link, so markers hide and styling applies. */ + const classesFor = (doc: string, extra?: ReturnType) => { + const state = makeState(`x ${doc}`, 0, extra ?? []); + return withClass( + decos(state, buildWikiLinkDecorations(state)), + 'cm-wikilink', + ); + }; + + it('is neutral while the vault index has not loaded', () => { + const found = classesFor('[[Nope]]'); + expect(found).toHaveLength(1); + expect(found[0].cls).not.toContain('cm-wikilink-unresolved'); + }); + + it('is neutral for a target that exists', () => { + expect(classesFor('[[Foo]]', vaultOf(['Foo']))[0].cls).not.toContain( + 'cm-wikilink-unresolved', + ); + }); + + it('marks a target that does not exist', () => { + expect(classesFor('[[Nope]]', vaultOf(['Foo']))[0].cls).toContain( + 'cm-wikilink-unresolved', + ); + }); + + it('never marks a target-less `[[#Heading]]` as unresolved', () => { + expect(classesFor('[[#Head]]', vaultOf([]))[0].cls).not.toContain( + 'cm-wikilink-unresolved', + ); + }); + }); +}); + +describe('wikiLinkAt', () => { + it('extracts target, anchor and alias from anywhere inside the link', () => { + const doc = 'x [[Foo#Head|Bar]] y'; + const state = makeState(doc); + for (const pos of [4, 8, 14]) { + expect(wikiLinkAt(state, pos)).toMatchObject({ + target: 'Foo', + anchor: 'Head', + alias: 'Bar', + }); + } + }); + + it('returns a null target for a same-document `[[#Heading]]`', () => { + const state = makeState('[[#Head]]'); + expect(wikiLinkAt(state, 4)).toMatchObject({ + target: null, + anchor: 'Head', + }); + }); + + it('returns null outside a wikilink and on a plain markdown link', () => { + const state = makeState('x [[Foo]] y [a](b)'); + expect(wikiLinkAt(state, 0)).toBeNull(); + expect(wikiLinkAt(state, 14)).toBeNull(); + }); +}); diff --git a/src/editors/automerge/livePreview/wikiLinkComplete.test.ts b/src/editors/automerge/livePreview/wikiLinkComplete.test.ts new file mode 100644 index 0000000..3193ae7 --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLinkComplete.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { CompletionContext } from '@codemirror/autocomplete'; + +import { wikiLinkCompletionSource } from './wikiLinkComplete'; +import { wikiLinkVaultFacet } from './wikiLinkVault'; +import { makeState } from './testSupport'; +import type { WikiLinkVaultView } from '../../types'; + +const fakeVault = (labels: string[]): WikiLinkVaultView => ({ + resolve: () => null, + candidates: () => + labels.map((label) => ({ + label, + detail: '/', + entryId: `/vault/${label}.md`, + })), +}); + +/** Complete at the end of `doc`, with an optional vault attached. */ +function completeAt(doc: string, labels?: string[]) { + const extra = labels ? wikiLinkVaultFacet.of(fakeVault(labels)) : []; + const state = makeState(doc, doc.length, extra); + return wikiLinkCompletionSource( + new CompletionContext(state, doc.length, false), + ); +} + +describe('wikiLinkCompletionSource', () => { + it('fires after `[[` and anchors just past the brackets', () => { + const result = completeAt('see [[', ['Daily']); + expect(result?.from).toBe(6); + expect(result?.options.map((o) => o.label)).toEqual(['Daily']); + }); + + it('keeps offering candidates while a partial name is typed', () => { + const result = completeAt('see [[Dai', ['Daily', 'Notes']); + // Filtering is CodeMirror's job; the source returns the full candidate set. + expect(result?.from).toBe(6); + expect(result?.options).toHaveLength(2); + }); + + it('carries the containing folder as detail', () => { + expect(completeAt('[[', ['Daily'])?.options[0].detail).toBe('/'); + }); + + it('stays valid for further plain-target keystrokes', () => { + const validFor = completeAt('[[', ['Daily'])?.validFor as RegExp; + expect(validFor.test('Dai')).toBe(true); + expect(validFor.test('Dai#')).toBe(false); + }); + + it('backs off once the user types `#` or `|`', () => { + expect(completeAt('[[Foo#', ['Daily'])).toBeNull(); + expect(completeAt('[[Foo|', ['Daily'])).toBeNull(); + }); + + it('returns null when the vault index has not loaded', () => { + expect(completeAt('see [[')).toBeNull(); + }); + + it('returns null when the vault is empty', () => { + expect(completeAt('see [[', [])).toBeNull(); + }); + + it('does not fire outside a `[[`', () => { + expect(completeAt('just text', ['Daily'])).toBeNull(); + expect(completeAt('[single', ['Daily'])).toBeNull(); + }); + + it('does not fire once the link is closed', () => { + expect(completeAt('[[Foo]]', ['Daily'])).toBeNull(); + }); +}); diff --git a/src/editors/automerge/livePreview/wikiLinkComplete.ts b/src/editors/automerge/livePreview/wikiLinkComplete.ts new file mode 100644 index 0000000..3bb6245 --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLinkComplete.ts @@ -0,0 +1,87 @@ +/** + * Note-name completion for `[[`. + * + * Candidates come precomputed from the vault index via `wikiLinkVaultFacet`, + * so the popup does no work per keystroke — CodeMirror's own fuzzy scorer does + * the filtering, and `validFor` keeps it client-side while you keep typing. + * + * KEYMAP PRECEDENCE: `listEditingKeymap` is registered at `Prec.high` and binds + * Enter, while `completionKeymap` ships at default precedence. Inside a list + * item (`- [[Not`) Enter would therefore insert a new bullet instead of + * accepting the completion. Installing `completionKeymap` at `Prec.highest` + * fixes that; `acceptCompletion` returns false when no popup is open, so list + * editing is untouched the rest of the time. + */ +import { Prec, type Extension } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; +import { + autocompletion, + completionKeymap, + type Completion, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete'; + +import { wikiLinkVaultFacet } from './wikiLinkVault'; + +/** `[[` followed by anything that is still a plain target. */ +const WIKI_TRIGGER = /\[\[[^[\]\n]*$/; + +/** Everything still inside the target while the popup filters client-side. */ +const STILL_A_TARGET = /^[^[\]\n#|]*$/; + +/** + * Insert the completion plus its closing `]]`, reusing a `]]` the user has + * already typed rather than leaving `[[Note]]]]` behind. + */ +function applyCandidate( + view: EditorView, + completion: Completion, + from: number, + to: number, +): void { + const alreadyClosed = view.state.sliceDoc(to, to + 2) === ']]'; + const insert = `${completion.label}]]`; + view.dispatch({ + changes: { from, to: alreadyClosed ? to + 2 : to, insert }, + selection: { anchor: from + insert.length }, + userEvent: 'input.complete', + }); +} + +export function wikiLinkCompletionSource( + context: CompletionContext, +): CompletionResult | null { + const match = context.matchBefore(WIKI_TRIGGER); + if (!match) return null; + + const from = match.from + 2; + const typed = context.state.sliceDoc(from, context.pos); + // Anchor and alias completion are follow-ups; once the user types `#` or `|` + // they are past the note name, so get out of the way. + if (typed.includes('#') || typed.includes('|')) return null; + + const vault = context.state.facet(wikiLinkVaultFacet); + if (!vault) return null; // index still loading + + const options: Completion[] = vault.candidates().map((candidate) => ({ + label: candidate.label, + detail: candidate.detail, + type: 'text', + apply: applyCandidate, + })); + if (options.length === 0) return null; + + return { from, options, validFor: STILL_A_TARGET }; +} + +export const wikiLinkCompletion: Extension = [ + autocompletion({ + override: [wikiLinkCompletionSource], + // Installed at highest precedence below instead, so Enter beats the list + // keymap. Leaving this true would register the same bindings twice. + defaultKeymap: false, + icons: false, + }), + Prec.highest(keymap.of(completionKeymap)), +]; diff --git a/src/editors/automerge/livePreview/wikiLinkDecoration.ts b/src/editors/automerge/livePreview/wikiLinkDecoration.ts new file mode 100644 index 0000000..36d9b91 --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLinkDecoration.ts @@ -0,0 +1,178 @@ +/** + * `[[Target#Anchor|Alias]]` wikilinks. + * + * Renders the display text — the alias when there is one, otherwise + * `Target#Anchor` — as a link, hiding the brackets and (in the alias case) the + * target it stands in for, unless the cursor is near. Targets that don't match + * anything in the vault get an extra `cm-wikilink-unresolved` class so a typo + * is visible before you click it. + * + * Resolution comes from `wikiLinkVaultFacet`; while that is `null` (index still + * loading) links render neutral rather than unresolved, so opening a document + * doesn't flash every link as broken. + * + * `wikiLinkAt` resolves the wikilink enclosing a document offset — used by the + * click handler in `index.ts`. + * + * Only `Decoration.mark` is used here, no widgets: `DecoCollector` dedupes on + * `from:to:class`, and `spec.class` is undefined for a replace-widget, so two + * widgets at the same offsets would collide. A widget-based render (e.g. + * `Note › Heading`) has to fix that dedupe key first. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import type { SyntaxNode } from '@lezer/common'; + +import { + type BuildResult, + type Range, + DecoCollector, + fullRanges, + isCursorNearSameLine, + decorationExtension, +} from './context'; +import { inlineFormattingHiddenMark } from './hiddenClasses'; +import { WikiLinkNodes } from './wikiLinkParser'; +import { wikiLinkVaultFacet } from './wikiLinkVault'; + +// Shared instances: CM6 reuses decorations across rebuilds, and these never vary. +const wikiLinkMark = Decoration.mark({ class: 'cm-wikilink' }); +const wikiLinkUnresolvedMark = Decoration.mark({ + class: 'cm-wikilink cm-wikilink-unresolved', +}); + +/** The pieces of a wikilink, as offsets into the document. */ +export interface ParsedWikiLink { + from: number; + to: number; + /** Trimmed target text; null for a bare `[[#Heading]]` (same-document ref). */ + target: string | null; + /** Trimmed `#heading` text without the `#`, or null. */ + anchor: string | null; + /** Trimmed alias text, or null. */ + alias: string | null; +} + +/** Children of a WikiLink node, grouped by role (mirrors `linkParts`). */ +function wikiLinkParts(node: SyntaxNode): { + marks: SyntaxNode[]; + target: SyntaxNode | null; + anchor: SyntaxNode | null; + alias: SyntaxNode | null; +} { + const marks: SyntaxNode[] = []; + let target: SyntaxNode | null = null; + let anchor: SyntaxNode | null = null; + let alias: SyntaxNode | null = null; + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.name === WikiLinkNodes.mark) marks.push(c); + else if (c.name === WikiLinkNodes.target) target = c; + else if (c.name === WikiLinkNodes.anchor) anchor = c; + else if (c.name === WikiLinkNodes.alias) alias = c; + } + return { marks, target, anchor, alias }; +} + +function sliceOrNull( + state: EditorState, + node: SyntaxNode | null, +): string | null { + if (!node) return null; + const text = state.doc.sliceString(node.from, node.to).trim(); + return text.length > 0 ? text : null; +} + +function parseNode(state: EditorState, node: SyntaxNode): ParsedWikiLink { + const { target, anchor, alias } = wikiLinkParts(node); + return { + from: node.from, + to: node.to, + target: sliceOrNull(state, target), + anchor: sliceOrNull(state, anchor), + alias: sliceOrNull(state, alias), + }; +} + +export function buildWikiLinkDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const out = new DecoCollector(); + const vault = state.facet(wikiLinkVaultFacet); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name !== WikiLinkNodes.link) return; + const { marks, target, anchor, alias } = wikiLinkParts(node.node); + + // Neutral unless the index has loaded AND the target misses. A + // target-less `[[#Heading]]` points at the current document, so it is + // never unresolved. + const targetText = sliceOrNull(state, target); + const unresolved = + vault != null && + targetText != null && + vault.resolve(targetText)?.exists !== true; + const mark = unresolved ? wikiLinkUnresolvedMark : wikiLinkMark; + + // Display text: the alias when present, else target + `#` + anchor so + // `[[Note#Head]]` reads as `Note#Head`. + if (alias) { + out.push(alias.from, alias.to, mark); + } else { + const start = target?.from ?? anchor?.from ?? null; + const end = anchor?.to ?? target?.to ?? null; + if (start != null && end != null && end > start) { + out.push(start, end, mark); + } + } + + if (isCursorNearSameLine(state, node.from, node.to)) { + return; // reveal full source for editing + } + + for (const m of marks) { + out.push(m.from, m.to, inlineFormattingHiddenMark, true); + } + // With an alias, the target it stands in for is hidden too. (The `#` + // between them is a WikiLinkMark, already hidden by the loop above.) + if (alias) { + if (target) { + out.push(target.from, target.to, inlineFormattingHiddenMark, true); + } + if (anchor) { + out.push(anchor.from, anchor.to, inlineFormattingHiddenMark, true); + } + } + }, + }); + } + + return out.result(); +} + +/** The wikilink enclosing `pos`, or null. */ +export function wikiLinkAt( + state: EditorState, + pos: number, +): ParsedWikiLink | null { + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + while (node && node.name !== WikiLinkNodes.link) node = node.parent; + if (!node) return null; + return parseNode(state, node); +} + +export const wikiLinkDecoration = decorationExtension( + buildWikiLinkDecorations, + { + // A vault-index swap is a compartment reconfigure, which sets none of the + // flags `shouldRebuild` inspects. + rebuildOn: (update) => + update.startState.facet(wikiLinkVaultFacet) !== + update.state.facet(wikiLinkVaultFacet), + }, +); diff --git a/src/editors/automerge/livePreview/wikiLinkParser.ts b/src/editors/automerge/livePreview/wikiLinkParser.ts new file mode 100644 index 0000000..ebd299d --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLinkParser.ts @@ -0,0 +1,100 @@ +/** + * `[[Target#Anchor|Alias]]` inline syntax, as a @lezer/markdown extension. + * + * Registered `before: 'Link'` so it claims `[[` ahead of the built-in link + * parser. That ordering does the heavy lifting: the default inline order is + * `Escape, Entity, InlineCode, HTMLTag, Emphasis, HardBreak, Link, Image, + * LinkEnd`, so `Escape` and `InlineCode` still win (`\[[x]]` and `` `[[x]]` `` + * stay literal) while `Link`/`Image` never see the interior. Fenced and + * indented code are safe for free — inline parsing never runs on `CodeText`. + * + * Because `addElement` advances the inline parser past the whole element, a + * matched wikilink produces NO inner `Link` node, so `linkDecoration` needs no + * guard against them. `wikiLink.test.ts` pins that down. + * + * `![[…]]` is claimed too. Embeds are out of scope, so it renders as an + * ordinary wikilink — but leaving it unclaimed is not neutral: it degrades to + * `Image > LinkMark "!["` plus a stray `Link "[Foo]"`, which paints a lone blue + * word after a literal `!`. The `!` lives inside the opening mark, so a future + * embed change only has to look at the first character of that mark. + */ +import type { InlineContext, MarkdownConfig } from '@lezer/markdown'; + +/** `!?[[ … ]]` with no brackets or newlines inside — wikilinks are single-line. */ +const WIKI_LINK_RE = /^!?\[\[([^[\]\n]*)\]\]/; + +const BANG = 33; /* ! */ +const BRACKET = 91; /* [ */ + +export const WikiLinkNodes = { + link: 'WikiLink', + mark: 'WikiLinkMark', + target: 'WikiLinkTarget', + anchor: 'WikiLinkAnchor', + alias: 'WikiLinkAlias', +} as const; + +function parseWikiLink(cx: InlineContext, next: number, pos: number): number { + // `![[` embeds open one character earlier than `[[`. + const open = next === BANG ? pos + 1 : pos; + if (cx.char(open) !== BRACKET || cx.char(open + 1) !== BRACKET) return -1; + + const match = WIKI_LINK_RE.exec(cx.slice(pos, cx.end)); + if (!match) return -1; + const inner = match[1]; + // `[[]]` / `[[ ]]` name nothing; leave them to the built-in link parser. + if (inner.trim().length === 0) return -1; + + const innerFrom = open + 2; + const end = pos + match[0].length; + const closeFrom = end - 2; + + // `|` splits the link part from the alias; `#` splits target from anchor + // *within* the link part, so a `#` after the pipe is just alias text. + const pipe = inner.indexOf('|'); + const linkPart = pipe < 0 ? inner : inner.slice(0, pipe); + const hash = linkPart.indexOf('#'); + + const children = [cx.elt(WikiLinkNodes.mark, pos, innerFrom)]; + + const targetEnd = innerFrom + (hash < 0 ? linkPart.length : hash); + if (targetEnd > innerFrom) { + children.push(cx.elt(WikiLinkNodes.target, innerFrom, targetEnd)); + } + + if (hash >= 0) { + children.push(cx.elt(WikiLinkNodes.mark, targetEnd, targetEnd + 1)); + const anchorEnd = innerFrom + linkPart.length; + if (anchorEnd > targetEnd + 1) { + children.push(cx.elt(WikiLinkNodes.anchor, targetEnd + 1, anchorEnd)); + } + } + + if (pipe >= 0) { + const pipePos = innerFrom + pipe; + children.push(cx.elt(WikiLinkNodes.mark, pipePos, pipePos + 1)); + if (closeFrom > pipePos + 1) { + children.push(cx.elt(WikiLinkNodes.alias, pipePos + 1, closeFrom)); + } + } + + children.push(cx.elt(WikiLinkNodes.mark, closeFrom, end)); + + return cx.addElement(cx.elt(WikiLinkNodes.link, pos, end, children)); +} + +/** + * No `style:` on the node specs on purpose: every live-preview concern paints + * with decorations, and `syntaxHighlighting(classHighlighter)` would otherwise + * add competing classes to the same spans. + */ +export const wikiLinkMarkdown: MarkdownConfig = { + defineNodes: [ + WikiLinkNodes.link, + WikiLinkNodes.mark, + WikiLinkNodes.target, + WikiLinkNodes.anchor, + WikiLinkNodes.alias, + ], + parseInline: [{ name: 'WikiLink', before: 'Link', parse: parseWikiLink }], +}; diff --git a/src/editors/automerge/livePreview/wikiLinkVault.ts b/src/editors/automerge/livePreview/wikiLinkVault.ts new file mode 100644 index 0000000..943bc9b --- /dev/null +++ b/src/editors/automerge/livePreview/wikiLinkVault.ts @@ -0,0 +1,20 @@ +/** + * Vault knowledge for the wikilink builder, click handler and completion source. + * + * A Facet for the same reason as `editorModeFacet`: the decoration builders are + * pure functions of `EditorState`, so anything they need must be readable from + * the state — and a `Compartment` reconfigure can swap the value at runtime + * without recreating the editor, so cursor, scroll and history survive. + * + * The default is `null`, meaning "the index has not loaded yet". Consumers MUST + * treat that as unknown rather than unresolved, so opening a document does not + * flash every link as broken before the vault tree arrives. + */ +import { Facet } from '@codemirror/state'; + +import type { WikiLinkVaultView } from '../../types'; + +export const wikiLinkVaultFacet = Facet.define< + WikiLinkVaultView | null, + WikiLinkVaultView | null +>({ combine: (values) => values[values.length - 1] ?? null }); diff --git a/src/editors/automerge/markdownExtensions.ts b/src/editors/automerge/markdownExtensions.ts new file mode 100644 index 0000000..3cbc9be --- /dev/null +++ b/src/editors/automerge/markdownExtensions.ts @@ -0,0 +1,20 @@ +/** + * The single markdown language configuration used by the editor and its tests. + * + * Kept in one place because a parser extension that reaches the editor but not + * the test helpers (or vice versa) fails silently — the suite would keep + * asserting the pre-extension tree shape while the app renders something else. + */ +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { GFM } from '@lezer/markdown'; + +import { wikiLinkMarkdown } from './livePreview/wikiLinkParser'; + +export function textileMarkdown() { + return markdown({ + base: markdownLanguage, + extensions: [GFM, wikiLinkMarkdown], + // `listEditingKeymap` supplies its own list handling at Prec.high. + addKeymap: false, + }); +} diff --git a/src/editors/types.ts b/src/editors/types.ts index 97ada2b..65d01b0 100644 --- a/src/editors/types.ts +++ b/src/editors/types.ts @@ -19,6 +19,52 @@ export type EditorBinding = { payload: unknown; }; +/** Where a `[[wikilink]]` target points, and whether that document exists yet. */ +export type WikiLinkResolution = { + /** + * Provider-scoped document id (an absolute path today). For a resolved link + * this is copied verbatim from the provider's listing, so it is byte-identical + * to the ids the sidebar emits — which is what makes tab dedup (an exact + * `providerId + entryId` string match) hit instead of opening a duplicate tab. + * When `exists` is false it is the path we *would* create. + */ + entryId: string; + /** Tab/title label — the filename, matching what the sidebar passes. */ + title: string; + exists: boolean; +}; + +/** One candidate for the `[[` completion popup. */ +export type WikiLinkCandidate = { + /** Text inserted between the brackets: the bare name, or a path if ambiguous. */ + label: string; + /** Secondary text — the containing folder, relative to the vault root. */ + detail: string; + entryId: string; +}; + +/** + * A read-only view of the vault, supplied by the app so that pure + * `EditorState` decoration builders and completion sources can resolve + * wikilinks *synchronously*. + * + * A `null` view (rather than an empty one) means "the index has not loaded + * yet" — links must then render neutral rather than unresolved, so a cold open + * does not flash every link as broken. + */ +export type WikiLinkVaultView = { + resolve(target: string): WikiLinkResolution | null; + candidates(): readonly WikiLinkCandidate[]; +}; + +/** A wikilink the user activated, already resolved against the vault. */ +export type WikiLinkActivation = WikiLinkResolution & { + /** `#heading` text without the `#`, or null. Carried but not yet acted on. */ + anchor: string | null; + /** The target text as typed, for diagnostics. */ + rawTarget: string; +}; + /** Props passed from workspaces into every registered editor component. */ export type EditorProps = { binding: EditorBinding; @@ -32,6 +78,18 @@ export type EditorProps = { * open. External links (with a URI scheme) are opened by the editor itself. */ onOpenLink?: (target: string) => void; + /** + * Vault knowledge for `[[wikilinks]]`: resolution (for rendering resolved vs + * unresolved) and the candidate list (for completion). `null` while the + * vault index is still loading. + */ + wikiLinkVault?: WikiLinkVaultView | null; + /** + * Called when a wikilink is activated, already resolved. Unlike `onOpenLink` + * the editor cannot resolve this itself, so the app both resolves it (via + * `wikiLinkVault`) and handles creating the note when it does not exist. + */ + onOpenWikiLink?: (activation: WikiLinkActivation) => void; }; /** diff --git a/src/wikilinks/WikiLinkVaultContext.tsx b/src/wikilinks/WikiLinkVaultContext.tsx new file mode 100644 index 0000000..fa29d8a --- /dev/null +++ b/src/wikilinks/WikiLinkVaultContext.tsx @@ -0,0 +1,220 @@ +/** + * The React seam that feeds vault knowledge to the editor's wikilink support. + * + * Holds one `WikiLinkIndex` per filesystem provider, derived from the *same* + * React Query cache entry the sidebar uses (so the recursive `getTree()` walk + * is fetched once, not once per observer), and hands each open document a + * `WikiLinkVaultView` bound to that document plus a handler that opens — or + * creates — whatever a wikilink points at. + * + * Deliberately attached at `DocumentSlotView` rather than drilled through the + * tiling tree: link activation ends in the global `onOpenEntry` channel, and + * the slot already knows its own `providerId`/`entryId`. That also means Zen + * gets wikilinks without any workspace-specific wiring. + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { FileSystemProvider } from '../filesystem/types'; +import { treeQueryKey } from '../filesystem/treeQuery'; +import type { OpenDocRequest } from '../workspaces/workspace'; +import type { WikiLinkActivation, WikiLinkVaultView } from '../editors/types'; +import { + buildWikiLinkIndex, + makeWikiLinkVaultView, + type WikiLinkIndex, +} from './wikiLinkIndex'; +import { baseName, dirName, relPathFrom } from './paths'; + +type IndexMap = ReadonlyMap; + +type ContextValue = { + indexes: IndexMap; + providers: readonly FileSystemProvider[]; + onOpenEntry: (request: OpenDocRequest) => void; +}; + +const WikiLinkVaultCtx = createContext(null); + +/** + * Keeps one provider's index current. A child component per provider because + * hooks cannot be called in a loop — the same shape `AppSidebar` uses for its + * per-provider sections. + */ +function ProviderIndexLoader({ + provider, + onIndex, +}: { + provider: FileSystemProvider; + onIndex: (providerId: string, index: WikiLinkIndex | null) => void; +}) { + const treeQuery = useQuery({ + queryKey: treeQueryKey(provider.id), + queryFn: () => provider.getTree(), + enabled: provider.isReady(), + // Matches the sidebar. Observers of one key share a single fetch at the + // shortest interval, so this costs nothing when the sidebar is mounted and + // keeps non-selected providers fresh when it is not. + refetchInterval: 3_000, + }); + + const tree = treeQuery.data; + // React Query's structural sharing keeps `data`'s identity stable across a + // deep-equal refetch, so this only recomputes on a real file change — which + // is what stops the 3s poll from churning decorations. + const index = useMemo( + () => (tree ? buildWikiLinkIndex(provider.rootId, tree) : null), + [tree, provider.rootId], + ); + + useEffect(() => { + onIndex(provider.id, index); + }, [onIndex, provider.id, index]); + + useEffect(() => { + return () => onIndex(provider.id, null); + }, [onIndex, provider.id]); + + return null; +} + +export function WikiLinkVaultProvider({ + providers, + onOpenEntry, + children, +}: { + providers: readonly FileSystemProvider[]; + onOpenEntry: (request: OpenDocRequest) => void; + children: ReactNode; +}) { + const [indexes, setIndexes] = useState(() => new Map()); + + const handleIndex = useCallback( + (providerId: string, index: WikiLinkIndex | null) => { + setIndexes((prev) => { + if ((prev.get(providerId) ?? null) === index) return prev; + const next = new Map(prev); + if (index) next.set(providerId, index); + else next.delete(providerId); + return next; + }); + }, + [], + ); + + const value = useMemo( + () => ({ indexes, providers, onOpenEntry }), + [indexes, providers, onOpenEntry], + ); + + return ( + + {providers.map((provider) => ( + + ))} + {children} + + ); +} + +/** + * Vault view + activation handler for one open document. + * + * Returns a `null` view until that provider's index has loaded, which the + * editor renders as "unknown" rather than "unresolved". + */ +export function useWikiLinkDocument( + providerId: string | null, + entryId: string | null, +): { + vault: WikiLinkVaultView | null; + openWikiLink: (activation: WikiLinkActivation) => void; +} { + const ctx = useContext(WikiLinkVaultCtx); + const queryClient = useQueryClient(); + + const index = providerId ? (ctx?.indexes.get(providerId) ?? null) : null; + + const vault = useMemo( + () => (index && entryId ? makeWikiLinkVaultView(index, entryId) : null), + [index, entryId], + ); + + const providers = ctx?.providers; + const onOpenEntry = ctx?.onOpenEntry; + + const openWikiLink = useCallback( + (activation: WikiLinkActivation) => { + if (!providerId || !onOpenEntry) return; + const provider = providers?.find((p) => p.id === providerId); + if (!provider) return; + + if (activation.exists) { + onOpenEntry({ + providerId, + entryId: activation.entryId, + title: activation.title, + }); + return; + } + + // Obsidian-style create-on-click. Going through `createFile` explicitly + // rather than letting the document provider create on open: a missing + // file's ENOENT loses its `code` across the Electron IPC boundary, so a + // local vault would silently open a buffer backed by nothing on disk and + // a synced vault would hard-fail. + void (async () => { + const dir = dirName(activation.entryId); + try { + const relDir = relPathFrom(provider.rootId, dir); + if (relDir && !index?.dirs.has(relDir)) { + await provider.createDirectory(relDir, provider.rootId); + } + const entry = await provider.createFile( + dir, + baseName(activation.entryId), + ); + // Open the id the provider returned, not the one we predicted. + onOpenEntry({ + providerId, + entryId: entry.id, + title: entry.name, + }); + } catch (error) { + // Most likely a lost race: someone created exactly this file first. + console.error( + '[wikilink] could not create', + activation.entryId, + error, + ); + onOpenEntry({ + providerId, + entryId: activation.entryId, + title: activation.title, + }); + } finally { + // Refresh now so the link stops rendering unresolved immediately + // rather than after the next poll. + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(providerId), + }); + } + })(); + }, + [providerId, providers, onOpenEntry, index, queryClient], + ); + + return { vault, openWikiLink }; +} diff --git a/src/wikilinks/paths.test.ts b/src/wikilinks/paths.test.ts new file mode 100644 index 0000000..ef64f47 --- /dev/null +++ b/src/wikilinks/paths.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; + +import { + baseName, + dirName, + ensureMdExtension, + normalizeRelPath, + relPathFrom, + stripMdExtension, +} from './paths'; + +describe('normalizeRelPath', () => { + it('collapses repeated slashes and drops `.` segments', () => { + expect(normalizeRelPath('a//b')).toBe('a/b'); + expect(normalizeRelPath('./a/./b')).toBe('a/b'); + }); + + it('resolves `..`', () => { + expect(normalizeRelPath('a/../b')).toBe('b'); + expect(normalizeRelPath('a/b/../../c')).toBe('c'); + }); + + it('clamps `..` at the root rather than escaping the vault', () => { + expect(normalizeRelPath('../../etc/passwd')).toBe('etc/passwd'); + }); + + it('strips a leading slash and converts backslashes', () => { + expect(normalizeRelPath('/a/b')).toBe('a/b'); + expect(normalizeRelPath('a\\b')).toBe('a/b'); + }); +}); + +describe('md extension helpers', () => { + it('strips `.md` case-insensitively', () => { + expect(stripMdExtension('Note.md')).toBe('Note'); + expect(stripMdExtension('Note.MD')).toBe('Note'); + expect(stripMdExtension('Note')).toBe('Note'); + }); + + it('appends `.md` to a name containing a dot', () => { + // createFile's own auto-append would skip this one and create an + // extensionless file, which is why callers go through here. + expect(ensureMdExtension('Release v1.2')).toBe('Release v1.2.md'); + }); + + it('does not double up an existing extension', () => { + expect(ensureMdExtension('Note.md')).toBe('Note.md'); + }); +}); + +describe('relPathFrom', () => { + it('strips the root prefix', () => { + expect(relPathFrom('/vault', '/vault/a/b.md')).toBe('a/b.md'); + }); + + it('tolerates a trailing slash on the root', () => { + expect(relPathFrom('/vault/', '/vault/a.md')).toBe('a.md'); + }); + + it('normalizes a path outside the root instead of producing nonsense', () => { + expect(relPathFrom('/vault', '/other/a.md')).toBe('other/a.md'); + }); +}); + +describe('baseName / dirName', () => { + it('splits a nested path', () => { + expect(baseName('a/b/c.md')).toBe('c.md'); + expect(dirName('a/b/c.md')).toBe('a/b'); + }); + + it('returns an empty dir at the top level', () => { + expect(dirName('c.md')).toBe(''); + }); +}); diff --git a/src/wikilinks/paths.ts b/src/wikilinks/paths.ts new file mode 100644 index 0000000..534b4fe --- /dev/null +++ b/src/wikilinks/paths.ts @@ -0,0 +1,75 @@ +/** + * Path helpers for wikilink resolution. + * + * Deliberately separate from `filesystem/vaultFs.ts`: those helpers are used by + * the providers and by move-detection, and their lack of `.`/`..` handling is + * load-bearing there. Wikilink targets are user-typed, so they need real + * normalization — but fixing it in place would change behaviour well outside + * this feature. + */ + +/** Vault files are markdown; a bare `[[Note]]` means `Note.md`. */ +const MD_EXTENSION = '.md'; + +/** + * Normalize a user-typed relative path: `\` → `/`, collapse repeated slashes, + * drop `.` segments, resolve `..`, and strip any leading `/` or `./`. + * + * `..` that would escape the vault root is dropped rather than propagated — + * a wikilink cannot address anything outside its own vault. + */ +export function normalizeRelPath(input: string): string { + const segments = input.replace(/\\/g, '/').split('/'); + const out: string[] = []; + for (const segment of segments) { + if (segment === '' || segment === '.') continue; + if (segment === '..') { + out.pop(); // at the root this is a no-op: clamp instead of escaping. + continue; + } + out.push(segment); + } + return out.join('/'); +} + +/** Strip a trailing `.md` (case-insensitively); `[[Note.md]]` means `[[Note]]`. */ +export function stripMdExtension(name: string): string { + return name.toLowerCase().endsWith(MD_EXTENSION) + ? name.slice(0, -MD_EXTENSION.length) + : name; +} + +/** + * Append `.md` unless it is already there. + * + * Note this is NOT what `FileSystemProvider.createFile` does on its own: it + * only appends when the name contains no dot at all, so it would create + * `[[Release v1.2]]` as an extensionless file. Callers pass an explicit name + * through this instead. + */ +export function ensureMdExtension(name: string): string { + return name.toLowerCase().endsWith(MD_EXTENSION) ? name : name + MD_EXTENSION; +} + +/** `entryId` expressed relative to `rootId`; `entryId` unchanged if outside. */ +export function relPathFrom(rootId: string, entryId: string): string { + const root = rootId.replace(/\\/g, '/').replace(/\/+$/, ''); + const entry = entryId.replace(/\\/g, '/'); + if (!root) return normalizeRelPath(entry); + return entry.startsWith(root + '/') + ? entry.slice(root.length + 1) + : normalizeRelPath(entry); +} + +/** Last path segment. */ +export function baseName(path: string): string { + const parts = path.replace(/\\/g, '/').split('/'); + return parts[parts.length - 1] ?? ''; +} + +/** Everything before the last path segment (no trailing slash). */ +export function dirName(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const cut = normalized.lastIndexOf('/'); + return cut <= 0 ? '' : normalized.slice(0, cut); +} diff --git a/src/wikilinks/wikiLinkIndex.test.ts b/src/wikilinks/wikiLinkIndex.test.ts new file mode 100644 index 0000000..8ce96e8 --- /dev/null +++ b/src/wikilinks/wikiLinkIndex.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; + +import type { FileSystemTreeNode } from '../filesystem/types'; +import { buildWikiLinkIndex, resolveWikiLink } from './wikiLinkIndex'; + +const ROOT = '/vault'; + +const file = (path: string): FileSystemTreeNode => ({ + id: `${ROOT}/${path}`, + name: path.split('/').pop() ?? path, + type: 'file', +}); + +const folder = ( + path: string, + children: FileSystemTreeNode[], +): FileSystemTreeNode => ({ + id: `${ROOT}/${path}`, + name: path.split('/').pop() ?? path, + type: 'folder', + children, +}); + +/** + * /vault + * Daily.md + * logo.png + * projects/ Notes.md, Deep/Notes.md + * archive/ Daily.md + */ +const TREE: FileSystemTreeNode[] = [ + file('Daily.md'), + file('logo.png'), + folder('projects', [ + file('projects/Notes.md'), + folder('projects/Deep', [file('projects/Deep/Notes.md')]), + ]), + folder('archive', [file('archive/Daily.md')]), +]; + +const index = buildWikiLinkIndex(ROOT, TREE); +const resolveFrom = (target: string, from: string) => + resolveWikiLink(index, target, `${ROOT}/${from}`); + +describe('buildWikiLinkIndex', () => { + it('excludes non-text files', () => { + expect(resolveFrom('logo', 'Daily.md')?.exists).toBe(false); + }); + + it('records folders for create-on-click', () => { + expect(index.dirs).toContain('projects/Deep'); + }); + + it('completes ambiguous stems as paths and unique ones as bare names', () => { + const labels = index.candidates.map((c) => c.label); + expect(labels).toContain('projects/Notes'); + expect(labels).toContain('projects/Deep/Notes'); + expect(labels).toContain('Daily'); // ambiguous too — see below + }); +}); + +describe('resolveWikiLink — bare names', () => { + it('matches a note anywhere in the vault', () => { + const r = resolveFrom('Notes', 'Daily.md'); + expect(r).toMatchObject({ exists: true, title: 'Notes.md' }); + }); + + it('matches case-insensitively', () => { + expect(resolveFrom('notes', 'Daily.md')?.exists).toBe(true); + }); + + it('accepts an explicit `.md`', () => { + expect(resolveFrom('Notes.md', 'Daily.md')?.exists).toBe(true); + }); + + it('returns the tree node id verbatim, so tab dedup matches exactly', () => { + expect(resolveFrom('Notes', 'Daily.md')?.entryId).toBe( + '/vault/projects/Notes.md', + ); + }); +}); + +describe('resolveWikiLink — ambiguity tie-breaks', () => { + it('prefers a sibling of the linking note', () => { + // Two `Notes.md` exist; link from within projects/Deep. + expect(resolveFrom('Notes', 'projects/Deep/Other.md')?.entryId).toBe( + '/vault/projects/Deep/Notes.md', + ); + }); + + it('prefers the shallowest path when there is no sibling', () => { + // Two `Daily.md` exist (root and archive/); link from projects/. + expect(resolveFrom('Daily', 'projects/Notes.md')?.entryId).toBe( + '/vault/Daily.md', + ); + }); + + it('prefers an exact-case match over a case-folded one', () => { + const mixed = buildWikiLinkIndex(ROOT, [ + folder('deep', [file('deep/note.md')]), + file('Note.md'), + ]); + expect(resolveWikiLink(mixed, 'Note', `${ROOT}/x.md`)?.entryId).toBe( + '/vault/Note.md', + ); + }); + + it('falls back to lexicographic order, not readdir order', () => { + const forward = buildWikiLinkIndex(ROOT, [ + folder('b', [file('b/N.md')]), + folder('a', [file('a/N.md')]), + ]); + const reversed = buildWikiLinkIndex(ROOT, [ + folder('a', [file('a/N.md')]), + folder('b', [file('b/N.md')]), + ]); + const pick = (i: typeof forward) => + resolveWikiLink(i, 'N', `${ROOT}/x.md`)?.entryId; + expect(pick(forward)).toBe('/vault/a/N.md'); + expect(pick(forward)).toBe(pick(reversed)); + }); +}); + +describe('resolveWikiLink — path-ish targets', () => { + it('resolves relative to the linking note first', () => { + expect(resolveFrom('Deep/Notes', 'projects/Notes.md')?.entryId).toBe( + '/vault/projects/Deep/Notes.md', + ); + }); + + it('falls back to the vault root', () => { + expect(resolveFrom('projects/Notes', 'Daily.md')?.entryId).toBe( + '/vault/projects/Notes.md', + ); + }); + + it('normalizes `..`', () => { + expect(resolveFrom('../Daily', 'projects/Notes.md')?.entryId).toBe( + '/vault/Daily.md', + ); + }); +}); + +describe('resolveWikiLink — misses', () => { + it('would create a bare name beside the linking note', () => { + expect(resolveFrom('Brand New', 'projects/Notes.md')).toEqual({ + entryId: '/vault/projects/Brand New.md', + title: 'Brand New.md', + exists: false, + }); + }); + + it('would create a path-ish target under the vault root', () => { + expect(resolveFrom('inbox/Todo', 'projects/Notes.md')).toEqual({ + entryId: '/vault/inbox/Todo.md', + title: 'Todo.md', + exists: false, + }); + }); + + it('keeps a dotted name intact and still adds `.md`', () => { + expect(resolveFrom('Release v1.2', 'Daily.md')?.entryId).toBe( + '/vault/Release v1.2.md', + ); + }); + + it('returns null for an empty target', () => { + expect(resolveFrom(' ', 'Daily.md')).toBeNull(); + }); +}); diff --git a/src/wikilinks/wikiLinkIndex.ts b/src/wikilinks/wikiLinkIndex.ts new file mode 100644 index 0000000..3817c71 --- /dev/null +++ b/src/wikilinks/wikiLinkIndex.ts @@ -0,0 +1,214 @@ +/** + * Name → document resolution for `[[wikilinks]]`, over a provider's file tree. + * + * Pure and framework-free: `buildWikiLinkIndex` takes the tree the sidebar + * already fetches and produces lookup tables; `resolveWikiLink` answers a + * single target. The React seam that keeps this fed lives in + * `WikiLinkVaultContext.tsx`. + * + * THE ENTRY-ID RULE: a resolved link's `entryId` is copied verbatim from the + * tree node, never rebuilt by string concatenation. Tab dedup compares + * `providerId + entryId` as exact strings (`tabsModel.slotShowsRequest`), so a + * path that is merely equivalent — rather than identical — would silently open + * a second tab for a document that is already showing. + */ +import type { FileSystemTreeNode } from '../filesystem/types'; +import type { + WikiLinkCandidate, + WikiLinkResolution, + WikiLinkVaultView, +} from '../editors/types'; +import { isTextFile } from '../vault/utils/fileType'; +import { + baseName, + dirName, + ensureMdExtension, + normalizeRelPath, + relPathFrom, + stripMdExtension, +} from './paths'; + +export interface WikiLinkIndexEntry { + /** Absolute path, verbatim from `getTree()`. See THE ENTRY-ID RULE above. */ + entryId: string; + /** Basename including extension, e.g. `Meeting notes.md`. */ + name: string; + /** Path relative to the vault root, extension stripped. */ + relPath: string; + /** Basename, extension stripped — what a bare `[[Note]]` matches. */ + stem: string; + /** Containing folder relative to the root; `''` at the root. */ + dirRel: string; + /** Segment count of `relPath`; smaller is "shallower" for tie-breaking. */ + depth: number; +} + +export interface WikiLinkIndex { + rootId: string; + byStemLower: Map; + byRelPathLower: Map; + /** Folders present in the vault, relative to the root — for create-on-click. */ + dirs: Set; + /** Precomputed so the completion popup never rebuilds this per keystroke. */ + candidates: readonly WikiLinkCandidate[]; +} + +function pushInto( + map: Map, + key: string, + entry: WikiLinkIndexEntry, +): void { + const existing = map.get(key); + if (existing) existing.push(entry); + else map.set(key, [entry]); +} + +export function buildWikiLinkIndex( + rootId: string, + tree: readonly FileSystemTreeNode[], +): WikiLinkIndex { + const entries: WikiLinkIndexEntry[] = []; + const dirs = new Set(['']); + + const walk = (nodes: readonly FileSystemTreeNode[]): void => { + for (const node of nodes) { + if (node.type === 'folder') { + dirs.add(relPathFrom(rootId, node.id)); + if (node.children) walk(node.children); + continue; + } + // Binary files (images and friends) are listed in the sidebar but are not + // editable, so they are not wikilink targets. + if (!isTextFile(node.id)) continue; + const relFull = relPathFrom(rootId, node.id); + const relPath = stripMdExtension(relFull); + entries.push({ + entryId: node.id, + name: node.name, + relPath, + stem: stripMdExtension(node.name), + dirRel: dirName(relFull), + depth: relPath.split('/').length, + }); + } + }; + walk(tree); + + const byStemLower = new Map(); + const byRelPathLower = new Map(); + for (const entry of entries) { + pushInto(byStemLower, entry.stem.toLowerCase(), entry); + pushInto(byRelPathLower, entry.relPath.toLowerCase(), entry); + } + + // Ambiguous stems complete as full paths so the two are distinguishable. + const candidates: WikiLinkCandidate[] = entries + .map((entry) => ({ + label: + (byStemLower.get(entry.stem.toLowerCase())?.length ?? 0) > 1 + ? entry.relPath + : entry.stem, + detail: entry.dirRel || '/', + entryId: entry.entryId, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + return { rootId, byStemLower, byRelPathLower, dirs, candidates }; +} + +/** + * Pick one entry from an ambiguous bucket. + * + * Order: exact-case match, then a sibling of the linking note, then the + * shallowest path, then lexicographic. The last tiebreak is not cosmetic — + * without it the winner would depend on `readdir` order, which varies by + * platform and filesystem. + */ +function disambiguate( + matches: readonly WikiLinkIndexEntry[], + exact: (entry: WikiLinkIndexEntry) => boolean, + fromDir: string, +): WikiLinkIndexEntry { + const exactCase = matches.filter(exact); + const pool = exactCase.length > 0 ? exactCase : matches; + const siblings = pool.filter((entry) => entry.dirRel === fromDir); + const finalPool = siblings.length > 0 ? siblings : pool; + return [...finalPool].sort( + (a, b) => a.depth - b.depth || a.entryId.localeCompare(b.entryId), + )[0]; +} + +function resolutionOf(entry: WikiLinkIndexEntry): WikiLinkResolution { + return { entryId: entry.entryId, title: entry.name, exists: true }; +} + +/** + * Resolve a bare target (no `#anchor`, no `|alias` — the parser already split + * those off) against the vault. + * + * A target containing `/` is path-ish: tried relative to the linking note + * first, then relative to the vault root. A bare name matches any note's + * basename, vault-wide and case-insensitively. + * + * Returns `exists: false` with the path that *would* be created when nothing + * matches: alongside the linking note for a bare name, under the vault root for + * a path-ish target. + */ +export function resolveWikiLink( + index: WikiLinkIndex, + target: string, + fromEntryId: string, +): WikiLinkResolution | null { + const normalized = stripMdExtension(normalizeRelPath(target.trim())); + if (normalized.length === 0) return null; + + const fromDir = dirName(relPathFrom(index.rootId, fromEntryId)); + const isPathish = normalized.includes('/'); + + if (isPathish) { + const relativeToNote = normalizeRelPath( + fromDir ? `${fromDir}/${normalized}` : normalized, + ); + for (const key of [relativeToNote, normalized]) { + const matches = index.byRelPathLower.get(key.toLowerCase()); + if (matches?.length) { + return resolutionOf( + disambiguate(matches, (e) => e.relPath === key, fromDir), + ); + } + } + } else { + const matches = index.byStemLower.get(normalized.toLowerCase()); + if (matches?.length) { + return resolutionOf( + disambiguate(matches, (e) => e.stem === normalized, fromDir), + ); + } + } + + // Nothing matched — describe the note we would create. + const relTarget = isPathish + ? normalized + : fromDir + ? `${fromDir}/${normalized}` + : normalized; + const fileName = ensureMdExtension(baseName(relTarget)); + const dir = dirName(relTarget); + const relFull = dir ? `${dir}/${fileName}` : fileName; + return { + entryId: `${index.rootId}/${relFull}`, + title: fileName, + exists: false, + }; +} + +/** Bind an index to the document doing the linking. */ +export function makeWikiLinkVaultView( + index: WikiLinkIndex, + fromEntryId: string, +): WikiLinkVaultView { + return { + resolve: (target) => resolveWikiLink(index, target, fromEntryId), + candidates: () => index.candidates, + }; +}