From 761a1a526ca033c198fbffc0bfccd43fa1f72d11 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 7 Sep 2026 12:10:19 +0200 Subject: [PATCH] feat(docs): reach a document's history, and see what links to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes/documents.py | 51 +++- backend/app/schemas/documents.py | 14 + backend/app/services/doc_backlinks.py | 158 +++++++++++ backend/tests/test_doc_backlinks.py | 254 +++++++++++++++++ frontend/src/api/client.ts | 2 + .../__tests__/DocHistory.test.tsx | 203 +++++++++++++ .../src/documentation/__tests__/diff.test.ts | 88 ++++++ .../__tests__/storeHistory.test.ts | 266 ++++++++++++++++++ .../documentation/__tests__/wikilinks.test.ts | 22 -- .../documentation/components/DocHistory.tsx | 198 +++++++++++++ .../documentation/components/DocViewer.tsx | 133 ++++++++- .../components/DocumentationView.tsx | 55 ++++ frontend/src/documentation/diff.ts | 140 +++++++++ frontend/src/documentation/store.ts | 92 +++++- frontend/src/documentation/types.ts | 12 + frontend/src/documentation/wikilinks.ts | 24 +- 16 files changed, 1662 insertions(+), 50 deletions(-) create mode 100644 backend/app/services/doc_backlinks.py create mode 100644 backend/tests/test_doc_backlinks.py create mode 100644 frontend/src/documentation/__tests__/DocHistory.test.tsx create mode 100644 frontend/src/documentation/__tests__/diff.test.ts create mode 100644 frontend/src/documentation/__tests__/storeHistory.test.ts create mode 100644 frontend/src/documentation/components/DocHistory.tsx create mode 100644 frontend/src/documentation/diff.ts diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py index d44690d..45c9030 100644 --- a/backend/app/api/routes/documents.py +++ b/backend/app/api/routes/documents.py @@ -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, diff --git a/backend/app/schemas/documents.py b/backend/app/schemas/documents.py index c76892b..da5a682 100644 --- a/backend/app/schemas/documents.py +++ b/backend/app/schemas/documents.py @@ -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. diff --git a/backend/app/services/doc_backlinks.py b/backend/app/services/doc_backlinks.py new file mode 100644 index 0000000..1ef315b --- /dev/null +++ b/backend/app/services/doc_backlinks.py @@ -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 "") + ) diff --git a/backend/tests/test_doc_backlinks.py b/backend/tests/test_doc_backlinks.py new file mode 100644 index 0000000..55e345a --- /dev/null +++ b/backend/tests/test_doc_backlinks.py @@ -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 == [] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 85e8cba..7b9b857 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -337,6 +337,8 @@ export const documentsApi = { ), restore: (id: string, revisionId: string) => api.post(`/documents/${id}/revisions/${revisionId}/restore`), + backlinks: (id: string) => + api.get(`/documents/${id}/backlinks`), regenerate: (id: string) => api.post(`/documents/${id}/regenerate`), search: (q: string, limit = 25) => diff --git a/frontend/src/documentation/__tests__/DocHistory.test.tsx b/frontend/src/documentation/__tests__/DocHistory.test.tsx new file mode 100644 index 0000000..34b8139 --- /dev/null +++ b/frontend/src/documentation/__tests__/DocHistory.test.tsx @@ -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 { + 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 { + 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 { + 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 { + 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() + expect(screen.queryByLabelText('Version history')).toBeNull() + }) + + it('opens the rail from the header button', () => { + const history = controls() + render() + + fireEvent.click(screen.getByLabelText('Version history')) + + expect(history.onToggle).toHaveBeenCalled() + }) + + it('says the button is pressed while the rail is open', () => { + render() + 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() + + 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() + 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() + + 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() + + 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() + + // 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() + + expect(screen.getByText('Identical to the current version')).toBeTruthy() + }) +}) + +describe('DocViewer — backlinks', () => { + it('lists what links here, with the line it links from', () => { + render() + + 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() + + 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() + + expect(screen.getAllByText('Backup runbook')).toHaveLength(1) + expect(screen.getByText('×3')).toBeTruthy() + }) + + it('shows nothing at all when nothing links here', () => { + render() + expect(screen.queryByText(/Linked from/)).toBeNull() + }) + + it('waits for the answer rather than claiming nothing links here', () => { + render() + expect(screen.queryByText(/Linked from/)).toBeNull() + }) +}) diff --git a/frontend/src/documentation/__tests__/diff.test.ts b/frontend/src/documentation/__tests__/diff.test.ts new file mode 100644 index 0000000..70a238e --- /dev/null +++ b/frontend/src/documentation/__tests__/diff.test.ts @@ -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 }]) + }) +}) diff --git a/frontend/src/documentation/__tests__/storeHistory.test.ts b/frontend/src/documentation/__tests__/storeHistory.test.ts new file mode 100644 index 0000000..87c0db8 --- /dev/null +++ b/frontend/src/documentation/__tests__/storeHistory.test.ts @@ -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 { + 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 { + 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 { + 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([]) + }) +}) diff --git a/frontend/src/documentation/__tests__/wikilinks.test.ts b/frontend/src/documentation/__tests__/wikilinks.test.ts index e070ddd..f48404e 100644 --- a/frontend/src/documentation/__tests__/wikilinks.test.ts +++ b/frontend/src/documentation/__tests__/wikilinks.test.ts @@ -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'] }) - }) -}) diff --git a/frontend/src/documentation/components/DocHistory.tsx b/frontend/src/documentation/components/DocHistory.tsx new file mode 100644 index 0000000..6a4c118 --- /dev/null +++ b/frontend/src/documentation/components/DocHistory.tsx @@ -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 = { + 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 ( + + ) +} + +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 ( +
+
+ + {REASONS[revision.reason] ?? revision.reason}{' '} + + {formatRelative(revision.saved_at)} + + + + {identical ? ( + 'Identical to the current version' + ) : ( + <> + +{stat.added}{' '} + −{stat.removed} since + + )} + +
+ + + +
+
+ + {showChanges ? ( +
+ {rows.map((row, index) => { + if (row.kind === 'gap') { + return ( +

+ ⋯ {row.text} +

+ ) + } + return ( +

+ {row.kind === 'add' ? '+ ' : row.kind === 'del' ? '− ' : ' '} + {row.text || ' '} +

+ ) + })} +
+ ) : ( + + )} +
+ ) +} diff --git a/frontend/src/documentation/components/DocViewer.tsx b/frontend/src/documentation/components/DocViewer.tsx index 1b1e10b..e408ad4 100644 --- a/frontend/src/documentation/components/DocViewer.tsx +++ b/frontend/src/documentation/components/DocViewer.tsx @@ -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(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 ( +
+ controls.onRestore(preview.revision.id)} + onClose={controls.onClosePreview} + /> + {controls.open && ( + + )} +
+ ) + } + return (
@@ -86,6 +152,19 @@ export function DocViewer({ + {history && ( + + )} {/* A folder holds children, not a generated body — nothing to rebuild. */} {doc.kind !== 'folder' && ( + + ))} + + + )}
- {toc.length > 1 && ( + {controls.open ? ( + + ) : ( + toc.length > 1 && ( + + ) )}
) diff --git a/frontend/src/documentation/components/DocumentationView.tsx b/frontend/src/documentation/components/DocumentationView.tsx index 37c3b29..28ad085 100644 --- a/frontend/src/documentation/components/DocumentationView.tsx +++ b/frontend/src/documentation/components/DocumentationView.tsx @@ -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)} diff --git a/frontend/src/documentation/diff.ts b/frontend/src/documentation/diff.ts new file mode 100644 index 0000000..691aae0 --- /dev/null +++ b/frontend/src/documentation/diff.ts @@ -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(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(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 +} diff --git a/frontend/src/documentation/store.ts b/frontend/src/documentation/store.ts index 3766201..e88073a 100644 --- a/frontend/src/documentation/store.ts +++ b/frontend/src/documentation/store.ts @@ -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 loadRevisions: (id: string) => Promise + previewRevision: (revisionId: string) => Promise + closeRevisionPreview: () => void restore: (id: string, revisionId: string) => Promise regenerate: (id: string) => Promise + loadBacklinks: (id: string) => Promise loadCoverage: () => Promise scaffold: (input: { deviceIds?: string[]; onlyWithNotes?: boolean }) => Promise runSearch: (query: string) => Promise @@ -186,6 +198,12 @@ export const useDocsStore = create()((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()((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()((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()((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()((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()((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()((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 { diff --git a/frontend/src/documentation/types.ts b/frontend/src/documentation/types.ts index 79121a3..87cb55f 100644 --- a/frontend/src/documentation/types.ts +++ b/frontend/src/documentation/types.ts @@ -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 diff --git a/frontend/src/documentation/wikilinks.ts b/frontend/src/documentation/wikilinks.ts index a5c0916..8663b5f 100644 --- a/frontend/src/documentation/wikilinks.ts +++ b/frontend/src/documentation/wikilinks.ts @@ -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 { - const index: Record = {} - 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 -}