diff --git a/src/documents/resolveDocumentLink.test.ts b/src/documents/resolveDocumentLink.test.ts new file mode 100644 index 0000000..87284ae --- /dev/null +++ b/src/documents/resolveDocumentLink.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveDocumentLink } from './resolveDocumentLink'; + +describe('resolveDocumentLink', () => { + const source = { providerId: 'vault-1', path: '/vault/notes/a.md' }; + + it('resolves a sibling file relative to the source document folder', () => { + expect(resolveDocumentLink(source, 'b.md')).toEqual({ + providerId: 'vault-1', + entryId: '/vault/notes/b.md', + title: 'b.md', + }); + }); + + it('resolves a nested subfolder target', () => { + expect(resolveDocumentLink(source, 'sub/c.md')).toEqual({ + providerId: 'vault-1', + entryId: '/vault/notes/sub/c.md', + title: 'c.md', + }); + }); + + it('carries the source provider onto the request', () => { + expect( + resolveDocumentLink({ providerId: 'other', path: '/x/y.md' }, 'z.md'), + ).toEqual({ + providerId: 'other', + entryId: '/x/z.md', + title: 'z.md', + }); + }); +}); diff --git a/src/documents/resolveDocumentLink.ts b/src/documents/resolveDocumentLink.ts new file mode 100644 index 0000000..615aa72 --- /dev/null +++ b/src/documents/resolveDocumentLink.ts @@ -0,0 +1,24 @@ +import { basename, dirname, join } from '../filesystem/vaultFs'; +import type { OpenDocRequest } from '../workspaces/workspace'; + +/** The open document a link is being followed *from*. */ +export type LinkSource = { + /** Provider the current document belongs to. */ + providerId: string; + /** The current document's id/path (links resolve relative to its folder). */ + path: string; +}; + +/** + * Resolve a relative markdown link target (e.g. `Note.md`, `sub/Note.md`) + * against the document it was clicked in, producing an `OpenDocRequest` the + * workspace can open. Resolution is a plain path join relative to the source + * document's folder — callers pass external (scheme-bearing) targets elsewhere. + */ +export function resolveDocumentLink( + source: LinkSource, + target: string, +): OpenDocRequest { + const entryId = join(dirname(source.path), target); + return { providerId: source.providerId, entryId, title: basename(entryId) }; +} diff --git a/src/editors/DocumentSlotView.tsx b/src/editors/DocumentSlotView.tsx index 5d5b16f..a09c007 100644 --- a/src/editors/DocumentSlotView.tsx +++ b/src/editors/DocumentSlotView.tsx @@ -1,4 +1,6 @@ import type { DocumentSlotState } from '../documents/useDocumentSlot'; +import { resolveDocumentLink } from '../documents/resolveDocumentLink'; +import type { OpenDocRequest } from '../workspaces/workspace'; import { DocumentPane } from './DocumentPane'; import type { EditorMode } from './types'; @@ -11,8 +13,12 @@ type DocumentSlotViewProps = { emptyHint?: string; /** Rendering mode for the open editor; defaults to `edit`. */ mode?: EditorMode; - /** Forwarded to the editor for internal-link navigation. */ - onOpenLink?: (target: string) => void; + /** + * Open a document (by provider + entry). When provided, internal-link clicks + * in the editor are resolved against the open document and routed here — so a + * workspace gets file-to-file navigation just by forwarding its open handler. + */ + onOpenEntry?: (request: OpenDocRequest) => void; }; function SlotPlaceholder({ message }: { message: string }) { @@ -33,7 +39,7 @@ export function DocumentSlotView({ placeholder = 'Start writing…', emptyHint = DEFAULT_EMPTY_HINT, mode, - onOpenLink, + onOpenEntry, }: DocumentSlotViewProps) { if (state.kind === 'empty') { return ; @@ -48,14 +54,27 @@ export function DocumentSlotView({ /> ); } + // Resolve internal-link targets against this document, then route to the + // workspace's open handler. Kept here (not in each workspace) so every + // workspace gets file-to-file navigation for free. + const { request, handle } = state; + const onOpenLink = onOpenEntry + ? (target: string) => + onOpenEntry( + resolveDocumentLink( + { providerId: request.providerId, path: handle.id }, + target, + ), + ) + : undefined; return ( // Key by document id so switching tabs to a different document remounts a // fresh editor instead of reusing one bound to the previous document. The // shared editor view binds its content on mount only, so without this a // tab switch can leave the previous file's content showing. void; onNewTab: () => void; onNewFile?: () => void; - /** Open an internal link target, resolved against this tile's active doc. */ - onOpenLink: (target: string) => void; + /** Open a document in the active tile (used to follow internal links). */ + onOpenEntry?: (request: OpenDocRequest) => void; }; function TilingTile({ @@ -181,7 +180,7 @@ function TilingTile({ onDropInZone, onNewTab, onNewFile, - onOpenLink, + onOpenEntry, }: TilingTileProps) { // Zone shown while a tab is dragged over this tile's body (null = no drag). const [dropZone, setDropZone] = useState(null); @@ -243,7 +242,7 @@ function TilingTile({ state={slot} className="min-h-0 flex-1" mode={mode} - onOpenLink={onOpenLink} + onOpenEntry={onOpenEntry} /> )} {dropZone !== null ? ( @@ -503,22 +502,6 @@ export function TilingWorkspace({ }; }, [tilingHandleRef, active]); - /** - * Follow an internal link from a tile's editor: resolve the (schemeless) - * target relative to that tile's current document and open it. The clicked - * tile is already active, so the open lands in the same tile. - */ - const handleOpenLink = (tileId: string, target: string) => { - const slot = getSlot(tileId); - if (slot.kind !== 'open') return; - const absPath = join(dirname(slot.handle.id), target); - onOpenEntry?.({ - providerId: slot.request.providerId, - entryId: absPath, - title: basename(absPath), - }); - }; - const renderNode = (node: TilingNode): ReactNode => { if (node.kind === 'tile') { const tileId = node.id; @@ -532,7 +515,7 @@ export function TilingWorkspace({ tabs={getTabs(tileId)} activeTabId={getActiveTabId(tileId)} onActivate={() => setActiveTileId(tileId)} - onOpenLink={(target) => handleOpenLink(tileId, target)} + onOpenEntry={onOpenEntry} onSelectTab={(tabId) => setActiveTab(tileId, tabId)} onTabAction={(action, tabId) => handleTabAction(tileId, action, tabId) diff --git a/src/workspaces/zen/zen.tsx b/src/workspaces/zen/zen.tsx index d85cc4b..f07fa68 100644 --- a/src/workspaces/zen/zen.tsx +++ b/src/workspaces/zen/zen.tsx @@ -9,6 +9,7 @@ export function ZenWorkspace({ openRequestId, openRequest, resolveProvider, + onOpenEntry, }: WorkspaceProps) { const { state, close } = useDocumentSlot({ openRequestId, @@ -23,7 +24,11 @@ export function ZenWorkspace({ return ( <>
- +