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:
committed by
Pouzor - Rémy Jardient
parent
080de69543
commit
761a1a526c
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 "")
|
||||
)
|
||||
@@ -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 == []
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user