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 (
<>
-
+