Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/specifications/internal-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Before this feature, a `page_link` whose target had been deleted was indistingui

## 5. Backlinks

Opening a Document shows a Backlinks panel. It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a navigable referring Document title and the current text of the referring block as context. The panel is derived live from `listIncomingLinks`, so a source edit, rename, move, or deletion is immediately reflected; it never stores a title as link identity and cannot retarget a backlink when Documents have duplicate titles.
Opening a Document shows a Backlinks panel (`BacklinksPanel.svelte`). It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a link to the exact referring block (not just the referring Document) and the current text of that block as context.

The current personal-MVP UI exposes every workspace Document to its sole local user. When workspace/space visibility boundaries are introduced, the panel must filter the `listIncomingLinks` result through the viewer's access scope before rendering; the reverse index itself remains an ID-based data primitive and must not become a permission side channel.
Powered by `services/documents.ts#listBacklinks`, not `$lib/data/links.ts#listIncomingLinks`'s client-side incremental index: that index is scoped to whatever single `Y.Doc` it's built against, which — since each Document has its own shard (#120) — can only ever be the current Document's own shard, structurally unable to span the workspace. The Backlinks panel built on it was removed for exactly this reason when #120 shipped. `listBacklinks` instead fans out across every Document's own shard server-side, the same `fanOutCatalogedAndUncataloged` pattern `search.ts#searchWorkspace` already established (#191), calling the stateless `listOutgoingLinks(doc, documentId)` scan once per fanned-out Document rather than relying on any single Y.Doc's incremental index. The load is SSR-only, not live (same accepted tradeoff `+layout.server.ts`'s own `documents`/`collections` lists already make) — a new backlink appears on next navigation/refresh, not instantly, since re-running a workspace-wide scan on every Yjs observer tick isn't proportionate.

Each entry navigates via a `#block-<id>` fragment on the referring Document's URL, reusing the same navigate/reveal/temporary-highlight mechanism `SyncedBlockUsage.svelte`'s "used in N places" panel already established: a same-document link short-circuits into an in-place scroll/focus/highlight (`navigateToBlock`) instead of a full reload, a cross-document link is a real `<a href>` (preserving browser Back and keyboard activation), and a stale/deleted source block degrades safely to just opening the Document, since both the DOM lookup and the block-ref lookup behind the highlight are optional-chained.

`listBacklinks` is permission-filtered the same way `listDocuments` is: a source Document the caller can't reach never contributes a backlink entry, so a scoped MCP token (not currently exposed there — UI-only per its `serviceSurfaces` entry) couldn't use this to learn about content outside its grant if it ever were. The current personal-MVP UI's human caller is unscoped (Phase 0), so this is a no-op for the UI's own use — the load-bearing case is forward-looking.
65 changes: 65 additions & 0 deletions src/lib/components/BacklinksPanel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { Backlink } from '$lib/data/links';
import Icon from './Icon.svelte';

// Issue #83: navigates to the exact referring block, not just the
// referring Document. `onJumpTo` mirrors SyncedBlockUsage.svelte's own
// same-document short-circuit — a link whose sourceDocumentId is the
// Document already open gets an in-place scroll/focus/highlight via the
// page's navigateToBlock instead of a full navigation (which would be a
// no-op page reload landing on the same #block-<id> hash regardless, just
// slower and losing in-progress state elsewhere on the page). A
// cross-document link is left as a real <a href>, so browser Back and
// keyboard Enter/Space activation both keep working for free.
let {
spaceId,
currentDocumentId,
backlinks,
onJumpTo
}: {
spaceId: string;
currentDocumentId: string;
backlinks: Backlink[];
onJumpTo: (documentId: string, recordId: string) => void;
} = $props();
</script>

<section
class="mt-5 rounded-lg border border-border bg-surface/50 p-3"
aria-labelledby="backlinks-heading"
>
<div class="flex items-center gap-2 text-xs font-semibold tracking-wider text-muted uppercase">
<Icon name="link" size={15} class="text-accent" />
<h2 id="backlinks-heading">Backlinks</h2>
<span class="normal-case">{backlinks.length}</span>
</div>
{#if backlinks.length > 0}
<ul class="mt-2 space-y-2">
{#each backlinks as backlink, index (`${backlink.sourceRecordId}-${index}`)}
<li class="min-w-0 text-sm">
<a
href="{resolve('/space/[spaceId]/doc/[id]', {
spaceId,
id: backlink.sourceDocumentId
})}#block-{backlink.sourceRecordId}"
onclick={(e) => {
if (backlink.sourceDocumentId === currentDocumentId) {
e.preventDefault();
onJumpTo(backlink.sourceDocumentId, backlink.sourceRecordId);
}
}}
class="font-medium text-fg underline underline-offset-2 transition-colors hover:text-accent"
>
{backlink.sourceDocumentTitle || 'Untitled Document'}
</a>
<p class="mt-0.5 truncate text-xs text-muted" title={backlink.context}>
{backlink.context}
</p>
</li>
{/each}
</ul>
{:else}
<p class="mt-2 text-xs text-muted italic">No pages link here yet.</p>
{/if}
</section>
173 changes: 173 additions & 0 deletions src/lib/components/SyncedBlockUsage.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'svelte';
import type { Backlink } from '$lib/data/links';
import SyncedBlockUsage from './SyncedBlockUsage.svelte';

