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,
+ };
+}