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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
# driver with `docket init`; without it git reports an ordinary conflict.
.docket/ledger.jsonl merge=docket
.docket/features.jsonl merge=docket
docket/web/vendor/** -diff linguist-vendored
# Built from web/ by `just web-build`; CI checks it matches the source.
docket/web/dist/** -diff linguist-generated eol=lf
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,39 @@ jobs:
go test ./...
go vet ./...

web:
name: Web view tests and committed build
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
working-directory: web
run: npm ci
- name: Test
working-directory: web
run: npm test
# Installs run the committed docket/web/dist without Node, so it must be
# exactly what the source builds.
- name: Build and compare with the committed bundle
working-directory: web
run: |
npm run build
git diff --exit-code -- ../docket/web/dist
test -z "$(git status --porcelain -- ../docket/web/dist)"
- name: Check the bundle budget
run: |
total=0
for f in docket/web/dist/assets/*; do total=$((total + $(gzip -c "$f" | wc -c))); done
echo "gzipped bundle: $total bytes"
test "$total" -le 768000

cross-build:
name: Cross-build release targets
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ venv/
.coverage.*
htmlcov/

# Web view source dependencies; the built bundle in docket/web/dist is committed.
web/node_modules/

# Secrets. docket construct reads a provider key out of a project's .env, so
# the file is expected to exist and must never be committed.
.env
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- `docket graph --web` is a new page with two views. Timeline, the default, lists records by time with git branches drawn as lanes and link chips in place of arrows; pills at the top and bottom edge point to linked records scrolled out of view. Graph draws readable cards laid out by ELK, with right-angled edges, and lists records with no relation beside the canvas. "Show lineage" opens one record's supersession chain and direct links as a fitted subgraph.
- The page searches all records with ⌘K or Ctrl+K, keeps the view, lineage, selection, direction and filters in the URL, and has light, dark and system themes.

### Removed

- The web view's Force, Radial and Circular engines, its grouping, and its focus and hops controls; lineage replaces focus. `docket export` keeps `--group`, `--focus`, `--hops` and `--include-orphans`.
- `/api/graph` no longer returns DOT and ignores query parameters. The vendored Graphviz WASM is no longer shipped.

## [0.27.0] - 2026-10-07

### Added
Expand Down
47 changes: 14 additions & 33 deletions docket/cli/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,39 +42,23 @@ def web_conflict(args: argparse.Namespace) -> str | None:
return None


def _filtered(args: argparse.Namespace) -> bool:
# Unfiltered, the page needs retired records too, which selection() leaves
# out unless asked; so it gets every record and no shown list.
return any(getattr(args, flag, None) for flag in ("where", "kind", "state", "find"))


def _payload(args: argparse.Namespace, params: dict[str, str]) -> bytes:
# Read on every request: the page polls, and a ledger of a few hundred
# records reads and renders in milliseconds.
from docket.graph_export import to_dot
from docket.graph_layout import GROUPS
# records reads in milliseconds. The page lays the graph out itself, so
# query parameters sent by older pages are ignored.
from docket.ledger import project
from docket.web.server import BadRequest

group = params.get("group", "none")
if group not in GROUPS:
raise BadRequest("group must be none, kind or scope")
hops = params.get("hops", "2")
if not (hops.isascii() and hops.isdigit() and 1 <= int(hops) <= 4):
raise BadRequest("hops must be a whole number from 1 to 4")
orphans = params.get("orphans", "show")
if orphans not in ("show", "hide"):
raise BadRequest("orphans must be show or hide")
_, shown, superseded = selection(args)

_, shown, _ = selection(args)
records = project(env.read(env.ledger_path()), validated=True)
try:
dot = to_dot(
shown,
superseded=superseded,
group=group,
focus=params.get("focus") or None,
hops=int(hops),
orphans=orphans == "show",
)
except ValueError as exc:
raise BadRequest(str(exc)) from exc
body = {
"dot": dot,
"records": records,
"shown": [e["id"] for e in shown] if _filtered(args) else None,
"title": env.project_root().name,
}
return json.dumps(body, ensure_ascii=False).encode("utf-8")
Expand Down Expand Up @@ -145,12 +129,9 @@ def cmd_graph_web(args: argparse.Namespace) -> int:

from docket.web import server as web_server

root = web_server.WEB_ROOT
missing = [
str(root / name) for name in web_server.ASSETS.values() if not (root / name).is_file()
]
if missing:
print(f"docket: web view files missing: {', '.join(missing)}", file=sys.stderr)
index = web_server.DIST / "index.html"
if not index.is_file():
print(f"docket: web view files missing: {index}; run `just web-build`", file=sys.stderr)
return 1
selection(args) # a bad --where fails here, before anything binds
port = DEFAULT_PORT if args.port is None else args.port
Expand Down
Loading
Loading