feat(docs): reach a document's history, and see what links to it

Two things the documentation section stored and never showed.

**History.** Up to 50 revisions per document have been written since the
section shipped — on every save, restore, regenerate, scaffold and the notes
migration — and nothing reached them. RegenerateDocModal went as far as
telling the user the old body "is in its history", which was true and useless:
there was no way there. The header now carries a history button; the rail lists
each version by what caused it, selecting one replaces the body in place, and
Changes turns it into a line diff against the current body. Restore snapshots
the body it replaces first, so a restore is itself undoable.

A revision's body is fetched only when it is opened — the list carries a size,
not the text, which is what RevisionSummary was shaped for.

The diff is a plain LCS over lines after the common head and tail are trimmed,
with unchanged runs collapsed to gaps: no library, and a pathological pair of
long unrelated bodies degrades to "replaced wholesale" rather than building a
quarter-million-cell matrix.

**Backlinks.** A wiki-link only says where it goes. Every document now lists
what points at it, with the line the link was written on and the label it was
written as; repeated links from one document collapse into a count.

The inversion is answered by the server, because the browser holds no body but
the open one — the list endpoint is metadata-only so the tree can badge without
downloading the space. doc_backlinks.py therefore mirrors the resolution rules
in wikilinks.ts, the way the generator's service_url mirrors serviceUrl.ts, and
its tests pin them. The inventory is loaded only when a body actually carries a
[[device:…]].

The client-side backlinkIndex goes with it. It was written, tested and never
rendered; keeping a second copy of the resolution rules on the side that cannot
see every body is only drift.