function instance(overrides: Partial<Backlink> = {}): Backlink {
return {
sourceDocumentId: 'doc-source',
sourceDocumentTitle: 'Source Doc',
sourceRecordId: 'block-1',
context: 'Some context text',
...overrides
};
}

function baseProps(
overrides: Partial<ComponentProps<typeof SyncedBlockUsage>> = {}
): ComponentProps<typeof SyncedBlockUsage> {
return {
spaceId: 'space-1',
currentDocumentId: 'doc-current',
instances: [instance()],
onJumpTo: vi.fn(),
...overrides
};
}

// This is the one live surface using the navigate-to-`#block-<id>`-and-reveal
// mechanism issue #83 asks for — the general page_link/wiki-link Backlinks
// panel that issue describes was removed (#120) pending a shard-aware
// reverse index (#81/#70), so this is where that navigation contract is
// actually exercised today.
describe('SyncedBlockUsage (#153, #83)', () => {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
it('lists each usage as a real link to its exact source-block fragment', async () => {
const user = userEvent.setup();
render(SyncedBlockUsage, baseProps());

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));

const link = screen.getByRole('menuitem', { name: /Source Doc/ });
expect(link).toHaveAttribute('href', expect.stringContaining('/space/space-1/doc/doc-source'));
expect(link.getAttribute('href')).toMatch(/#block-block-1$/);
});

it('jumps in place and suppresses navigation when the usage is in the current document', async () => {
const user = userEvent.setup();
const props = baseProps({
currentDocumentId: 'doc-source',
instances: [instance({ sourceDocumentId: 'doc-source' })]
});
render(SyncedBlockUsage, props);

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));
await user.click(screen.getByRole('menuitem', { name: /Source Doc/ }));

expect(props.onJumpTo).toHaveBeenCalledExactlyOnceWith('doc-source', 'block-1');
});

// A real <a href> (not a JS-only handler) for a cross-document usage is
// what keeps browser Back and keyboard Enter/Space activation working for
// free — asserting onJumpTo is never called here is how this test proves
// the click was left to normal navigation instead of being intercepted.
it('does not intercept navigation when the usage is in a different document', async () => {
const user = userEvent.setup();
const props = baseProps({
currentDocumentId: 'doc-current',
instances: [instance({ sourceDocumentId: 'doc-other' })]
});
render(SyncedBlockUsage, props);

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));
await user.click(screen.getByRole('menuitem', { name: /Source Doc/ }));

expect(props.onJumpTo).not.toHaveBeenCalled();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// The pointer-click test above only proves the onclick handler's own
// preventDefault branch is skipped for a cross-document usage — it
// doesn't rule out some *other* handler (e.g. a keydown listener) doing
// the same thing only for keyboard activation. Enter-on-a-focused-anchor
// dispatches a real click event the same way a browser does, so this
// listens for that event directly to confirm nothing prevented it.
it('does not intercept keyboard (Enter) activation when the usage is in a different document', async () => {
const user = userEvent.setup();
const props = baseProps({
currentDocumentId: 'doc-current',
instances: [instance({ sourceDocumentId: 'doc-other' })]
});
render(SyncedBlockUsage, props);

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));
const link = screen.getByRole('menuitem', { name: /Source Doc/ });
let clickEvent: MouseEvent | undefined;
link.addEventListener('click', (e) => {
clickEvent = e as MouseEvent;
});

