A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Move internal-link resolution into DocumentSlotView; wire Zen

Link-target resolution (relative-path join + building an OpenDocRequest) lived
in the tiling workspace, so each workspace would have to reimplement it. Move it
into a shared, pure resolveDocumentLink() and have DocumentSlotView — which
already holds the open doc's path and provider — translate a workspace's
onOpenEntry into the editor's onOpenLink handler.

Now any workspace gets file-to-file link navigation by forwarding the
onOpenEntry it already receives: Zen becomes a two-line change, and tiling drops
its bespoke handleOpenLink plus the onOpenLink prop threading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Ethan Graf (Jul 26, 2026, 10:54 AM EDT) 7bc9fedc 561eaa9b

+92 -28
+33
src/documents/resolveDocumentLink.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { resolveDocumentLink } from './resolveDocumentLink'; 4 + 5 + describe('resolveDocumentLink', () => { 6 + const source = { providerId: 'vault-1', path: '/vault/notes/a.md' }; 7 + 8 + it('resolves a sibling file relative to the source document folder', () => { 9 + expect(resolveDocumentLink(source, 'b.md')).toEqual({ 10 + providerId: 'vault-1', 11 + entryId: '/vault/notes/b.md', 12 + title: 'b.md', 13 + }); 14 + }); 15 + 16 + it('resolves a nested subfolder target', () => { 17 + expect(resolveDocumentLink(source, 'sub/c.md')).toEqual({ 18 + providerId: 'vault-1', 19 + entryId: '/vault/notes/sub/c.md', 20 + title: 'c.md', 21 + }); 22 + }); 23 + 24 + it('carries the source provider onto the request', () => { 25 + expect( 26 + resolveDocumentLink({ providerId: 'other', path: '/x/y.md' }, 'z.md'), 27 + ).toEqual({ 28 + providerId: 'other', 29 + entryId: '/x/z.md', 30 + title: 'z.md', 31 + }); 32 + }); 33 + });
+24
src/documents/resolveDocumentLink.ts
··· 1 + import { basename, dirname, join } from '../filesystem/vaultFs'; 2 + import type { OpenDocRequest } from '../workspaces/workspace'; 3 + 4 + /** The open document a link is being followed *from*. */ 5 + export type LinkSource = { 6 + /** Provider the current document belongs to. */ 7 + providerId: string; 8 + /** The current document's id/path (links resolve relative to its folder). */ 9 + path: string; 10 + }; 11 + 12 + /** 13 + * Resolve a relative markdown link target (e.g. `Note.md`, `sub/Note.md`) 14 + * against the document it was clicked in, producing an `OpenDocRequest` the 15 + * workspace can open. Resolution is a plain path join relative to the source 16 + * document's folder — callers pass external (scheme-bearing) targets elsewhere. 17 + */ 18 + export function resolveDocumentLink( 19 + source: LinkSource, 20 + target: string, 21 + ): OpenDocRequest { 22 + const entryId = join(dirname(source.path), target); 23 + return { providerId: source.providerId, entryId, title: basename(entryId) }; 24 + }
+24 -5
src/editors/DocumentSlotView.tsx
··· 1 1 import type { DocumentSlotState } from '../documents/useDocumentSlot'; 2 + import { resolveDocumentLink } from '../documents/resolveDocumentLink'; 3 + import type { OpenDocRequest } from '../workspaces/workspace'; 2 4 import { DocumentPane } from './DocumentPane'; 3 5 import type { EditorMode } from './types'; 4 6 ··· 11 13 emptyHint?: string; 12 14 /** Rendering mode for the open editor; defaults to `edit`. */ 13 15 mode?: EditorMode; 14 - /** Forwarded to the editor for internal-link navigation. */ 15 - onOpenLink?: (target: string) => void; 16 + /** 17 + * Open a document (by provider + entry). When provided, internal-link clicks 18 + * in the editor are resolved against the open document and routed here — so a 19 + * workspace gets file-to-file navigation just by forwarding its open handler. 20 + */ 21 + onOpenEntry?: (request: OpenDocRequest) => void; 16 22 }; 17 23 18 24 function SlotPlaceholder({ message }: { message: string }) { ··· 33 39 placeholder = 'Start writing…', 34 40 emptyHint = DEFAULT_EMPTY_HINT, 35 41 mode, 36 - onOpenLink, 42 + onOpenEntry, 37 43 }: DocumentSlotViewProps) { 38 44 if (state.kind === 'empty') { 39 45 return <SlotPlaceholder message={emptyHint} />; ··· 48 54 /> 49 55 ); 50 56 } 57 + // Resolve internal-link targets against this document, then route to the 58 + // workspace's open handler. Kept here (not in each workspace) so every 59 + // workspace gets file-to-file navigation for free. 60 + const { request, handle } = state; 61 + const onOpenLink = onOpenEntry 62 + ? (target: string) => 63 + onOpenEntry( 64 + resolveDocumentLink( 65 + { providerId: request.providerId, path: handle.id }, 66 + target, 67 + ), 68 + ) 69 + : undefined; 51 70 return ( 52 71 // Key by document id so switching tabs to a different document remounts a 53 72 // fresh editor instead of reusing one bound to the previous document. The 54 73 // shared editor view binds its content on mount only, so without this a 55 74 // tab switch can leave the previous file's content showing. 56 75 <DocumentPane 57 - key={state.handle.id} 58 - handle={state.handle} 76 + key={handle.id} 77 + handle={handle} 59 78 placeholder={placeholder} 60 79 className={className} 61 80 mode={mode}
+5 -22
src/workspaces/tiling/tiling.tsx
··· 18 18 updateSplitRatio, 19 19 } from '../../tiling/model'; 20 20 import { useResizeObserver } from '../../tiling/useResizeObserver'; 21 - import { basename, dirname, join } from '../../filesystem/vaultFs'; 22 21 import { WorkspaceBarSlot } from '../barSlot'; 23 22 import type { OpenDocRequest, WorkspaceProps } from '../workspace'; 24 23 import { useTilingDocumentSlots } from './useTilingDocumentSlots'; ··· 163 162 onDropInZone: (fromTileId: string, tabId: string, zone: DropZone) => void; 164 163 onNewTab: () => void; 165 164 onNewFile?: () => void; 166 - /** Open an internal link target, resolved against this tile's active doc. */ 167 - onOpenLink: (target: string) => void; 165 + /** Open a document in the active tile (used to follow internal links). */ 166 + onOpenEntry?: (request: OpenDocRequest) => void; 168 167 }; 169 168 170 169 function TilingTile({ ··· 181 180 onDropInZone, 182 181 onNewTab, 183 182 onNewFile, 184 - onOpenLink, 183 + onOpenEntry, 185 184 }: TilingTileProps) { 186 185 // Zone shown while a tab is dragged over this tile's body (null = no drag). 187 186 const [dropZone, setDropZone] = useState<DropZone | null>(null); ··· 243 242 state={slot} 244 243 className="min-h-0 flex-1" 245 244 mode={mode} 246 - onOpenLink={onOpenLink} 245 + onOpenEntry={onOpenEntry} 247 246 /> 248 247 )} 249 248 {dropZone !== null ? ( ··· 503 502 }; 504 503 }, [tilingHandleRef, active]); 505 504 506 - /** 507 - * Follow an internal link from a tile's editor: resolve the (schemeless) 508 - * target relative to that tile's current document and open it. The clicked 509 - * tile is already active, so the open lands in the same tile. 510 - */ 511 - const handleOpenLink = (tileId: string, target: string) => { 512 - const slot = getSlot(tileId); 513 - if (slot.kind !== 'open') return; 514 - const absPath = join(dirname(slot.handle.id), target); 515 - onOpenEntry?.({ 516 - providerId: slot.request.providerId, 517 - entryId: absPath, 518 - title: basename(absPath), 519 - }); 520 - }; 521 - 522 505 const renderNode = (node: TilingNode): ReactNode => { 523 506 if (node.kind === 'tile') { 524 507 const tileId = node.id; ··· 532 515 tabs={getTabs(tileId)} 533 516 activeTabId={getActiveTabId(tileId)} 534 517 onActivate={() => setActiveTileId(tileId)} 535 - onOpenLink={(target) => handleOpenLink(tileId, target)} 518 + onOpenEntry={onOpenEntry} 536 519 onSelectTab={(tabId) => setActiveTab(tileId, tabId)} 537 520 onTabAction={(action, tabId) => 538 521 handleTabAction(tileId, action, tabId)
+6 -1
src/workspaces/zen/zen.tsx
··· 9 9 openRequestId, 10 10 openRequest, 11 11 resolveProvider, 12 + onOpenEntry, 12 13 }: WorkspaceProps) { 13 14 const { state, close } = useDocumentSlot({ 14 15 openRequestId, ··· 23 24 return ( 24 25 <> 25 26 <div className="flex h-full min-h-0 w-full flex-col overflow-hidden p-2"> 26 - <DocumentSlotView state={state} className="min-h-0 flex-1" /> 27 + <DocumentSlotView 28 + state={state} 29 + className="min-h-0 flex-1" 30 + onOpenEntry={onOpenEntry} 31 + /> 27 32 </div> 28 33 <WorkspaceBarSlot> 29 34 <div className="text-foreground/70 min-w-0 flex-1 truncate text-xs">