ha-relevant: no
This commit is contained in:
Pouzor
2026-09-07 15:22:23 +02:00
committed by Pouzor - Rémy Jardient
parent 080de69543
commit 761a1a526c
16 changed files with 1662 additions and 50 deletions
+50 -1
View File
@@ -25,6 +25,7 @@ from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Document, DocumentRevision, Edge, InventoryDevice, Node, Rack, RackDevice
from app.schemas.documents import (
BacklinkHit,
CoverageResponse,
DocumentCreate,
DocumentResponse,
@@ -37,7 +38,7 @@ from app.schemas.documents import (
SearchHit,
SearchResponse,
)
from app.services import doc_search
from app.services import doc_backlinks, doc_search
from app.services.doc_template import (
BLOCKS,
TEMPLATE_DEVICE,
@@ -389,6 +390,54 @@ async def list_revisions(
]
@router.get("/{document_id}/backlinks", response_model=list[BacklinkHit])
async def list_backlinks(
document_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[BacklinkHit]:
"""The documents whose body links here.
Answered on the server because the browser only holds document *metadata* —
the list endpoint carries no bodies, and loading every body to invert the
links client-side would trade a small query for a large download on every
open.
"""
doc = await db.get(Document, document_id)
if not doc:
raise HTTPException(404, "Document not found")
# Every document is loaded because every one is a possible *target* of a
# link — a bare `[[VLAN plan]]` resolves by title. Only the bodies carrying
# `[[` are walked as sources, and the inventory is fetched only when a
# `[[device:…]]` is actually in play.
docs = list(
(
await db.execute(select(Document).order_by(Document.sort_order, Document.title))
).scalars().all()
)
devices = (
list((await db.execute(select(InventoryDevice))).scalars().all())
if doc_backlinks.has_device_link(docs)
else []
)
titles = {d.id: d for d in docs}
return [
BacklinkHit(
doc_id=hit.doc_id,
title=titles[hit.doc_id].title,
kind=titles[hit.doc_id].kind,
device_id=titles[hit.doc_id].device_id,
label=hit.label,
context=hit.context,
count=hit.count,
)
for hit in doc_backlinks.backlinks_for(document_id, docs, devices)
if hit.doc_id in titles
]
@router.get("/revisions/{revision_id}", response_model=RevisionResponse)
async def get_revision(
revision_id: str,
+14
View File
@@ -127,6 +127,20 @@ class SearchResponse(BaseModel):
hits: list[SearchHit]
class BacklinkHit(BaseModel):
"""A document that links here, and the line it does it on."""
doc_id: str
title: str
kind: str
device_id: str | None = None
# What the link was written as, so a `[[…|label]]` reads back as the author
# meant it rather than as the target's own title.
label: str
context: str
count: int = 1
class ScaffoldRequest(BaseModel):
"""Create the missing device documents.
+158
View File
@@ -0,0 +1,158 @@
"""Backlinks — the documents pointing at a given document.
A wiki-link is one-way in the body: `[[device:nas-01]]` says where to go, not
where it came from. This walks every body once and inverts that.
The parsing and resolution rules **mirror `frontend/src/documentation/
wikilinks.ts` exactly** — same prefixes, same fallback order, same
case-insensitivity — the way `doc_template.service_url` mirrors
`utils/serviceUrl.ts`. They are duplicated rather than shared because the
forward direction has to resolve while the user types, with only what the
browser already holds, and the reverse direction needs every body, which the
browser does not hold: the list endpoint is metadata-only on purpose.
Change one side and change the other; `test_doc_backlinks.py` pins the rules.
"""
import re
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
_LINK = re.compile(r"\[\[([^\]]+)\]\]")
_TARGETS = {"device", "doc", "node"}
# How much of the line the link sits on is worth showing next to it.
_CONTEXT_CHARS = 160
@dataclass(frozen=True)
class WikiLink:
"""`[[device:nas-01|the NAS]]` → target=device, key=nas-01, label=the NAS."""
target: str
key: str
label: str
@dataclass(frozen=True)
class Backlink:
"""One document pointing at another, and the line it does it on."""
doc_id: str
label: str
context: str
count: int
def parse_link(inner: str) -> WikiLink | None:
"""Parse the inside of a `[[…]]`. An unknown prefix is part of the key."""
address, _, label_part = inner.partition("|")
address = address.strip()
label = label_part.strip()
if not address:
return None
target = "doc"
key = address
prefix, sep, rest = address.partition(":")
if sep and prefix.lower() in _TARGETS:
target = prefix.lower()
key = rest.strip()
if not key:
return None
return WikiLink(target=target, key=key, label=label or key)
def iter_links(body: str) -> Iterator[tuple[str, WikiLink]]:
"""Every link in a body, with the line it was written on."""
for line in (body or "").splitlines():
for match in _LINK.finditer(line):
link = parse_link(match.group(1))
if link:
yield line, link
def device_label(device: Any) -> str:
"""Mirrors `deviceLabel` in `documentation/tree.ts`."""
return (
device.label
or device.friendly_name
or device.hostname
or device.ip
or "Unnamed device"
)
def resolve(link: WikiLink, docs: list[Any], devices: list[Any]) -> str | None:
"""The document a link points at, or None when nothing matches yet."""
key = link.key.lower()
if link.target == "device":
device = next(
(d for d in devices if d.id == link.key or device_label(d).lower() == key),
None,
)
if device is None:
return None
return next((doc.id for doc in docs if doc.device_id == device.id), None)
if link.target == "node":
return next((doc.id for doc in docs if doc.node_id == link.key), None)
# A bare or `doc:` link: id, then slug, then title.
return (
next((doc.id for doc in docs if doc.id == link.key), None)
or next((doc.id for doc in docs if (doc.slug or "").lower() == key), None)
or next((doc.id for doc in docs if (doc.title or "").lower() == key), None)
)
def _context(line: str, label: str) -> str:
"""The line the link is on, trimmed to a readable window around it."""
text = " ".join(line.split())
if len(text) <= _CONTEXT_CHARS:
return text
at = text.find(label)
if at < 0:
return text[:_CONTEXT_CHARS].rstrip() + "…"
start = max(0, at - _CONTEXT_CHARS // 2)
end = min(len(text), start + _CONTEXT_CHARS)
return ("…" if start else "") + text[start:end].strip() + ("…" if end < len(text) else "")
def backlinks_for(target_id: str, docs: list[Any], devices: list[Any]) -> list[Backlink]:
"""Which of `docs` link to `target_id`, in the order the tree lists them.
One entry per source document however many times it links, because the
reader wants the documents, not the occurrences; `count` keeps the rest.
A document linking to itself is not a backlink.
"""
found: dict[str, Backlink] = {}
for doc in docs:
# Every document is a resolution target, but only one carrying `[[`
# can be a source — skipping the rest early keeps this one cheap pass.
if doc.id == target_id or "[[" not in (doc.body or ""):
continue
for line, link in iter_links(doc.body or ""):
if resolve(link, docs, devices) != target_id:
continue
existing = found.get(doc.id)
if existing is None:
found[doc.id] = Backlink(
doc_id=doc.id,
label=link.label,
context=_context(line, link.label),
count=1,
)
else:
found[doc.id] = Backlink(
doc_id=existing.doc_id,
label=existing.label,
context=existing.context,
count=existing.count + 1,
)
return list(found.values())
def has_device_link(docs: list[Any]) -> bool:
"""Whether any body carries a `[[device:…]]`, so the devices load is skippable."""
return any(
link.target == "device" for doc in docs for _, link in iter_links(doc.body or "")
)
+254
View File
@@ -0,0 +1,254 @@
"""Backlinks — the inverse of a wiki-link.
Two halves. The pure resolution rules, which must stay identical to
`frontend/src/documentation/wikilinks.ts`, and the route, which has to find a
target document that carries no links of its own and must not pay for the
inventory when nothing links to a device.
"""
from dataclasses import dataclass
from httpx import AsyncClient
from app.services import doc_backlinks
@dataclass
class FakeDoc:
id: str
slug: str = ""
title: str = ""
body: str = ""
device_id: str | None = None
node_id: str | None = None
@dataclass
class FakeDevice:
id: str
label: str | None = None
friendly_name: str | None = None
hostname: str | None = None
ip: str | None = None
# ── parsing ─────────────────────────────────────────────────────────────────
def test_a_bare_link_is_a_document_link():
link = doc_backlinks.parse_link("VLAN plan")
assert link is not None
assert (link.target, link.key, link.label) == ("doc", "VLAN plan", "VLAN plan")
def test_a_prefix_picks_the_target():
assert doc_backlinks.parse_link("device:nas-01").target == "device"
assert doc_backlinks.parse_link("NODE:abc").target == "node"
assert doc_backlinks.parse_link("doc:vlan-plan").target == "doc"
def test_an_unknown_prefix_stays_part_of_the_key():
link = doc_backlinks.parse_link("http://nas.lan")
assert link is not None
assert (link.target, link.key) == ("doc", "http://nas.lan")
def test_a_pipe_sets_the_label():
link = doc_backlinks.parse_link("device:nas-01|the big NAS")
assert link is not None
assert (link.key, link.label) == ("nas-01", "the big NAS")
def test_an_empty_link_is_not_a_link():
assert doc_backlinks.parse_link("") is None
assert doc_backlinks.parse_link("device:") is None
def test_links_are_collected_with_their_line():
body = "intro\nsee [[a]] and [[b]]\n"
found = list(doc_backlinks.iter_links(body))
assert [link.key for _, link in found] == ["a", "b"]
assert {line for line, _ in found} == {"see [[a]] and [[b]]"}
# ── resolution ──────────────────────────────────────────────────────────────
def _resolve(inner: str, docs: list[FakeDoc], devices: list[FakeDevice] | None = None):
link = doc_backlinks.parse_link(inner)
assert link is not None
return doc_backlinks.resolve(link, docs, devices or [])
def test_a_document_resolves_by_id_then_slug_then_title():
docs = [
FakeDoc(id="d1", slug="vlan-plan", title="VLAN plan"),
FakeDoc(id="d2", slug="other", title="Other"),
]
assert _resolve("d1", docs) == "d1"
assert _resolve("vlan-plan", docs) == "d1"
assert _resolve("VLAN PLAN", docs) == "d1"
def test_an_unmatched_document_link_resolves_to_nothing():
assert _resolve("nowhere", [FakeDoc(id="d1", slug="s", title="t")]) is None
def test_a_device_link_resolves_by_id_or_label():
docs = [FakeDoc(id="d1", device_id="dev1")]
devices = [FakeDevice(id="dev1", label="nas-01")]
assert _resolve("device:dev1", docs, devices) == "d1"
assert _resolve("device:NAS-01", docs, devices) == "d1"
def test_a_device_label_falls_back_the_way_the_tree_does():
assert doc_backlinks.device_label(FakeDevice(id="x", hostname="h")) == "h"
assert doc_backlinks.device_label(FakeDevice(id="x", ip="10.0.0.1")) == "10.0.0.1"
assert doc_backlinks.device_label(FakeDevice(id="x")) == "Unnamed device"
def test_a_device_with_no_document_resolves_to_nothing():
assert _resolve("device:dev1", [], [FakeDevice(id="dev1", label="nas")]) is None
def test_a_node_link_resolves_through_node_id():
docs = [FakeDoc(id="d1", node_id="n1")]
assert _resolve("node:n1", docs) == "d1"
assert _resolve("node:n2", docs) is None
# ── inversion ───────────────────────────────────────────────────────────────
def test_a_document_that_links_here_is_a_backlink():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="see [[B]]"),
FakeDoc(id="d2", slug="b", title="B", body="no links"),
]
hits = doc_backlinks.backlinks_for("d2", docs, [])
assert [h.doc_id for h in hits] == ["d1"]
assert hits[0].context == "see [[B]]"
def test_a_document_does_not_link_to_itself():
docs = [FakeDoc(id="d1", slug="a", title="A", body="[[A]] again")]
assert doc_backlinks.backlinks_for("d1", docs, []) == []
def test_two_links_from_one_document_are_one_backlink_with_a_count():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="[[B]] and later [[b]]"),
FakeDoc(id="d2", slug="b", title="B"),
]
hits = doc_backlinks.backlinks_for("d2", docs, [])
assert len(hits) == 1
assert hits[0].count == 2
def test_the_written_label_is_kept():
docs = [
FakeDoc(id="d1", slug="a", title="A", body="[[B|the other one]]"),
FakeDoc(id="d2", slug="b", title="B"),
]
assert doc_backlinks.backlinks_for("d2", docs, [])[0].label == "the other one"
def test_a_long_line_is_windowed_around_the_link():
filler = "word " * 60
docs = [
FakeDoc(id="d1", slug="a", title="A", body=f"{filler}[[B]]{filler}"),
FakeDoc(id="d2", slug="b", title="B"),
]
context = doc_backlinks.backlinks_for("d2", docs, [])[0].context
assert "[[B]]" in context
assert len(context) < 200
def test_has_device_link_only_fires_on_a_device_link():
assert doc_backlinks.has_device_link([FakeDoc(id="d", body="[[device:x]]")])
assert not doc_backlinks.has_device_link([FakeDoc(id="d", body="[[plain]]")])
# ── route ───────────────────────────────────────────────────────────────────
async def test_backlinks_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/x/backlinks")).status_code == 401
async def test_backlinks_404_on_an_unknown_document(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/nope/backlinks", headers=headers)
assert res.status_code == 404
async def test_the_route_finds_a_target_that_carries_no_links(client: AsyncClient, headers: dict):
"""The target's own body has no `[[`, so it is only ever a resolution target."""
target = (
await client.post("/api/v1/documents", json={"title": "VLAN plan"}, headers=headers)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Runbook"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Read the [[VLAN plan]] first."},
headers=headers,
)
res = await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)
assert res.status_code == 200
hits = res.json()
assert [h["doc_id"] for h in hits] == [source["id"]]
assert hits[0]["title"] == "Runbook"
assert hits[0]["label"] == "VLAN plan"
assert hits[0]["context"] == "Read the [[VLAN plan]] first."
async def test_a_device_document_is_reachable_by_its_device_link(client: AsyncClient, headers: dict):
res = await client.post(
"/api/v1/scan/pending",
json={
"label": "nas-01",
"hostname": "nas-01.lan",
"ip": "192.168.1.20",
"discovery_source": "manual",
},
headers=headers,
)
assert res.status_code in (200, 201), res.text
device = res.json()
doc = (
await client.post(
"/api/v1/documents",
json={"title": "nas-01", "kind": "device", "device_id": device["id"]},
headers=headers,
)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Backups"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Runs on [[device:nas-01]]."},
headers=headers,
)
hits = (await client.get(f"/api/v1/documents/{doc['id']}/backlinks", headers=headers)).json()
assert [h["doc_id"] for h in hits] == [source["id"]]
assert hits[0]["device_id"] is None # the *source* is a page, not a device
async def test_an_unresolved_link_is_not_a_backlink(client: AsyncClient, headers: dict):
target = (
await client.post("/api/v1/documents", json={"title": "Target"}, headers=headers)
).json()
source = (
await client.post("/api/v1/documents", json={"title": "Source"}, headers=headers)
).json()
await client.patch(
f"/api/v1/documents/{source['id']}",
json={"body": "Points at [[Something else]]."},
headers=headers,
)
hits = (await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)).json()
assert hits == []
+2
View File
@@ -337,6 +337,8 @@ export const documentsApi = {
),
restore: (id: string, revisionId: string) =>
api.post<import('@/documentation/types').Doc>(`/documents/${id}/revisions/${revisionId}/restore`),
backlinks: (id: string) =>
api.get<import('@/documentation/types').DocBacklink[]>(`/documents/${id}/backlinks`),
regenerate: (id: string) =>
api.post<import('@/documentation/types').Doc>(`/documents/${id}/regenerate`),
search: (q: string, limit = 25) =>
@@ -0,0 +1,203 @@
/**
* The two panels a document grew: the versions it used to have, and the
* documents that point at it. Both existed in the API from the start and
* neither had a way in, so what these assert first is that the wiring is there.
*/
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { DocViewer, type HistoryControls } from '../components/DocViewer'
import type { Doc, DocBacklink, DocRevision } from '../types'
function makeDoc(overrides: Partial<Doc> = {}): Doc {
return {
id: 'doc-1',
kind: 'page',
title: 'NAS',
slug: 'nas',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
body: 'the current body\nsecond line',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as Doc
}
function revision(overrides: Partial<DocRevision> = {}): DocRevision {
return {
id: 'rev-1',
document_id: 'doc-1',
title: 'NAS',
reason: 'edit',
saved_at: '2026-01-01T00:00:00Z',
size: 2048,
...overrides,
}
}
function backlink(overrides: Partial<DocBacklink> = {}): DocBacklink {
return {
doc_id: 'doc-2',
title: 'Backup runbook',
kind: 'page',
label: 'NAS',
context: 'Runs nightly against [[NAS]].',
count: 1,
...overrides,
}
}
function controls(overrides: Partial<HistoryControls> = {}): HistoryControls {
return {
open: false,
loading: false,
revisions: [],
preview: null,
onToggle: vi.fn(),
onSelect: vi.fn(),
onClosePreview: vi.fn(),
onRestore: vi.fn(),
...overrides,
}
}
const base = {
docs: [],
devices: [],
drifted: false,
onEdit: vi.fn(),
onToggleStar: vi.fn(),
onMarkReviewed: vi.fn(),
onRegenerate: vi.fn(),
onDelete: vi.fn(),
onOpenDoc: vi.fn(),
onCreateFromLink: vi.fn(),
onToggleTask: vi.fn(),
onSetTags: vi.fn(),
}
describe('DocViewer — history', () => {
it('offers no history when the host keeps none', () => {
render(<DocViewer {...base} doc={makeDoc()} />)
expect(screen.queryByLabelText('Version history')).toBeNull()
})
it('opens the rail from the header button', () => {
const history = controls()
render(<DocViewer {...base} doc={makeDoc()} history={history} />)
fireEvent.click(screen.getByLabelText('Version history'))
expect(history.onToggle).toHaveBeenCalled()
})
it('says the button is pressed while the rail is open', () => {
render(<DocViewer {...base} doc={makeDoc()} history={controls({ open: true })} />)
expect(screen.getByLabelText('Version history')).toHaveAttribute('aria-pressed', 'true')
})
it('lists a revision by what caused it, and selects it on click', () => {
const history = controls({ open: true, revisions: [revision({ reason: 'regenerate' })] })
render(<DocViewer {...base} doc={makeDoc()} history={history} />)
fireEvent.click(screen.getByText('Regenerated'))
expect(history.onSelect).toHaveBeenCalledWith('rev-1')
expect(screen.getByText(/2 kB/)).toBeTruthy()
})
it('explains an empty history rather than showing an empty list', () => {
render(<DocViewer {...base} doc={makeDoc()} history={controls({ open: true })} />)
expect(screen.getByText(/No earlier version yet/)).toBeTruthy()
})
it('replaces the body with the version being read', () => {
const history = controls({
open: true,
revisions: [revision()],
preview: { revision: revision(), body: 'what it said before' },
})
render(<DocViewer {...base} doc={makeDoc()} history={history} />)
expect(screen.getByText('what it said before')).toBeTruthy()
expect(screen.queryByText(/the current body/)).toBeNull()
})
it('restores the version being read, and can go back to the current one', () => {
const history = controls({
open: true,
revisions: [revision()],
preview: { revision: revision(), body: 'what it said before' },
})
render(<DocViewer {...base} doc={makeDoc()} history={history} />)
fireEvent.click(screen.getByText('Restore'))
expect(history.onRestore).toHaveBeenCalledWith('rev-1')
fireEvent.click(screen.getByLabelText('Back to the current version'))
expect(history.onClosePreview).toHaveBeenCalled()
})
it('shows what changed since that version', () => {
const history = controls({
preview: { revision: revision(), body: 'the current body\nold second line' },
})
render(<DocViewer {...base} doc={makeDoc()} history={history} />)
// The summary is on screen before anything is clicked: one line each way.
expect(screen.getByText('+1')).toBeTruthy()
expect(screen.getByText('−1')).toBeTruthy()
fireEvent.click(screen.getByText('Changes'))
expect(screen.getByText(/− old second line/)).toBeTruthy()
expect(screen.getByText(/\+ second line/)).toBeTruthy()
})
it('says so when a version is identical to the current body', () => {
const doc = makeDoc()
const history = controls({ preview: { revision: revision(), body: doc.body } })
render(<DocViewer {...base} doc={doc} history={history} />)
expect(screen.getByText('Identical to the current version')).toBeTruthy()
})
})
describe('DocViewer — backlinks', () => {
it('lists what links here, with the line it links from', () => {
render(<DocViewer {...base} doc={makeDoc()} backlinks={[backlink()]} />)
expect(screen.getByText('Linked from (1)')).toBeTruthy()
expect(screen.getByText('Backup runbook')).toBeTruthy()
expect(screen.getByText('Runs nightly against [[NAS]].')).toBeTruthy()
})
it('opens the linking document', () => {
const onOpenDoc = vi.fn()
render(<DocViewer {...base} doc={makeDoc()} backlinks={[backlink()]} onOpenDoc={onOpenDoc} />)
fireEvent.click(screen.getByText('Backup runbook'))
expect(onOpenDoc).toHaveBeenCalledWith('doc-2')
})
it('counts repeated links from the same document once, and says how many', () => {
render(<DocViewer {...base} doc={makeDoc()} backlinks={[backlink({ count: 3 })]} />)
expect(screen.getAllByText('Backup runbook')).toHaveLength(1)
expect(screen.getByText('×3')).toBeTruthy()
})
it('shows nothing at all when nothing links here', () => {
render(<DocViewer {...base} doc={makeDoc()} backlinks={[]} />)
expect(screen.queryByText(/Linked from/)).toBeNull()
})
it('waits for the answer rather than claiming nothing links here', () => {
render(<DocViewer {...base} doc={makeDoc()} backlinks={[backlink()]} backlinksLoading />)
expect(screen.queryByText(/Linked from/)).toBeNull()
})
})
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { collapseDiff, diffLines, diffStat, type DiffLine } from '../diff'
const text = (lines: DiffLine[], kind: DiffLine['kind']) =>
lines.filter((line) => line.kind === kind).map((line) => line.text)
describe('diffLines', () => {
it('marks an unchanged body as all the same', () => {
const body = 'one\ntwo\nthree'
expect(diffLines(body, body).every((line) => line.kind === 'same')).toBe(true)
})
it('finds an inserted line', () => {
const lines = diffLines('one\nthree', 'one\ntwo\nthree')
expect(text(lines, 'add')).toEqual(['two'])
expect(text(lines, 'del')).toEqual([])
})
it('finds a removed line', () => {
const lines = diffLines('one\ntwo\nthree', 'one\nthree')
expect(text(lines, 'del')).toEqual(['two'])
expect(text(lines, 'add')).toEqual([])
})
it('reads a changed line as one removal and one addition', () => {
const lines = diffLines('one\ntwo\nthree', 'one\nTWO\nthree')
expect(text(lines, 'del')).toEqual(['two'])
expect(text(lines, 'add')).toEqual(['TWO'])
})
it('keeps every line of both bodies', () => {
const lines = diffLines('a\nb', 'a\nc\nd')
expect(lines.map((line) => line.text)).toEqual(['a', 'b', 'c', 'd'])
})
it('handles an empty body on either side', () => {
expect(text(diffLines('', 'new'), 'add')).toEqual(['new'])
expect(text(diffLines('old', ''), 'del')).toEqual(['old'])
})
it('degrades to a wholesale replacement rather than hanging on two huge bodies', () => {
const before = Array.from({ length: 700 }, (_, i) => `before ${i}`).join('\n')
const after = Array.from({ length: 700 }, (_, i) => `after ${i}`).join('\n')
const lines = diffLines(before, after)
expect(text(lines, 'del')).toHaveLength(700)
expect(text(lines, 'add')).toHaveLength(700)
})
})
describe('diffStat', () => {
it('counts what changed', () => {
expect(diffStat(diffLines('a\nb\nc', 'a\nB\nc\nd'))).toEqual({ added: 2, removed: 1 })
})
it('counts nothing for an identical body', () => {
expect(diffStat(diffLines('a', 'a'))).toEqual({ added: 0, removed: 0 })
})
})
describe('collapseDiff', () => {
it('collapses a long unchanged run into a gap', () => {
const before = Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n')
const after = before.replace('line 15', 'line fifteen')
const rows = collapseDiff(diffLines(before, after))
const gaps = rows.filter((row) => row.kind === 'gap')
expect(gaps).toHaveLength(2)
expect(rows.some((row) => row.kind === 'add' && row.text === 'line fifteen')).toBe(true)
})
it('keeps the lines around a change as context', () => {
const rows = collapseDiff(diffLines('a\nb\nc\nd\ne', 'a\nb\nC\nd\ne'), 1)
expect(rows.filter((row) => row.kind === 'same').map((row) => row.text)).toEqual(['b', 'd'])
})
it('says how many lines a gap hides', () => {
const before = Array.from({ length: 20 }, (_, i) => `l${i}`).join('\n')
const after = `${before}\nnew`
const [gap] = collapseDiff(diffLines(before, after)).filter((row) => row.kind === 'gap')
expect(gap).toMatchObject({ skipped: 17 })
expect(gap.text).toBe('17 unchanged lines')
})
it('leaves a diff with no change as a single gap', () => {
const rows = collapseDiff(diffLines('a\nb', 'a\nb'))
expect(rows).toEqual([{ kind: 'gap', text: '2 unchanged lines', skipped: 2 }])
})
})
@@ -0,0 +1,266 @@
/**
* The two halves of a document's context: what it used to say, and what points
* at it. Both were reachable in the API and in the store long before anything
* rendered them, so these pin the wiring as much as the logic.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { documentsApi } from '@/api/client'
import { useDocsStore } from '../store'
import type { Doc, DocBacklink, DocRevision } from '../types'
vi.mock('@/api/client', () => ({
documentsApi: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
revisions: vi.fn(),
revision: vi.fn(),
restore: vi.fn(),
regenerate: vi.fn(),
backlinks: vi.fn(),
search: vi.fn(),
block: vi.fn(),
coverage: vi.fn(),
scaffold: vi.fn(),
},
}))
const api = vi.mocked(documentsApi)
function doc(overrides: Partial<Doc> = {}): Doc {
return {
id: 'doc-1',
kind: 'page',
title: 'Page',
slug: 'page',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
body: 'current body',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as Doc
}
function revision(overrides: Partial<DocRevision> = {}): DocRevision {
return {
id: 'rev-1',
document_id: 'doc-1',
title: 'Page',
reason: 'edit',
saved_at: '2026-01-02T00:00:00Z',
size: 12,
...overrides,
}
}
function backlink(overrides: Partial<DocBacklink> = {}): DocBacklink {
return {
doc_id: 'doc-2',
title: 'Runbook',
kind: 'page',
label: 'Page',
context: 'see [[Page]]',
count: 1,
...overrides,
}
}
const INITIAL = useDocsStore.getState()
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
useDocsStore.setState({
...INITIAL,
docs: [],
openDoc: null,
draft: null,
revisions: [],
revisionsLoading: false,
revisionPreview: null,
backlinks: [],
backlinksLoading: false,
loadError: null,
})
})
// ── revisions ───────────────────────────────────────────────────────────────
describe('loadRevisions', () => {
it('stores the list and clears the loading flag', async () => {
useDocsStore.setState({ openDoc: doc() })
api.revisions.mockResolvedValue({ data: [revision()] } as never)
await useDocsStore.getState().loadRevisions('doc-1')
expect(useDocsStore.getState().revisions).toHaveLength(1)
expect(useDocsStore.getState().revisionsLoading).toBe(false)
})
it('drops an answer for a document the user has already left', async () => {
useDocsStore.setState({ openDoc: doc({ id: 'doc-9' }) })
api.revisions.mockResolvedValue({ data: [revision()] } as never)
await useDocsStore.getState().loadRevisions('doc-1')
expect(useDocsStore.getState().revisions).toEqual([])
})
it('reports a failure instead of spinning forever', async () => {
useDocsStore.setState({ openDoc: doc() })
api.revisions.mockRejectedValue(new Error('boom'))
await useDocsStore.getState().loadRevisions('doc-1')
expect(useDocsStore.getState().revisionsLoading).toBe(false)
expect(useDocsStore.getState().loadError).toBe('Could not load the history')
})
})
describe('previewRevision', () => {
it('fetches the body of a revision already in the list', async () => {
useDocsStore.setState({ openDoc: doc(), revisions: [revision()] })
api.revision.mockResolvedValue({ data: { ...revision(), body: 'old body' } } as never)
await useDocsStore.getState().previewRevision('rev-1')
expect(api.revision).toHaveBeenCalledWith('rev-1')
expect(useDocsStore.getState().revisionPreview).toEqual({
revision: revision(),
body: 'old body',
})
})
it('asks for nothing when the revision is not in the list', async () => {
await useDocsStore.getState().previewRevision('rev-nope')
expect(api.revision).not.toHaveBeenCalled()
expect(useDocsStore.getState().revisionPreview).toBeNull()
})
it('surfaces a failed read', async () => {
useDocsStore.setState({ revisions: [revision()] })
api.revision.mockRejectedValue(new Error('boom'))
await useDocsStore.getState().previewRevision('rev-1')
expect(useDocsStore.getState().revisionPreview).toBeNull()
expect(useDocsStore.getState().loadError).toBe('Could not read that version')
})
it('closes on demand', () => {
useDocsStore.setState({ revisionPreview: { revision: revision(), body: 'old' } })
useDocsStore.getState().closeRevisionPreview()
expect(useDocsStore.getState().revisionPreview).toBeNull()
})
})
describe('restore', () => {
it('takes the restored body and closes the version being read', async () => {
useDocsStore.setState({
openDoc: doc(),
docs: [doc()],
revisions: [revision()],
revisionPreview: { revision: revision(), body: 'old body' },
})
api.restore.mockResolvedValue({ data: doc({ body: 'old body' }) } as never)
api.revisions.mockResolvedValue({ data: [revision({ id: 'rev-2', reason: 'restore' })] } as never)
await useDocsStore.getState().restore('doc-1', 'rev-1')
expect(useDocsStore.getState().openDoc?.body).toBe('old body')
expect(useDocsStore.getState().revisionPreview).toBeNull()
// The restore itself became a revision, so the list is re-read.
expect(useDocsStore.getState().revisions[0].reason).toBe('restore')
})
it('keeps the editor on the restored body when one was open', async () => {
useDocsStore.setState({ openDoc: doc(), docs: [doc()], draft: 'half-typed' })
api.restore.mockResolvedValue({ data: doc({ body: 'old body' }) } as never)
api.revisions.mockResolvedValue({ data: [] } as never)
await useDocsStore.getState().restore('doc-1', 'rev-1')
expect(useDocsStore.getState().draft).toBe('old body')
expect(useDocsStore.getState().dirty).toBe(false)
})
})
// ── backlinks ───────────────────────────────────────────────────────────────
describe('loadBacklinks', () => {
it('stores what links here', async () => {
useDocsStore.setState({ openDoc: doc() })
api.backlinks.mockResolvedValue({ data: [backlink()] } as never)
await useDocsStore.getState().loadBacklinks('doc-1')
expect(useDocsStore.getState().backlinks).toEqual([backlink()])
expect(useDocsStore.getState().backlinksLoading).toBe(false)
})
it('drops an answer for a document the user has already left', async () => {
useDocsStore.setState({ openDoc: doc({ id: 'doc-9' }) })
api.backlinks.mockResolvedValue({ data: [backlink()] } as never)
await useDocsStore.getState().loadBacklinks('doc-1')
expect(useDocsStore.getState().backlinks).toEqual([])
})
it('stays quiet on a failure — the panel is a bonus, not the document', async () => {
useDocsStore.setState({ openDoc: doc() })
api.backlinks.mockRejectedValue(new Error('boom'))
await useDocsStore.getState().loadBacklinks('doc-1')
expect(useDocsStore.getState().backlinks).toEqual([])
expect(useDocsStore.getState().backlinksLoading).toBe(false)
expect(useDocsStore.getState().loadError).toBeNull()
})
it('leaves the document now on screen alone when an older request fails', async () => {
useDocsStore.setState({ openDoc: doc({ id: 'doc-2' }), backlinks: [backlink()] })
api.backlinks.mockRejectedValue(new Error('boom'))
await useDocsStore.getState().loadBacklinks('doc-1')
expect(useDocsStore.getState().backlinks).toEqual([backlink()])
})
})
describe('open', () => {
it('asks for the backlinks of the document it opened', async () => {
api.get.mockResolvedValue({ data: doc() } as never)
api.backlinks.mockResolvedValue({ data: [backlink()] } as never)
await useDocsStore.getState().open('doc-1')
// `open` does not await the backlinks; let the microtask queue drain.
await Promise.resolve()
await Promise.resolve()
expect(api.backlinks).toHaveBeenCalledWith('doc-1')
expect(useDocsStore.getState().backlinks).toEqual([backlink()])
})
it('clears the previous document’s history and backlinks first', async () => {
useDocsStore.setState({
revisions: [revision()],
revisionPreview: { revision: revision(), body: 'old' },
backlinks: [backlink()],
})
api.get.mockRejectedValue(new Error('boom'))
await useDocsStore.getState().open('doc-2')
expect(useDocsStore.getState().revisions).toEqual([])
expect(useDocsStore.getState().revisionPreview).toBeNull()
expect(useDocsStore.getState().backlinks).toEqual([])
})
})
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
backlinkIndex,
collectWikiLinks,
parseWikiLink,
resolveWikiLink,
@@ -116,24 +115,3 @@ describe('collectWikiLinks', () => {
expect(collectWikiLinks('# Heading\n\nplain text')).toEqual([])
})
})
describe('backlinkIndex', () => {
it('maps a document to the documents that link to it', () => {
const bodies = [
{ ...docs[0], body: 'see [[doc:vlan-plan]]' },
{ ...docs[1], body: 'no links' },
{ ...docs[2], body: 'also [[VLAN plan]]' },
]
expect(backlinkIndex(bodies)).toEqual({ d2: ['d1', 'd3'] })
})
it('ignores a document linking to itself', () => {
const bodies = [{ ...docs[1], body: 'see [[VLAN plan]]' }]
expect(backlinkIndex(bodies)).toEqual({})
})
it('does not list the same source twice', () => {
const bodies = [{ ...docs[0], body: '[[VLAN plan]] and [[doc:vlan-plan]]' }, { ...docs[1], body: '' }]
expect(backlinkIndex(bodies)).toEqual({ d2: ['d1'] })
})
})
@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react'
import { RotateCcw, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { collapseDiff, diffLines, diffStat } from '../diff'
import { Markdown } from '../markdown/Markdown'
import type { DocRevision } from '../types'
import type { LinkableDevice, LinkableDoc } from '../wikilinks'
/**
* Reading a document's history.
*
* The server has kept up to fifty revisions per document since the section
* shipped and every destructive action says so — regenerate in particular
* promises the old body "is in its history" — but nothing reached them. The
* rail lists what is there, and the preview answers the question a list cannot:
* what did this version actually say, and how does it differ from the one on
* screen.
*/
/** Why a revision was taken, said in the words the action used. */
const REASONS: Record<DocRevision['reason'], string> = {
edit: 'Saved',
restore: 'Restored',
import: 'Imported',
migrate: 'Migrated from notes',
scaffold: 'Generated',
regenerate: 'Regenerated',
}
function size(bytes: number): string {
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} kB`
}
interface RailProps {
revisions: DocRevision[]
activeId: string | null
loading: boolean
onSelect: (revisionId: string) => void
onClose: () => void
}
export function DocHistoryRail({ revisions, activeId, loading, onSelect, onClose }: RailProps) {
return (
<nav
aria-label="Document history"
className="flex w-60 shrink-0 flex-col overflow-y-auto border-l border-border"
>
<div className="flex items-center gap-1 px-3 py-4">
<p className="flex-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
History
</p>
<Button size="icon-xs" variant="ghost" aria-label="Close history" onClick={onClose} className="cursor-pointer">
<X />
</Button>
</div>
{loading && <p className="px-3 text-xs text-muted-foreground">Loading…</p>}
{!loading && revisions.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
No earlier version yet. One is kept every time you save a change.
</p>
)}
<ul className="pb-6">
{revisions.map((revision) => (
<li key={revision.id}>
<button
type="button"
onClick={() => onSelect(revision.id)}
title={formatTimestamp(revision.saved_at)}
aria-current={revision.id === activeId}
className={cn(
'w-full cursor-pointer px-3 py-1.5 text-left hover:bg-muted/60',
revision.id === activeId && 'bg-muted',
)}
>
<span className="block truncate text-xs text-foreground">
{REASONS[revision.reason] ?? revision.reason}
</span>
<span className="block truncate text-[10px] text-muted-foreground">
{formatRelative(revision.saved_at)} · {size(revision.size)}
</span>
</button>
</li>
))}
</ul>
</nav>
)
}
interface PreviewProps {
revision: DocRevision
body: string
currentBody: string
docs: LinkableDoc[]
devices: LinkableDevice[]
onOpenDoc: (id: string) => void
onRestore: () => void
onClose: () => void
}
export function RevisionPreview({
revision,
body,
currentBody,
docs,
devices,
onOpenDoc,
onRestore,
onClose,
}: PreviewProps) {
const [showChanges, setShowChanges] = useState(false)
// The diff reads old → new, so the current body is the "after" side: what the
// reader wants is "what happened since this version", not how to undo it.
const rows = useMemo(() => collapseDiff(diffLines(body, currentBody)), [body, currentBody])
const stat = useMemo(() => diffStat(diffLines(body, currentBody)), [body, currentBody])
const identical = stat.added === 0 && stat.removed === 0
return (
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-2 border-b border-border bg-[var(--surface,#161b22)] px-6 py-2.5">
<span className="text-xs text-foreground">
{REASONS[revision.reason] ?? revision.reason}{' '}
<span className="text-muted-foreground" title={formatTimestamp(revision.saved_at)}>
{formatRelative(revision.saved_at)}
</span>
</span>
<span className="text-[10px] text-muted-foreground">
{identical ? (
'Identical to the current version'
) : (
<>
<span className="text-[var(--status-online,#39d353)]">+{stat.added}</span>{' '}
<span className="text-[var(--status-offline,#f85149)]">−{stat.removed}</span> since
</>
)}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setShowChanges((on) => !on)}
className="cursor-pointer"
aria-pressed={showChanges}
>
{showChanges ? 'Read it' : 'Changes'}
</Button>
<Button size="sm" variant="ghost" onClick={onRestore} className="cursor-pointer gap-1">
<RotateCcw size={13} /> Restore
</Button>
<Button size="icon-xs" variant="ghost" aria-label="Back to the current version" onClick={onClose} className="cursor-pointer">
<X />
</Button>
</div>
</div>
{showChanges ? (
<div className="px-6 pb-16 pt-3 font-mono text-xs">
{rows.map((row, index) => {
if (row.kind === 'gap') {
return (
<p key={index} className="my-1 text-[10px] text-muted-foreground/60">
⋯ {row.text}
</p>
)
}
return (
<p
key={index}
className={cn(
'whitespace-pre-wrap break-words border-l-2 py-px pl-2',
row.kind === 'add' && 'border-[var(--status-online,#39d353)] bg-[var(--status-online,#39d353)]/10',
row.kind === 'del' &&
'border-[var(--status-offline,#f85149)] bg-[var(--status-offline,#f85149)]/10',
row.kind === 'same' && 'border-transparent text-muted-foreground',
)}
>
{row.kind === 'add' ? '+ ' : row.kind === 'del' ? '− ' : ' '}
{row.text || ' '}
</p>
)
})}
</div>
) : (
<Markdown
body={body}
docs={docs}
devices={devices}
onOpenDoc={onOpenDoc}
className="max-w-[72ch] px-6 pb-16 pt-2 text-sm"
/>
)}
</div>
)
}
@@ -1,19 +1,40 @@
import { useMemo, useState } from 'react'
import { Clock, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Clock, History, Link2, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { isOverdue, parseFrontmatter } from '../frontmatter'
import { Markdown } from '../markdown/Markdown'
import { extractToc } from '../markdown/toc'
import type { Doc } from '../types'
import type { Doc, DocBacklink, DocRevision } from '../types'
import type { LinkableDevice, LinkableDoc } from '../wikilinks'
import { DocHistoryRail, RevisionPreview } from './DocHistory'
/** Everything the history rail and the revision preview need, in one prop. */
export interface HistoryControls {
open: boolean
loading: boolean
revisions: DocRevision[]
preview: { revision: DocRevision; body: string } | null
onToggle: () => void
onSelect: (revisionId: string) => void
onClosePreview: () => void
onRestore: (revisionId: string) => void
}
interface Props {
doc: Doc
docs: LinkableDoc[]
devices: LinkableDevice[]
drifted: boolean
/** The documents linking here. Empty until the server answers. */
backlinks?: DocBacklink[]
backlinksLoading?: boolean
/**
* Omitted by a host that keeps no history state, and then the viewer offers
* none — the button would have nothing to open.
*/
history?: HistoryControls
onEdit: () => void
onToggleStar: () => void
onMarkReviewed: () => void
@@ -26,6 +47,17 @@ interface Props {
onSetTags: (tags: string[]) => void
}
const NO_HISTORY: HistoryControls = {
open: false,
loading: false,
revisions: [],
preview: null,
onToggle: () => {},
onSelect: () => {},
onClosePreview: () => {},
onRestore: () => {},
}
/** The metadata a frontmatter block is worth surfacing as a chip. */
const CHIPS: { key: string; label: string }[] = [
{ key: 'criticality', label: 'Criticality' },
@@ -38,6 +70,9 @@ export function DocViewer({
docs,
devices,
drifted,
backlinks = [],
backlinksLoading = false,
history,
onEdit,
onToggleStar,
onMarkReviewed,
@@ -48,6 +83,7 @@ export function DocViewer({
onToggleTask,
onSetTags,
}: Props) {
const controls = history ?? NO_HISTORY
const { data } = useMemo(() => parseFrontmatter(doc.body), [doc.body])
const [tagDraft, setTagDraft] = useState<string | null>(null)
const toc = useMemo(() => extractToc(doc.body), [doc.body])
@@ -68,6 +104,36 @@ export function DocViewer({
setTagDraft(null)
}
// Reading an old version replaces the body, not the page: the title, the
// chips and the history rail stay put, so it reads as the same document at a
// different moment rather than as somewhere else.
const preview = controls.preview
if (preview) {
return (
<div className="flex min-h-0 flex-1">
<RevisionPreview
revision={preview.revision}
body={preview.body}
currentBody={doc.body}
docs={docs}
devices={devices}
onOpenDoc={onOpenDoc}
onRestore={() => controls.onRestore(preview.revision.id)}
onClose={controls.onClosePreview}
/>
{controls.open && (
<DocHistoryRail
revisions={controls.revisions}
activeId={preview.revision.id}
loading={controls.loading}
onSelect={controls.onSelect}
onClose={controls.onToggle}
/>
)}
</div>
)
}
return (
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-1 overflow-y-auto">
@@ -86,6 +152,19 @@ export function DocViewer({
<Button size="sm" variant="ghost" onClick={onEdit} className="cursor-pointer gap-1">
<Pencil size={13} /> Edit
</Button>
{history && (
<Button
size="icon-xs"
variant="ghost"
title="Earlier versions of this document"
aria-label="Version history"
aria-pressed={history.open}
onClick={history.onToggle}
className={cn('cursor-pointer', history.open && 'bg-muted')}
>
<History />
</Button>
)}
{/* A folder holds children, not a generated body — nothing to rebuild. */}
{doc.kind !== 'folder' && (
<Button
@@ -174,11 +253,54 @@ export function DocViewer({
onOpenDoc={onOpenDoc}
onCreateFromLink={onCreateFromLink}
onToggleTask={onToggleTask}
className="max-w-[72ch] px-6 pb-16 pt-2 text-sm"
className="max-w-[72ch] px-6 pb-6 pt-2 text-sm"
/>
{/* A wiki-link only says where it goes; this is the other direction,
and the reason a device document is worth linking to at all. */}
{!backlinksLoading && backlinks.length > 0 && (
<section aria-labelledby="backlinks-heading" className="max-w-[72ch] px-6 pb-16">
<h2
id="backlinks-heading"
className="mb-2 flex items-center gap-1.5 border-t border-border pt-4 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70"
>
<Link2 size={11} /> Linked from ({backlinks.length})
</h2>
<ul className="space-y-1">
{backlinks.map((link) => (
<li key={link.doc_id}>
<button
type="button"
onClick={() => onOpenDoc(link.doc_id)}
className="w-full cursor-pointer rounded px-2 py-1.5 text-left hover:bg-muted/60"
>
<span className="flex items-baseline gap-1.5">
<span className="truncate text-xs text-primary">{link.title}</span>
{link.count > 1 && (
<span className="text-[10px] text-muted-foreground">×{link.count}</span>
)}
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{link.context}
</span>
</button>
</li>
))}
</ul>
</section>
)}
</div>
{toc.length > 1 && (
{controls.open ? (
<DocHistoryRail
revisions={controls.revisions}
activeId={null}
loading={controls.loading}
onSelect={controls.onSelect}
onClose={controls.onToggle}
/>
) : (
toc.length > 1 && (
<nav aria-label="On this page" className="hidden w-52 shrink-0 overflow-y-auto border-l border-border px-3 py-5 xl:block">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
On this page
@@ -193,7 +315,8 @@ export function DocViewer({
{entry.text}
</a>
))}
</nav>
</nav>
)
)}
</div>
)
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import type { InventoryEntry } from '@/types'
import { cn } from '@/lib/utils'
import { formatRelative } from '@/utils/timeFormat'
import { isOverdue } from '../frontmatter'
import { driftedIds, useDocsStore } from '../store'
import {
@@ -62,6 +63,15 @@ export function DocumentationView() {
markReviewed,
setTags,
regenerate,
revisions,
revisionsLoading,
revisionPreview,
loadRevisions,
previewRevision,
closeRevisionPreview,
restore,
backlinks,
backlinksLoading,
coverage,
loadCoverage,
scaffold,
@@ -84,6 +94,7 @@ export function DocumentationView() {
const [libraryOpen, setLibraryOpen] = useState(true)
const [regenerateOpen, setRegenerateOpen] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
useEffect(() => {
void loadDocs()
@@ -236,6 +247,38 @@ export function DocumentationView() {
toast.success('Document regenerated — the old body is in its history')
}, [openDoc, regenerate])
// The rail is loaded when it is opened, and again whenever the document it is
// showing changes underneath it — a save adds a revision to the list.
const openDocId = openDoc?.id
const openDocSavedAt = openDoc?.updated_at
useEffect(() => {
if (!historyOpen || !openDocId) return
void loadRevisions(openDocId)
}, [historyOpen, openDocId, openDocSavedAt, loadRevisions])
const handleToggleHistory = useCallback(() => {
setHistoryOpen((open) => {
// Closing the rail leaves the version being read; there would be no way
// back to the current body otherwise.
if (open) closeRevisionPreview()
return !open
})
}, [closeRevisionPreview])
const handleRestore = useCallback(
async (revisionId: string) => {
if (!openDoc) return
const revision = revisions.find((r) => r.id === revisionId)
const when = revision ? formatRelative(revision.saved_at) : 'that version'
if (!window.confirm(`Restore the version from ${when}? The current body is saved to the history first.`)) {
return
}
await restore(openDoc.id, revisionId)
toast.success('Version restored — the body it replaced is in the history')
},
[openDoc, restore, revisions],
)
const handleMigrate = useCallback(async () => {
const created = await scaffold({ onlyWithNotes: true })
await loadDocs()
@@ -468,6 +511,18 @@ export function DocumentationView() {
docs={docs}
devices={linkableDevices}
drifted={openDoc.drifted ?? false}
backlinks={backlinks}
backlinksLoading={backlinksLoading}
history={{
open: historyOpen,
loading: revisionsLoading,
revisions,
preview: revisionPreview,
onToggle: handleToggleHistory,
onSelect: (id) => void previewRevision(id),
onClosePreview: closeRevisionPreview,
onRestore: (id) => void handleRestore(id),
}}
onEdit={startEdit}
onToggleStar={() => void toggleStar(openDoc.id)}
onMarkReviewed={() => void markReviewed(openDoc.id)}
+140
View File
@@ -0,0 +1,140 @@
/**
* A line diff between two document bodies.
*
* History is only useful if you can see what a version actually changed, and
* "restore and compare afterwards" is not that. This is deliberately small: no
* word-level diff, no library — a document is prose in lines, and lines are the
* unit a writer thinks in.
*
* The matching is a plain LCS over the lines that differ, after the common head
* and tail are trimmed off. That trim is what keeps it cheap: a typical edit
* touches a paragraph in the middle of a long document, so the matrix is built
* over a handful of lines rather than the whole file. `MAX_CELLS` catches the
* pathological case — two long, wholly different bodies — where the answer is
* "all of it changed" anyway.
*/
export type DiffKind = 'same' | 'add' | 'del'
export interface DiffLine {
kind: DiffKind
text: string
}
/** Above this many LCS cells the diff degrades to "replaced wholesale". */
const MAX_CELLS = 250_000
function split(body: string): string[] {
return (body ?? '').split('\n')
}
function lcs(before: string[], after: string[]): DiffLine[] {
// table[i][j] = length of the longest common subsequence of the suffixes.
const table: number[][] = Array.from({ length: before.length + 1 }, () =>
new Array<number>(after.length + 1).fill(0),
)
for (let i = before.length - 1; i >= 0; i--) {
for (let j = after.length - 1; j >= 0; j--) {
table[i][j] =
before[i] === after[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1])
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < before.length && j < after.length) {
if (before[i] === after[j]) {
out.push({ kind: 'same', text: before[i] })
i++
j++
} else if (table[i + 1][j] >= table[i][j + 1]) {
out.push({ kind: 'del', text: before[i] })
i++
} else {
out.push({ kind: 'add', text: after[j] })
j++
}
}
while (i < before.length) out.push({ kind: 'del', text: before[i++] })
while (j < after.length) out.push({ kind: 'add', text: after[j++] })
return out
}
/** Every line of both bodies, tagged with what happened to it. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = split(before)
const b = split(after)
let head = 0
while (head < a.length && head < b.length && a[head] === b[head]) head++
let tail = 0
while (
tail < a.length - head &&
tail < b.length - head &&
a[a.length - 1 - tail] === b[b.length - 1 - tail]
) {
tail++
}
const middleA = a.slice(head, a.length - tail)
const middleB = b.slice(head, b.length - tail)
const middle =
middleA.length * middleB.length > MAX_CELLS
? [
...middleA.map((text): DiffLine => ({ kind: 'del', text })),
...middleB.map((text): DiffLine => ({ kind: 'add', text })),
]
: lcs(middleA, middleB)
return [
...a.slice(0, head).map((text): DiffLine => ({ kind: 'same', text })),
...middle,
...a.slice(a.length - tail).map((text): DiffLine => ({ kind: 'same', text })),
]
}
/** How many lines the change added and removed, for the one-line summary. */
export function diffStat(lines: DiffLine[]): { added: number; removed: number } {
return {
added: lines.filter((line) => line.kind === 'add').length,
removed: lines.filter((line) => line.kind === 'del').length,
}
}
/**
* The diff with long runs of unchanged lines collapsed to `context` on each
* side of a change, so a one-line edit in a long document reads as one hunk.
* A collapsed run is reported as a gap rather than dropped silently.
*/
export type DiffRow = DiffLine | { kind: 'gap'; text: string; skipped: number }
export function collapseDiff(lines: DiffLine[], context = 3): DiffRow[] {
const keep = new Array<boolean>(lines.length).fill(false)
lines.forEach((line, index) => {
if (line.kind === 'same') return
for (let i = Math.max(0, index - context); i <= Math.min(lines.length - 1, index + context); i++) {
keep[i] = true
}
})
const rows: DiffRow[] = []
let skipped = 0
lines.forEach((line, index) => {
if (keep[index]) {
if (skipped) {
rows.push({ kind: 'gap', text: `${skipped} unchanged line${skipped > 1 ? 's' : ''}`, skipped })
skipped = 0
}
rows.push(line)
} else {
skipped++
}
})
if (skipped) {
rows.push({ kind: 'gap', text: `${skipped} unchanged line${skipped > 1 ? 's' : ''}`, skipped })
}
return rows
}
+88 -4
View File
@@ -5,6 +5,7 @@ import { isOverdue, withTags } from './frontmatter'
import { isDescendant } from './tree'
import type {
Doc,
DocBacklink,
DocCoverage,
DocRevision,
DocSearchResult,
@@ -109,6 +110,14 @@ export interface DocsState {
pendingDraft: string | null
revisions: DocRevision[]
revisionsLoading: boolean
/** A revision being read, alongside the current body. Null when not reading one. */
revisionPreview: { revision: DocRevision; body: string } | null
/** The documents linking to the open one. Inverted server-side. */
backlinks: DocBacklink[]
backlinksLoading: boolean
coverage: DocCoverage | null
search: DocSearchResult | null
searching: boolean
@@ -149,9 +158,12 @@ export interface DocsState {
remove: (id: string) => Promise<void>
loadRevisions: (id: string) => Promise<void>
previewRevision: (revisionId: string) => Promise<void>
closeRevisionPreview: () => void
restore: (id: string, revisionId: string) => Promise<void>
regenerate: (id: string) => Promise<boolean>
loadBacklinks: (id: string) => Promise<void>
loadCoverage: () => Promise<void>
scaffold: (input: { deviceIds?: string[]; onlyWithNotes?: boolean }) => Promise<number>
runSearch: (query: string) => Promise<void>
@@ -186,6 +198,12 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
pendingDraft: null,
revisions: [],
revisionsLoading: false,
revisionPreview: null,
backlinks: [],
backlinksLoading: false,
coverage: null,
search: null,
searching: false,
@@ -212,7 +230,16 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
},
open: async (id) => {
set({ openLoading: true, draft: null, dirty: false, pendingDraft: null, revisions: [] })
set({
openLoading: true,
draft: null,
dirty: false,
pendingDraft: null,
revisions: [],
revisionsLoading: false,
revisionPreview: null,
backlinks: [],
})
try {
const { data } = await documentsApi.get(id)
// A draft newer than the stored document is unsaved work from a previous
@@ -226,6 +253,8 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
})
if (draft && stale) clearDraft(id)
writeUi({ ...readUi(), lastDocId: id })
// Not awaited: the document renders now, the "Linked from" block fills in.
void get().loadBacklinks(id)
} catch (error) {
set({ openLoading: false, loadError: message(error, 'Could not open that document') })
}
@@ -254,7 +283,17 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
return true
},
close: () => set({ openDoc: null, draft: null, dirty: false, pendingDraft: null, revisions: [] }),
close: () =>
set({
openDoc: null,
draft: null,
dirty: false,
pendingDraft: null,
revisions: [],
revisionsLoading: false,
revisionPreview: null,
backlinks: [],
}),
startEdit: () => {
const doc = get().openDoc
@@ -400,10 +439,33 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
},
loadRevisions: async (id) => {
const { data } = await documentsApi.revisions(id)
set({ revisions: data })
set({ revisionsLoading: true })
try {
const { data } = await documentsApi.revisions(id)
if (get().openDoc?.id !== id) return
set({ revisions: data, revisionsLoading: false })
} catch (error) {
if (get().openDoc?.id !== id) return
set({ revisionsLoading: false, loadError: message(error, 'Could not load the history') })
}
},
// A revision's body is fetched on demand rather than with the list: the list
// is what the history panel shows, and fifty bodies to render one of them is
// the whole reason `RevisionSummary` carries a size instead of the text.
previewRevision: async (revisionId) => {
const revision = get().revisions.find((r) => r.id === revisionId)
if (!revision) return
try {
const { data } = await documentsApi.revision(revisionId)
set({ revisionPreview: { revision, body: data.body } })
} catch (error) {
set({ loadError: message(error, 'Could not read that version') })
}
},
closeRevisionPreview: () => set({ revisionPreview: null }),
restore: async (id, revisionId) => {
const { data } = await documentsApi.restore(id, revisionId)
clearDraft(id)
@@ -411,6 +473,9 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
openDoc: data,
draft: state.draft === null ? null : data.body,
dirty: false,
// The restored body is now the current one; there is nothing left to
// compare it against, so the preview closes rather than showing itself.
revisionPreview: null,
docs: state.docs.map((d) => (d.id === id ? { ...d, ...data } : d)),
}))
await get().loadRevisions(id)
@@ -437,6 +502,25 @@ export const useDocsStore = create<DocsState>()((set, get) => ({
}
},
// Backlinks are the server's answer because the browser holds no bodies but
// its own: `list()` is metadata-only so the tree can badge without a download.
loadBacklinks: async (id) => {
if (STANDALONE) return
set({ backlinksLoading: true })
try {
const { data } = await documentsApi.backlinks(id)
// A slow answer for a document the user has already left is dropped
// rather than shown under the new one.
if (get().openDoc?.id !== id) return
set({ backlinks: data, backlinksLoading: false })
} catch {
// Backlinks are a bonus panel; a failure must not break reading. A failure
// for a document already left must not wipe the one now on screen either.
if (get().openDoc?.id !== id) return
set({ backlinks: [], backlinksLoading: false })
}
},
loadCoverage: async () => {
if (STANDALONE) return
try {
+12
View File
@@ -51,6 +51,18 @@ export interface DocRevision {
size: number
}
/** A document pointing at the open one. Inverted server-side — see the route. */
export interface DocBacklink {
doc_id: string
title: string
kind: DocKind
device_id?: string | null
/** The link as it was written, which may differ from the target's title. */
label: string
context: string
count: number
}
export interface DocSearchHit {
doc_id: string
title: string
+6 -18
View File
@@ -4,6 +4,11 @@
* Parsed as a plain text pass rather than a remark plugin: the syntax is one
* token with no nesting, and keeping it out of the AST pipeline means the
* markdown stays ordinary markdown for anything that reads the file elsewhere.
*
* This is the forward direction only — where a link goes. The reverse ("what
* links here") lives in `backend/app/services/doc_backlinks.py`, which mirrors
* the rules below, because it needs every body and the browser holds none but
* the open one. Change a rule here and change it there.
*/
export type WikiTarget = 'device' | 'doc' | 'node'
@@ -55,7 +60,7 @@ export function splitWikiLinks(text: string): Segment[] {
return segments
}
/** Every wiki-link in a body, for the backlinks index. */
/** Every wiki-link in a body. */
export function collectWikiLinks(body: string): WikiLink[] {
const found: WikiLink[] = []
LINK.lastIndex = 0
@@ -104,20 +109,3 @@ export function resolveWikiLink(
null
)
}
/** doc id → the documents that link to it. */
export function backlinkIndex(
docs: (LinkableDoc & { body: string })[],
devices: LinkableDevice[] = [],
): Record<string, string[]> {
const index: Record<string, string[]> = {}
for (const doc of docs) {
for (const link of collectWikiLinks(doc.body)) {
const targetId = resolveWikiLink(link, docs, devices)
if (!targetId || targetId === doc.id) continue
const current = index[targetId] ?? []
if (!current.includes(doc.id)) index[targetId] = [...current, doc.id]
}
}
return index
}