link.focus();
await user.keyboard('{Enter}');

expect(clickEvent).toBeDefined();
expect(clickEvent?.defaultPrevented).toBe(false);
expect(props.onJumpTo).not.toHaveBeenCalled();
});

it('shows a placeholder instead of a link list when there are no other usages', async () => {
const user = userEvent.setup();
render(SyncedBlockUsage, baseProps({ instances: [] }));

await user.click(screen.getByRole('button', { name: 'Used in 0 places' }));

expect(screen.getByText('No other locations yet.')).toBeInTheDocument();
expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();
});

it('closes on Escape', async () => {
const user = userEvent.setup();
render(SyncedBlockUsage, baseProps());

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));
expect(screen.getByRole('menu')).toBeInTheDocument();

await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

it('ArrowDown/ArrowUp move roving focus among usage links, wrapping at each end', async () => {
const user = userEvent.setup();
const props = baseProps({
instances: [
instance({ sourceRecordId: 'block-1', sourceDocumentTitle: 'First Doc' }),
instance({ sourceRecordId: 'block-2', sourceDocumentTitle: 'Second Doc' })
]
});
render(SyncedBlockUsage, props);

await user.click(screen.getByRole('button', { name: 'Used in 2 places' }));
const first = screen.getByRole('menuitem', { name: /First Doc/ });
const second = screen.getByRole('menuitem', { name: /Second Doc/ });
expect(first).toHaveFocus();

await user.keyboard('{ArrowDown}');
expect(second).toHaveFocus();

await user.keyboard('{ArrowDown}');
expect(first).toHaveFocus();

await user.keyboard('{ArrowUp}');
expect(second).toHaveFocus();
});

it('offers Detach only when onDetach is provided, and calls it', async () => {
const user = userEvent.setup();
const props = baseProps({ onDetach: vi.fn() });
render(SyncedBlockUsage, props);

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));
await user.click(screen.getByRole('menuitem', { name: 'Detach to independent copy' }));

expect(props.onDetach).toHaveBeenCalledOnce();
});

it('does not offer Detach when onDetach is absent', async () => {
const user = userEvent.setup();
render(SyncedBlockUsage, baseProps());

await user.click(screen.getByRole('button', { name: 'Used in 1 place' }));

expect(screen.queryByRole('menuitem', { name: 'Detach to independent copy' })).toBeNull();
});
});
79 changes: 77 additions & 2 deletions src/lib/services/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import {
createDocument as crdtCreateDocument,
deleteDocument as crdtDeleteDocument,
getDocument as crdtGetDocument,
listDocuments as crdtListDocuments,
resolveChildPages,
updateDocumentParent as crdtUpdateDocumentParent,
updateDocumentTitle as crdtUpdateDocumentTitle
} from '$lib/data/document-ops';
import { getCollection as crdtGetCollection } from '$lib/data/collection-ops';
import {
createRecord as crdtCreateRecord,
getRecord as crdtGetRecord,
listRecordsForParent as crdtListRecordsForParent
} from '$lib/data/record-ops';
import { plainText } from '$lib/data/richtext';
import { logAudit } from '$lib/server/audit';
import {
RecordIdConflictError,
Expand All @@ -27,8 +30,16 @@ import {
resolveShardForParent
} from '$lib/server/catalog';
import { grantDocumentAccess, tokenAllowsParent } from '$lib/server/token-store';
import { listWorkspaceDocuments } from '$lib/server/workspace-repository';
import { resolveInternalLinkTarget, type InternalLinkTarget } from '$lib/data/links';
import {
fanOutCatalogedAndUncataloged,
listWorkspaceDocuments
} from '$lib/server/workspace-repository';
import {
listOutgoingLinks,
resolveInternalLinkTarget,
type Backlink,
type InternalLinkTarget
} from '$lib/data/links';
import type {
CalloutStyle,
ChildPageNode,
Expand Down Expand Up @@ -593,6 +604,70 @@ export function getDocument(
};
}

/**
* Every Document currently pointing at `documentId` via a `page_link` block
* or an inline `[[wiki link]]` (`internal-links.md` §5) — the reverse of
* `listOutgoingLinks`. Fanned out across every Document's own shard the same
* way `search.ts#searchWorkspace` scans for text matches (#191's shared
* workspace-repository fan-out): `$lib/data/links.ts#listIncomingLinks`'s own
* incremental index is scoped to whatever single `Y.Doc` it's built against
* and can't span per-Document shards on its own (#120) — see that module's
* doc comment, and the Backlinks panel removal note this replaces in
* `+page.svelte`. `listOutgoingLinks` itself has no such limitation: it's a
* plain one-shot scan of one already-resolved `doc`, which is exactly what
* this function calls it with, once per fanned-out Document.
*
* Permission-filtered the same way `listDocuments` is: a source Document the
* caller can't reach never contributes a backlink entry, per
* `internal-links.md` §5's requirement that a viewer only ever sees backlinks
* from Documents already within their own access scope. A no-op filter for
* Phase 0's unscoped human caller (`isAccessToken` is false), load-bearing
* once this is ever reachable by a scoped MCP token.
*/
export function listBacklinks(caller: CallerIdentity, documentId: string): Backlink[] {
requireAccessibleParent(caller, documentId, 'list_backlinks');
const actor = actorForCaller(caller);
const { workspaceId, defaultSpaceId, doc: defaultDoc } = resolveWorkspaceContext();
const allowed = (id: string, docSpaceId?: string) =>
!isAccessToken(caller) || tokenAllowsParent(caller, id, docSpaceId);

const backlinks: Backlink[] = [];
for (const { meta, doc } of fanOutCatalogedAndUncataloged({
workspaceId,
defaultSpaceId,
defaultDoc,
listCatalog: listCatalogDocuments,
listUncataloged: crdtListDocuments,
getId: (m) => m.id,
getSpaceId: (m) => m.spaceId,
allowed,
resolveShardDoc: true
})) {
for (const link of listOutgoingLinks(doc, meta.id)) {
if (link.targetId !== documentId) continue;
const sourceRecord = crdtGetRecord(doc, link.sourceRecordId);
if (!sourceRecord) continue;
const trimmedContent = sourceRecord.content ? plainText(sourceRecord.content).trim() : '';
backlinks.push({
sourceDocumentId: meta.id,
sourceDocumentTitle: meta.title,
sourceRecordId: link.sourceRecordId,
context:
trimmedContent ||
(sourceRecord.blockType === 'page_link' ? 'Page link' : 'Untitled block')
});
}
}

logAudit({
actor,
action: 'list_backlinks',
targetRecordId: documentId,
diff: { count: backlinks.length }
});
return backlinks;
}

/**
* `spaceId` is optional and additive — omitted, this is exactly today's
* behavior (every Document in the workspace, catalog plus uncataloged
Expand Down
6 changes: 6 additions & 0 deletions src/lib/services/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export const serviceSurfaces: Record<ServiceMethod, ServiceSurfaceDefinition> =
mcpToolName: 'list_documents',
mcpDescription: 'List Documents this connection has access to, including tree hierarchy.'
},
// UI-only for now (issue #83): the Document route's own load function
// renders a Backlinks panel from this. Not MCP-exposed — #83's own scope
// doesn't ask for an agent-facing surface, and this can be added later
// without touching the function's own contract.
'documents.listBacklinks': { mcp: false, ui: true },

'records.createRecord': {
mcp: true,
Expand Down Expand Up @@ -207,6 +212,7 @@ export const uiAdapterBindings = {
// see docs/specifications/audit-coverage.md.
'documents.updateDocumentTitle': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte',
'documents.listDocuments': 'src/routes/+layout.server.ts',
'documents.listBacklinks': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts',
'records.createRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte',
'records.writeRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte',
'records.deleteRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte',
Expand Down
Loading
Loading