diff --git a/.gitignore b/.gitignore
index baa72cf..02db46d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,6 @@ bench/
# edits applied through ccc's edit tools, read back by `ccc prompts`
/.ccc/edits.jsonl
+
+# timeline steps the ccc servers on this project share
+/.ccc/timeline.jsonl
diff --git a/README.md b/README.md
index 5db7324..1a9050b 100644
--- a/README.md
+++ b/README.md
@@ -2,22 +2,35 @@
[](https://github.com/colwill/ccc/actions/workflows/ccc-release.yaml)
+
# Collateral Code Check (`ccc`)
-Collateral Code Check tells you, and your AI agent, what a change touches before you commit it - the functions, tests, services and cross-service contracts it reaches - by:
+What is becoming obvious is that understanding the changes being made by LLMs/Agents to projects is an increasingly complex problem.
- - highlighting which tests will be ran with your changes
+`ccc` reduces that problem by providing tools to understand code, visualise changes for humans and allow LLMs/Agents to make the changes they need to with significant oversight by the human in charge.
+
+## So what is ccc?
+
+Collateral Code Check - (collateral as-in collateral damage) shows everything a change touches (before you commit it!) visually in your IDE, in your pipeline or anywhere else you need to see it. This includes; functions, tests, services and cross-service contracts it reaches.
+
+It does this by:
+
+ - Highlighting what pipeline tests run with your changes
- - showing if your change violates language linting rules
+ - Showing if your change violates language linting rules
+
+ - Showing new or modified cross-service calls before you commit
- - showing when you create or modify cross-service calls before you commit
+ - Provides agents/models with accurate real-time insights
- - providing language models with accurate real-time insights into your changes
+ - Provides agents/models with restricted edit controls (no sed, sudo etc)
- - triggering specific testing tools based on your changes
+ - Visualise changes from agents/models before they are applied with the visualiser.
- - making your agent's changes for it - a project-wide rename, a dead-code delete, a new function beside an old one - as one confined, previewed, revertible call that costs a fraction of the tokens read-then-edit does with an extra layer of safety ([how much](#edits-go-through-ccc))
+Additionally, benefits for heavy agent/model usage are:
+
+- Project-wide rename, dead-code delete, new functions beside an old one are a single confined, previewed and revertible atomic call that costs a fraction of the tokens read-then-edit does with an extra layer of safety ([more info](#edits-go-through-ccc))
**ccc** stands on the shoulders of [Tree-Sitter](https://github.com/tree-sitter/tree-sitter). It scans a project and builds the **ccc** code map in memory.
This is a human and machine readable map of the source tree including every source file; its
@@ -149,8 +162,7 @@ ccc insights --html page.html # output format is html, as a single page
`ccc run --vis` serves a 2D map of the project at `http://127.0.0.1:6767/vis` and opens it in your
browser. It follows the C4 model from the whole system down to the code, then goes one level
-further: the logic of a single function, drawn as a node graph in the style of Unreal Engine
-Blueprints.
+further: the logic of a single function, drawn as a node graph.
| level | boxes | arrows |
|---|---|---|
@@ -171,6 +183,23 @@ is a call the resolver found evidence for, and a branch is a node in the syntax
anything was seen to take. Calls that reach nothing in the project (the standard library, a
dependency) are folded into grey pills, and the toolbar can show them in full or hide them.
+Along the foot of the page runs the timeline of what agents did through ccc - the same in a browser
+as in the editor:
+
+- **A definition at a time.** An edit is split into the definitions it touches - imports first,
+ then what it adds, then what it changes, then what it removes - so a replay builds the change up
+ one function, type or constant at a time, in the order the agent staged it.
+- **Looks too.** `find`, `references`, `file` and `dependencies` calls land as looks at what they
+ named, so you can see what an agent read before it wrote anything.
+- **At your pace.** Live keeps the time between steps as it was; ½×, ¼×, ⅛× and ⅒× stretch it, and
+ following runs behind by however many steps are still waiting.
+- **One step on its own.** While a step is on show (◎), what it did not touch fades back, and a big
+ scene draws only what is wired to it, laid out together to fit the screen. Click the canvas to
+ bring the rest back.
+
+Every ccc server on a project shares its steps through `.ccc/timeline.jsonl` (kept out of git), so
+the visualiser shows an agent's work whichever server the agent talks to.
+
The data behind the page is plain JSON, with or without `--vis`:
```sh
diff --git a/assets/vis/vis.css b/assets/vis/vis.css
index 8aa8f4b..2ba7ddd 100644
--- a/assets/vis/vis.css
+++ b/assets/vis/vis.css
@@ -139,12 +139,18 @@ button, input { font: inherit; color: inherit; }
.brand {
display: flex;
- align-items: baseline;
- gap: 8px;
+ align-items: center;
white-space: nowrap;
}
-.brand b { font-weight: 800; letter-spacing: -.01em; }
-.brand span { color: var(--muted); font-size: 12px; }
+/* the logo is white on clear, so it is drawn as a mask in the text's colour - it reads on either theme */
+.brand .logo {
+ display: block;
+ width: 72px;
+ height: 16px;
+ background: var(--text);
+ -webkit-mask: url("data:image/png;base64,__CCC_LOGO__") center / contain no-repeat;
+ mask: url("data:image/png;base64,__CCC_LOGO__") center / contain no-repeat;
+}
.crumbs {
display: flex;
@@ -253,6 +259,20 @@ button, input { font: inherit; color: inherit; }
touch-action: none;
}
.viewport.panning { cursor: grabbing; }
+/* zoomed out this far the grid is noise, not a measure */
+.viewport.far { background-image: none; }
+/* the logo, faint and tiled, under everything drawn - fixed to the window,
+ so zooming and panning the canvas never move it */
+.viewport::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: var(--text);
+ opacity: .01;
+ -webkit-mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='440' height='220'%3E%3Cimage href='data:image/png;base64,__CCC_LOGO__' x='30' y='40' width='110' height='25'/%3E%3Cimage href='data:image/png;base64,__CCC_LOGO__' x='250' y='150' width='110' height='25'/%3E%3C/svg%3E") 0 0 / 440px 220px repeat;
+ mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='440' height='220'%3E%3Cimage href='data:image/png;base64,__CCC_LOGO__' x='30' y='40' width='110' height='25'/%3E%3Cimage href='data:image/png;base64,__CCC_LOGO__' x='250' y='150' width='110' height='25'/%3E%3C/svg%3E") 0 0 / 440px 220px repeat;
+}
.surface {
position: absolute;
@@ -639,9 +659,262 @@ button, input { font: inherit; color: inherit; }
}
.note-bar[hidden] { display: none; }
+/* ---------- edit timeline ---------- */
+
+/* what a step changed: added green, modified purple, removed red; a step not
+ written yet is dashed amber */
+.node.chg {
+ --chg: var(--primary);
+ border-color: var(--chg);
+ box-shadow: var(--shadow), 0 0 0 3px color-mix(in srgb, var(--chg) 30%, transparent);
+ animation: chg-pulse 1.4s ease-out 2;
+}
+.node.chg-added { --chg: var(--green); }
+.node.chg-removed { --chg: var(--red); opacity: .85; }
+.node.chg-pending { --chg: var(--orange); border-style: dashed; }
+/* what an agent looked at, rather than changed */
+.node.chg-read { --chg: var(--blue); }
+@keyframes chg-pulse {
+ 0% { box-shadow: var(--shadow), 0 0 0 0 color-mix(in srgb, var(--chg) 60%, transparent); }
+ 70% { box-shadow: var(--shadow), 0 0 0 16px color-mix(in srgb, var(--chg) 0%, transparent); }
+ 100% { box-shadow: var(--shadow), 0 0 0 3px color-mix(in srgb, var(--chg) 30%, transparent); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .node.chg { animation: none; }
+ .viewport.glide .surface { transition: none !important; }
+}
+.viewport.glide .surface { transition: transform .6s cubic-bezier(.2, .7, .2, 1); }
+.viewport.glide { transition: background-size .6s, background-position .6s; }
+/* a step on show, seen alone: whatever it did not touch fades back */
+.node.faded, .wire.faded, .elabel.faded, #frames.faded { opacity: .2; }
+
+.timeline {
+ position: fixed;
+ left: 12px;
+ right: 12px;
+ bottom: 12px;
+ z-index: 14;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 6px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-bar);
+}
+.timeline[hidden] { display: none; }
+/* no edits yet: only the switches for what comes stay, in the corner */
+.timeline.empty { left: auto; }
+.timeline.empty > :not(.tl-ws):not(.tl-follow):not(.tl-speed):not(.tl-focus) { display: none; }
+body.with-tl-switches .zoom { bottom: 62px; }
+body.with-timeline .legend { bottom: 62px; }
+body.with-timeline .zoom { bottom: 62px; }
+body.with-timeline .panel { bottom: 62px; }
+.tl-controls { display: flex; gap: 2px; }
+.timeline button {
+ height: 28px;
+ min-width: 28px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: 4px;
+ background: none;
+ color: var(--tool);
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.timeline button:hover:not(:disabled) { background: var(--hover); color: var(--tool-active); }
+.timeline button:disabled { opacity: .4; cursor: default; }
+.tl-track {
+ position: relative;
+ flex: 1 1 160px;
+ min-width: 80px;
+ height: 28px;
+ border-radius: 4px;
+ background: var(--surface-2);
+ cursor: pointer;
+ touch-action: none;
+ outline: none;
+}
+.tl-track:focus-visible { box-shadow: 0 0 0 2px var(--primary-soft); }
+.tl-ticks { position: absolute; inset: 4px 4px; display: flex; align-items: flex-end; gap: 1px; pointer-events: none; }
+.tl-ticks .tick { flex: 1 1 0; max-width: 14px; height: 55%; border-radius: 2px; background: var(--grey); opacity: .75; }
+.tl-ticks .tick.staged { background: var(--orange); }
+.tl-ticks .tick.applied { background: var(--green); }
+.tl-ticks .tick.reverted { background: var(--red); }
+.tl-ticks .tick.discarded { background: var(--grey); opacity: .45; }
+.tl-ticks .tick.edited { background: var(--blue); }
+.tl-ticks .tick.read { background: var(--primary); opacity: .4; height: 30%; }
+.tl-ticks .tick.at { height: 100%; opacity: 1; box-shadow: 0 0 0 2px var(--primary); }
+/* a change made by hand is a little person: the tick its body, this its head */
+.tl-ticks .tick.human { position: relative; height: 40%; }
+.tl-ticks .tick.human.at { height: 70%; }
+.tl-ticks .tick.human::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 1px);
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: inherit;
+ transform: translateX(-50%);
+}
+/* a step that only moved layout, while those are hidden */
+.tl-ticks .tick.ws:not(.at) { height: 20%; opacity: .3; }
+.timeline .tl-ws, .timeline .tl-speed, .timeline .tl-focus { border: 1px solid var(--border); border-radius: 14px; font-weight: 400; }
+.timeline .tl-speed { min-width: 46px; font-variant-numeric: tabular-nums; }
+.timeline .tl-ws[aria-pressed="true"], .timeline .tl-focus[aria-pressed="true"] { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
+.panel .chg-tag.ws { background: var(--grey); }
+.person, .glyph { flex: none; width: 12px; height: 12px; margin-right: 4px; vertical-align: -2px; fill: currentColor; }
+.tl-step .person, .replay-badge .person, .tl-ask .person, .ask-row .person { color: var(--blue); }
+.tl-ask .glyph, .ask-row .glyph { color: var(--primary); }
+/* where one ask's steps end and the next one's begin */
+.tl-ticks .tl-sep { flex: none; align-self: stretch; width: 2px; margin: 0 2px; border-radius: 1px; background: var(--border); }
+.timeline .tl-ask {
+ flex: 0 1 auto;
+ min-width: 0;
+ max-width: 28ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ font-weight: 400;
+ font-size: 12px;
+}
+.timeline .tl-ask[hidden] { display: none; }
+.panel .ask-session {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin: 12px 0 4px;
+ color: var(--muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+}
+.panel .ask-session button { flex: none; white-space: nowrap; text-transform: none; letter-spacing: 0; }
+.panel .ask-row { display: flex; align-items: stretch; gap: 2px; margin: 2px 0; border-radius: 6px; }
+.panel .ask-row.at { background: var(--primary-soft); }
+.panel .ask-go {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ min-width: 0;
+ padding: 6px;
+ border: 0;
+ background: none;
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+}
+.panel .ask-go:hover { background: var(--hover); }
+.panel .ask-go .glyph, .panel .ask-go .person { margin-top: 2px; }
+.panel .ask-text {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.panel .ask-n { flex: none; color: var(--muted); font-size: 11px; }
+.panel .ask-play { flex: none; border: 0; background: none; color: var(--tool); cursor: pointer; }
+.panel .ask-play:hover { color: var(--primary); }
+.timeline .tl-step {
+ flex: 0 1 auto;
+ min-width: 0;
+ max-width: 34ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font: 12px var(--mono);
+ font-weight: 400;
+ color: var(--muted);
+}
+.timeline .tl-step:disabled { opacity: 1; }
+.tl-side { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
+.tl-side[hidden] { display: none; }
+.timeline .tl-side button { border-radius: 0; font-weight: 400; font-size: 12px; }
+.timeline .tl-side button[aria-pressed="true"] { background: var(--primary-soft); color: var(--primary); }
+.timeline .tl-follow { border: 1px solid var(--border); border-radius: 14px; font-weight: 400; }
+.timeline .tl-follow[aria-pressed="true"] { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
+
+.replay-badge {
+ position: fixed;
+ left: 12px;
+ top: 64px;
+ z-index: 12;
+ padding: 4px 10px;
+ border: 1px solid var(--orange);
+ border-radius: 14px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 12px;
+ box-shadow: var(--shadow-bar);
+ max-width: calc(100vw - 24px);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.replay-badge[hidden] { display: none; }
+
+.panel .chg-row { display: flex; align-items: baseline; gap: 8px; }
+.panel .chg-tag {
+ flex: none;
+ padding: 0 6px;
+ border-radius: 8px;
+ font-size: 11px;
+ color: #fff;
+ background: var(--primary);
+}
+.panel .chg-tag.added { background: var(--green); }
+.panel .chg-tag.removed { background: var(--red); }
+.panel .chg-tag.read { background: var(--blue); }
+.panel .diff-file { margin: 8px 0 2px; font: 12px var(--mono); }
+.panel .diff-file span { color: var(--muted); }
+.panel .diff pre {
+ margin: 0;
+ padding: 6px 8px;
+ max-height: 340px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface-2);
+ font: 11px/1.45 var(--mono);
+}
+.panel .diff .add { display: block; background: color-mix(in srgb, var(--green) 16%, transparent); }
+.panel .diff .del { display: block; background: color-mix(in srgb, var(--red) 14%, transparent); }
+.panel .diff .hunk { display: block; color: var(--primary); }
+.panel .diff .meta { display: block; color: var(--faint); }
+
@media (max-width: 900px) {
- .brand span { display: none; }
.search input { width: 160px; }
.panel { width: auto; left: 12px; top: auto; height: 45vh; }
.panel:not([hidden]) ~ .zoom { right: 12px; bottom: calc(45vh + 24px); }
+ body.with-timeline .panel { bottom: 62px; }
+ body.with-timeline .panel:not([hidden]) ~ .zoom { bottom: calc(45vh + 74px); }
+}
+
+/* a VS Code side bar: one column - the crumbs get a row of their own above
+ search and the toggles, and the rest of the chrome folds away */
+@media (max-width: 560px) {
+ .brand, .crumbs .lvl, .spacer, .legend, #layout-reset { display: none; }
+ .bar { height: 84px; flex-wrap: wrap; align-content: center; gap: 4px 6px; padding: 4px 8px; }
+ .crumbs { flex: 1 1 100%; order: -1; }
+ .crumbs button { max-width: 150px; padding: 3px 5px; }
+ .search { flex: 1 1 0; min-width: 0; }
+ .search input { width: 100%; height: 30px; }
+ .search svg { top: 8px; }
+ .tools .chip { padding: 0 6px; font-size: 11px; }
+ .viewport { top: 84px; }
+ .panel, .note-bar, .replay-badge { top: 94px; }
+ .timeline { flex-wrap: wrap; left: 6px; right: 6px; bottom: 6px; }
+ .tl-track { order: 5; flex-basis: 100%; }
+ .timeline .tl-step { flex: 1 1 0; max-width: none; }
+ .timeline .tl-ask { max-width: 12ch; }
+ body.with-timeline .zoom, body.with-timeline .panel { bottom: 88px; }
+ .panel { left: 6px; right: 6px; }
}
diff --git a/assets/vis/vis.html b/assets/vis/vis.html
index 1062be7..d43e3aa 100644
--- a/assets/vis/vis.html
+++ b/assets/vis/vis.html
@@ -8,7 +8,7 @@
-
cccCollateral Code Check
+
@@ -60,6 +60,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/vis/vis.js b/assets/vis/vis.js
index 8ae3dac..e1d0b68 100644
--- a/assets/vis/vis.js
+++ b/assets/vis/vis.js
@@ -6,6 +6,14 @@
// every level arrives language-agnostic from /vis.json, /vis/code and
// /vis/flow - and keeps nothing the server does not, beyond where you dragged
// things and which toggles you left on.
+//
+// Below the canvas runs a timeline of every step ccc's edit tools take. It
+// follows them as they land - the canvas flies to the change and marks it -
+// and replays any of them on either side, scrubbed like a video.
+//
+// The same page runs inside the VS Code extension as a webview: there it
+// asks the extension for data instead of fetching it, and asks it to open
+// files instead of following vscode:// links.
(() => {
'use strict';
@@ -27,10 +35,18 @@
return e;
}
- // browser storage can be missing or refuse writes; nothing depends on it
+ // set inside the VS Code extension's webview, where the extension is the host
+ const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;
+
+ // the webview's own state inside VS Code, browser storage elsewhere; either
+ // can be missing or refuse writes, and nothing depends on it
const store = {
get(key, fallback) {
try {
+ if (vscode) {
+ const all = vscode.getState() || {};
+ return key in all ? all[key] : fallback;
+ }
const v = localStorage.getItem('ccc-vis:' + key);
return v == null ? fallback : JSON.parse(v);
} catch {
@@ -39,7 +55,8 @@
},
set(key, value) {
try {
- localStorage.setItem('ccc-vis:' + key, JSON.stringify(value));
+ if (vscode) vscode.setState({ ...(vscode.getState() || {}), [key]: value });
+ else localStorage.setItem('ccc-vis:' + key, JSON.stringify(value));
} catch {
// not kept - the page still works
}
@@ -95,15 +112,58 @@
return PIN_COLOURS[x % PIN_COLOURS.length];
}
+ // in a webview every request goes through the extension, which reaches the
+ // server wherever it runs - over ssh, in a container - where the page cannot
+ const waiting = new Map();
+ let asked = 0;
+
+ if (vscode) {
+ window.addEventListener('message', e => {
+ const m = e.data || {};
+ if (m.type === 'res' && waiting.has(m.id)) {
+ waiting.get(m.id)(m);
+ waiting.delete(m.id);
+ }
+ });
+ }
+
+ function viaHost(url) {
+ return new Promise((resolve, reject) => {
+ const id = ++asked;
+ const timer = setTimeout(() => {
+ waiting.delete(id);
+ reject(new Error('the extension did not answer'));
+ }, 30000);
+ waiting.set(id, m => {
+ clearTimeout(timer);
+ resolve(m);
+ });
+ vscode.postMessage({ type: 'get', id, url });
+ });
+ }
+
async function getJSON(url) {
- const r = await fetch(url, { cache: 'no-store' });
+ let status;
+ let reason = '';
+ let text;
+ if (vscode) {
+ const m = await viaHost(url);
+ if (m.error) throw new Error(m.error);
+ status = m.status;
+ text = m.body;
+ } else {
+ const r = await fetch(url, { cache: 'no-store' });
+ status = r.status;
+ reason = r.statusText;
+ text = await r.text();
+ }
let body = null;
try {
- body = await r.json();
+ body = JSON.parse(text);
} catch {
// not json - the status says enough
}
- if (!r.ok) throw new Error((body && body.error) || `${r.status} ${r.statusText}`);
+ if (status < 200 || status >= 300) throw new Error((body && body.error) || `${status} ${reason}`.trim());
return body;
}
@@ -124,6 +184,16 @@
tests: store.get('tests', false),
// light unless the viewer chose otherwise; `auto` follows the system
theme: store.get('theme', 'light'),
+ // the edit step whose changes are marked on the canvas, and which side of it
+ marks: null,
+ // the next mount glides the camera onto what it marks
+ fly: false,
+ // the overview on show is a past step's snapshot, not the live map
+ overviewReplay: false,
+ // the panel holds an edit step, so a replay keeps it open on the next one
+ stepPanel: false,
+ // the step on show is seen alone - see `fade`
+ focusing: false,
};
const LEVELS = {
@@ -193,16 +263,24 @@
let renderSeq = 0;
+ async function ensureOverview() {
+ if (!S.overview) {
+ S.overview = await getJSON('/vis.json');
+ S.generated = S.overview.generated;
+ }
+ }
+
+ // the live map at the route in the address - any replay ends here
async function render(keepView) {
const seq = ++renderSeq;
+ endReplay();
const route = parseRoute();
const same = S.route && href(S.route) === href(route);
S.route = route;
+ // a webview forgets its address when it is reloaded
+ if (vscode) store.set('hash', location.hash);
try {
- if (!S.overview) {
- S.overview = await getJSON('/vis.json');
- S.generated = S.overview.generated;
- }
+ await ensureOverview();
let scene;
if (route.level === 'containers') scene = sceneContainers();
else if (route.level === 'components') scene = sceneComponents(route.container);
@@ -210,7 +288,15 @@
scene = sceneCode(await getJSON('/vis/code?file=' + encodeURIComponent(route.file)));
} else if (route.level === 'flow') {
const q = `file=${encodeURIComponent(route.file)}&line=${route.line}` + (route.name ? '&name=' + encodeURIComponent(route.name) : '');
- scene = sceneFlow(await getJSON('/vis/flow?' + q));
+ const flow = await getJSON('/vis/flow?' + q);
+ if (seq !== renderSeq) return;
+ // the function moved since this address was written - follow it there
+ if (flow.function.line !== route.line) {
+ route.line = flow.function.line;
+ history.replaceState(null, '', href(route));
+ if (vscode) store.set('hash', location.hash);
+ }
+ scene = sceneFlow(flow);
} else scene = sceneSystem();
if (seq !== renderSeq) return; // a later navigation won
mount(scene, keepView && same);
@@ -1063,10 +1149,14 @@
function mount(scene, keepView) {
clearCanvas();
- hidePanel();
+ // the list of asks belongs to no one level, so it stays while they are walked
+ if (!S.asksPanel) hidePanel();
hideMessage();
S.scene = scene;
S.selected = null;
+ // a step arriving or replayed is seen alone - see `fade`
+ S.focusing = S.fly && T.focus && !!S.marks;
+ if (S.focusing) S.scene = scene = narrowed(scene);
const l = layers();
for (const n of scene.nodes) {
n.el.dataset.id = n.id;
@@ -1099,6 +1189,35 @@
legend(scene.legend || []);
if (keepView) applyView();
else fit();
+ const changed = applyMarks();
+ if (S.fly && changed.length) flyTo(changed);
+ S.fly = false;
+ }
+
+ // a step's scene past this many nodes keeps only what it touched and what
+ // is wired straight to it
+ const FOCUS_KEEP = 12;
+
+ // A scene cut down to the step on show and its neighbours, laid out afresh
+ // so they sit together and fit the screen. A function's logic and the
+ // context level keep every node - their shape is the point of them.
+ function narrowed(scene) {
+ if (scene.layout === 'flow' || scene.layout === 'context' || scene.nodes.length <= FOCUS_KEEP) return scene;
+ const hit = new Set(scene.nodes.filter(n => changeOf(n, S.marks.ev, S.marks.side)).map(n => n.id));
+ if (!hit.size) return scene;
+ const near = new Set(hit);
+ for (const e of scene.edges) {
+ if (hit.has(e.from)) near.add(e.to);
+ if (hit.has(e.to)) near.add(e.from);
+ }
+ if (near.size >= scene.nodes.length) return scene;
+ return {
+ ...scene,
+ nodes: scene.nodes.filter(n => near.has(n.id)),
+ edges: scene.edges.filter(e => near.has(e.from) && near.has(e.to)),
+ persist: false,
+ pruned: true,
+ };
}
function place(n) {
@@ -1277,6 +1396,8 @@
vp.style.setProperty('--zoom', S.zoom);
vp.style.setProperty('--pan-x', S.panX);
vp.style.setProperty('--pan-y', S.panY);
+ // at 30% and below the grid goes - see `.viewport.far`
+ vp.classList.toggle('far', S.zoom <= 0.3);
$('zoom-level').textContent = Math.round(S.zoom * 100) + '%';
}
@@ -1369,9 +1490,13 @@
function hidePanel() {
$('panel').hidden = true;
+ S.stepPanel = false;
+ S.asksPanel = false;
}
function panel(what, title, fill) {
+ S.stepPanel = false;
+ S.asksPanel = false;
$('panel-what').textContent = what;
$('panel-title').textContent = title;
const body = $('panel-body');
@@ -1398,15 +1523,25 @@
return `vscode://file${root.startsWith('/') ? '' : '/'}${root}/${file}${line ? ':' + line : ''}`;
}
+ // a link that opens the file at the line - a vscode:// link in a browser, a
+ // request to the extension inside it
+ function editorLink(text, file, line) {
+ const a = h('a', null, text);
+ a.href = editorHref(file, line);
+ a.addEventListener('click', e => {
+ if (!vscode) return;
+ e.preventDefault();
+ vscode.postMessage({ type: 'open', file, line });
+ });
+ return a;
+ }
+
function actions(list) {
const box = h('div', 'actions');
for (const a of list) {
if (!a) continue;
- if (a.href) {
- const link = h('a', null, a.label);
- link.href = a.href;
- box.append(link);
- } else {
+ if (a.open) box.append(editorLink(a.label, ...a.open));
+ else {
const b = h('button', null, a.label);
b.addEventListener('click', a.run);
box.append(b);
@@ -1465,7 +1600,7 @@
const inE = o.component_edges.filter(e => e.to === d.id).sort((a, b) => b.count - a.count);
if (outE.length) section(body, 'Calls into', list(outE.slice(0, 20).map(e => goBtn(`${e.to} ×${e.count}`, { level: 'code', file: e.to }, e.symbols.join(', ')))));
if (inE.length) section(body, 'Called from', list(inE.slice(0, 20).map(e => goBtn(`${e.from} ×${e.count}`, { level: 'code', file: e.from }, e.symbols.join(', ')))));
- body.append(actions([{ label: 'Open code', run: () => drill(n) }, { label: 'Open in editor', href: editorHref(d.id, 1) }]));
+ body.append(actions([{ label: 'Open code', run: () => drill(n) }, { label: 'Open in editor', open: [d.id, 1] }]));
});
case 'outer':
return panel('Container', d.name, body => {
@@ -1504,7 +1639,7 @@
const route = x => ({ level: 'flow', file: x.file, line: x.line, name: x.name });
if (f.calls && f.calls.length) section(body, `Calls (${f.calls_total})`, list(f.calls.map(c => all.get(c.to)).filter(Boolean).map(x => goBtn(`${x.owner ? x.owner + '::' : ''}${x.name} ${x.file === f.file ? '' : x.file}`, route(x)))));
if (f.callers && f.callers.length) section(body, `Called by (${f.callers_total})`, list(f.callers.map(c => all.get(c)).filter(Boolean).map(x => goBtn(`${x.owner ? x.owner + '::' : ''}${x.name} ${x.file === f.file ? '' : x.file}`, route(x)))));
- body.append(actions([{ label: 'Open logic', run: () => drill(n) }, n.kind === 'proxy' && { label: 'Open its file', run: () => go({ level: 'code', file: f.file }) }, { label: 'Open in editor', href: editorHref(f.file, f.line) }]));
+ body.append(actions([{ label: 'Open logic', run: () => drill(n) }, n.kind === 'proxy' && { label: 'Open its file', run: () => go({ level: 'code', file: f.file }) }, { label: 'Open in editor', open: [f.file, f.line] }]));
body.append(h('p', 'hint', 'Double-click a function to open its logic. Drag to move; positions are remembered.'));
});
}
@@ -1519,14 +1654,14 @@
if (f.doc) body.append(h('p', 'doc', f.doc));
if (st.params.length) section(body, 'Parameters', list(st.params.map(p => h('code', null, p))));
if (st.callers.length) section(body, `Called by (${st.callers_total})`, list(st.callers.map(c => goBtn(`${c.owner ? c.owner + '::' : ''}${c.name} ${c.file}`, { level: 'flow', file: c.file, line: c.line, name: c.name }))));
- body.append(actions([{ label: 'Open its file', run: () => go({ level: 'code', file: f.file }) }, { label: 'Open in editor', href: editorHref(f.file, f.line) }]));
+ body.append(actions([{ label: 'Open its file', run: () => go({ level: 'code', file: f.file }) }, { label: 'Open in editor', open: [f.file, f.line] }]));
});
}
if (n.kind === 'calls') {
return panel('Library calls', `${st.calls.length} call${st.calls.length === 1 ? '' : 's'}`, body => {
body.append(h('p', 'doc', 'Calls that reach no function in this project - the standard library, a dependency, or a name the resolver found no evidence for.'));
section(body, 'In order', list(st.calls.map(c => h('code', null, `${c.qual ? c.qual + '.' : ''}${c.name}(${c.args.join(', ')}) :${c.line}`))));
- body.append(actions([{ label: 'Open in editor', href: editorHref(file, st.line) }]));
+ body.append(actions([{ label: 'Open in editor', open: [file, st.line] }]));
});
}
const title = st.t === 'call' ? st.name : st.label || st.kind;
@@ -1535,7 +1670,7 @@
if (st.arms) section(body, 'Arms', list(st.arms.map(a => `${a.label}: ${a.steps.length ? plural(a.steps.length, 'step') : a.implicit ? 'nothing written - carries on' : 'nothing'}`)));
if (st.target) section(body, 'Reaches', goBtn(`${st.target.owner ? st.target.owner + '::' : ''}${st.target.name} ${st.target.file}:${st.target.line}`, n.drill));
else if (st.t === 'call') body.append(h('p', 'doc', 'No function in this project - a library call, or one the resolver had no evidence for.'));
- body.append(actions([n.drill && { label: 'Open its logic', run: () => drill(n) }, st.line && { label: 'Open in editor', href: editorHref(file, st.line) }]));
+ body.append(actions([n.drill && { label: 'Open its logic', run: () => drill(n) }, st.line && { label: 'Open in editor', open: [file, st.line] }]));
});
}
@@ -1550,11 +1685,7 @@
body,
'Call sites',
list(
- d.sites.map(s => {
- const a = h('a', null, `${s.caller} ${s.caller_file}:${s.caller_line} → ${s.symbol}`);
- a.href = editorHref(s.caller_file, s.caller_line);
- return a;
- })
+ d.sites.map(s => editorLink(`${s.caller} ${s.caller_file}:${s.caller_line} → ${s.symbol}`, s.caller_file, s.caller_line))
)
);
}
@@ -1748,8 +1879,14 @@
d.n.el.classList.remove('dragging');
if (d.moved) {
if (S.scene.persist) savePos(d.n);
- } else select(d.n.id);
- } else if (!d.moved) select(null);
+ } else {
+ unfocus(false);
+ select(d.n.id);
+ }
+ } else if (!d.moved) {
+ unfocus(true);
+ select(null);
+ }
};
vp.addEventListener('pointerup', finish);
vp.addEventListener('pointercancel', finish);
@@ -1845,32 +1982,881 @@
else if (e.key === '+' || e.key === '=') zoomBy(1.2);
else if (e.key === '-' || e.key === '_') zoomBy(1 / 1.2);
else if (e.key === 'Enter' && S.selected) drill(S.byId.get(S.selected));
+ else if (e.key === ',' || e.key === '<') stepBy(-1);
+ else if (e.key === '.' || e.key === '>') stepBy(1);
+ else if (e.key === 'l' || e.key === 'L') followLive();
+ else if (e.key === '[') groupBy(-1);
+ else if (e.key === ']') groupBy(1);
});
window.addEventListener('hashchange', () => render(false));
}
- // ------------------------------------------------------------- refresh
+ // ------------------------------------------------------------- timeline
+
+ // Every step ccc's edit tools took, and every change made by hand, oldest
+ // first, as the server logged it.
+ // Following, each new one is shown where it landed and as the map holds it
+ // now; scrubbing replays any of them from the snapshots the server drew on
+ // each side of it while this page was open.
+ // replay speeds, as a share of real time
+ const SPEEDS = [1, 0.5, 0.25, 0.125, 0.1];
+ const SPEED_LABEL = { 1: 'Live', 0.5: '½×', 0.25: '¼×', 0.125: '⅛×', 0.1: '⅒×' };
+ // at live speed a step stays on show at least this long - parts that
+ // landed together still take their turn each - and never waits longer
+ // than this for the next, however long the agent paused
+ const STEP_MS = 1200;
+ const GAP_MS = 4000;
+
+ const T = {
+ boot: null,
+ seq: 0,
+ steps: [],
+ // the step on show, as an index into `steps`
+ at: null,
+ follow: true,
+ side: 'after',
+ // a past step drawn from its snapshot rather than the live map
+ replay: null,
+ // per step, the answer of /vis/event: the step and both its snapshots
+ full: new Map(),
+ playing: false,
+ // the first answer is history, not news - nothing flies for it
+ primed: false,
+ // show a change that only moves whitespace or wraps lines
+ ws: store.get('ws', false),
+ // how fast steps play, following or replaying - 1 keeps the time between them
+ speed: SPEEDS.includes(store.get('speed', 1)) ? store.get('speed', 1) : 1,
+ // steps landed while following, waiting their turn at that speed
+ queue: [],
+ pumping: false,
+ // the step shown last while following, and when
+ shown: null,
+ // each step on show is seen alone: what it touched in full, the rest faded
+ focus: store.get('focus', true),
+ // the agent session on show - one at a time, and undefined for the latest
+ session: undefined,
+ };
+ const MAX_TIMELINE = 200;
+ const STATUS = {
+ staged: 'staged - not written yet',
+ applied: 'applied - written',
+ reverted: 'reverted - put back',
+ discarded: 'discarded - dropped unwritten',
+ edited: 'edited by hand - saved outside ccc’s edit tools',
+ read: 'read - an agent looked at it through ccc’s tools',
+ };
+
+ const sameFn = (t, f) => t.file === f.file && t.name === f.name && (t.owner || null) === (f.owner || null);
+ const within = (line, ranges) => line != null && ranges.some(([a, b]) => line >= a && line <= b);
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+ const byHand = ev => ev.by === 'human';
+ // a step, file or function only its layout moved, while such changes are hidden
+ const layoutOnly = x => !T.ws && !!x && !!x.whitespace;
+ // the latest reason the agent gave for the step, if it gave one
+ const why = ev => (ev.intent && ev.intent[ev.intent.length - 1]) || null;
+
+ // The timeline's steps grouped by the ask behind them - the request an
+ // agent was answering, read from its transcript - or by a run of changes
+ // made by hand, as /vis/asks answers.
+ const A = { groups: [], of: new Map(), timer: 0 };
+ const groupOf = ev => (ev ? A.groups[A.of.get(ev.seq)] : null);
+
+ // what a group of steps is put down to, in a line
+ function groupText(g) {
+ if (g.kind === 'hand') return 'Changed by hand';
+ if (g.kind === 'agent') return 'An agent - no transcript names its ask';
+ return g.ask.prompt;
+ }
+
+ // a speech bubble, beside an ask an agent was answering
+ function bubble() {
+ const s = sv('svg', { class: 'glyph', viewBox: '0 0 16 16', 'aria-hidden': 'true' });
+ s.append(sv('path', { d: 'M2 2.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7l-3.5 3v-3H2a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z' }));
+ return s;
+ }
+ const groupIcon = g => (g.kind === 'hand' ? person() : bubble());
+
+ // which ask each step answers - read again a moment after steps land, once
+ // the agent's transcript has caught up with them
+ function asksSoon(ms) {
+ clearTimeout(A.timer);
+ A.timer = setTimeout(loadAsks, ms);
+ }
+
+ async function loadAsks() {
+ let r;
+ try {
+ r = await getJSON('/vis/asks');
+ } catch {
+ return;
+ }
+ A.groups = r.groups || [];
+ A.of = new Map();
+ A.groups.forEach((g, i) => g.steps.forEach(seq => A.of.set(seq, i)));
+ drawTimeline();
+ if (S.asksPanel) panelAsks();
+ }
+
+ // a person, beside a change made by hand rather than by an agent
+ function person() {
+ const s = sv('svg', { class: 'person', viewBox: '0 0 16 16', 'aria-hidden': 'true' });
+ s.append(sv('circle', { cx: 8, cy: 4.5, r: 3 }), sv('path', { d: 'M2 15c0-3.6 2.7-6 6-6s6 2.4 6 6z' }));
+ return s;
+ }
+
+ function summary(ev) {
+ const fn = ev.focus && ev.focus.level === 'flow' ? ev.focus.name : null;
+ const files = ev.files.length === 1 ? base(ev.files[0].path) : plural(ev.files_total, 'file');
+ if (ev.status === 'read') return `looked at ${fn || files}`;
+ const added = ev.files.reduce((n, f) => n + f.added, 0);
+ const removed = ev.files.reduce((n, f) => n + f.removed, 0);
+ // one part of a step: the definition it is about, and which part of how many
+ const p = ev.part;
+ const what = (p && p.name ? `${p.owner ? p.owner + '::' : ''}${p.name}` : fn) || files;
+ const part = p ? ` · ${p.kind === 'file' ? 'file' : p.kind} ${p.change} · ${p.index + 1}/${p.of}` : '';
+ return `${what} +${added} −${removed}${part}`;
+ }
+
+ // one part of a step in a line: what it is about, and which of how many
+ function partOf(ev) {
+ const p = ev.part;
+ if (!p) return null;
+ const name = p.name ? ` ${p.owner ? p.owner + '::' : ''}${p.name}` : '';
+ return `${p.kind}${name} ${p.change} ${p.index + 1}/${p.of}`;
+ }
+
+ // where on the canvas a step is seen, on one side of it
+ function focusRoute(ev, side) {
+ const f = ev.focus;
+ if (!f) return null;
+ const mapped = file => !!containerOf(file);
+ if (f.level === 'flow') {
+ const pos = f[side];
+ if (!mapped(f.file)) return null;
+ return pos ? { level: 'flow', file: f.file, line: pos.line, name: f.name } : { level: 'code', file: f.file };
+ }
+ if (f.level === 'code') return mapped(f.file) ? { level: 'code', file: f.file } : null;
+ const containers = [...new Set(ev.files.map(x => containerOf(x.path)).filter(Boolean))];
+ if (containers.length === 1) return { level: 'components', container: containers[0] };
+ return containers.length ? { level: 'containers' } : null;
+ }
+
+ // how a node took part in the marked step - added, modified, removed - if at all
+ function changeOf(n, ev, side) {
+ const d = n.data || {};
+ switch (S.route && S.route.level) {
+ case 'flow': {
+ const fn = S.scene.entry && S.scene.entry.data.function;
+ const t = fn && ev.functions.find(x => sameFn(x, fn));
+ if (!t || layoutOnly(t)) return null;
+ if (n.kind === 'entry') return t.change === 'modified' ? null : t.change;
+ const lines = t['lines_' + side] || [];
+ const hit = n.kind === 'calls' ? d.calls.some(c => within(c.line, lines)) : within(d.line, lines);
+ return hit ? (t.change === 'added' || t.change === 'read' ? t.change : 'modified') : null;
+ }
+ case 'code': {
+ if (n.kind !== 'function' && n.kind !== 'proxy') return null;
+ const t = ev.functions.find(x => sameFn(x, d));
+ return t && !layoutOnly(t) ? t.change : null;
+ }
+ case 'components': {
+ const f = n.kind === 'component' && ev.files.find(x => x.path === d.id);
+ return f && !layoutOnly(f) ? f.change : null;
+ }
+ case 'containers':
+ return n.kind === 'container' && ev.files.some(x => containerOf(x.path) === d.id && !layoutOnly(x)) ? touched(ev) : null;
+ case 'system':
+ return n.kind === 'system' && ev.files.some(x => !layoutOnly(x)) ? touched(ev) : null;
+ default:
+ return null;
+ }
+ }
+
+ // how a level above the code took part: looked at, or changed
+ const touched = ev => (ev.status === 'read' ? 'read' : 'modified');
+
+ // A change with no step of its own - lines removed, a statement that calls
+ // nothing - is marked on the step it lands after, or on the entry.
+ function landing(ev, side) {
+ const fn = S.scene.entry && S.scene.entry.data.function;
+ const t = fn && ev.functions.find(x => sameFn(x, fn));
+ const ranges = t && t['lines_' + side];
+ if (!ranges || !ranges.length || t.change !== 'modified' || layoutOnly(t)) return null;
+ const at = ranges[0][0];
+ let best = null;
+ for (const n of S.scene.nodes) {
+ const line = n.kind === 'entry' || !n.data ? null : n.data.line;
+ if (line != null && line <= at && (!best || line >= best.line)) best = { n, line };
+ }
+ return best ? best.n : S.scene.entry;
+ }
+
+ // mark what the step on show changed, and return the nodes marked
+ function applyMarks() {
+ const hit = [];
+ if (!S.scene) return hit;
+ const m = S.marks;
+ const mark = (n, how) => {
+ // read layout between the two so the pulse starts again
+ void n.el.offsetWidth;
+ n.el.classList.add('chg', 'chg-' + how);
+ if (m.ev.status === 'staged') n.el.classList.add('chg-pending');
+ hit.push(n);
+ };
+ for (const n of S.scene.nodes) {
+ n.el.classList.remove('chg', 'chg-added', 'chg-modified', 'chg-removed', 'chg-pending', 'chg-read');
+ const how = m ? changeOf(n, m.ev, m.side) : null;
+ if (how) mark(n, how);
+ }
+ if (m && !hit.length && S.route && S.route.level === 'flow') {
+ const near = landing(m.ev, m.side);
+ if (near) mark(near, 'modified');
+ }
+ fade(hit);
+ return hit;
+ }
+
+ // The step on show seen alone: what it touched in full, every other node
+ // and wire faded back - while it is on show, and until the viewer reaches
+ // into the canvas.
+ function fade(hit) {
+ const on = !!(S.scene && S.focusing && hit.length);
+ const keep = new Set(hit.map(n => n.id));
+ for (const n of S.scene ? S.scene.nodes : []) n.el.classList.toggle('faded', on && !keep.has(n.id));
+ for (const e of S.scene ? S.scene.edges : []) {
+ const lit = keep.has(e.from) || keep.has(e.to);
+ if (e.path) e.path.classList.toggle('faded', on && !lit);
+ if (e.labelEl) e.labelEl.classList.toggle('faded', on && !lit);
+ }
+ $('frames').classList.toggle('faded', on);
+ }
+
+ // the canvas whole again - `whole` brings back what a big scene left out
+ function unfocus(whole) {
+ if (!S.focusing) return;
+ S.focusing = false;
+ fade([]);
+ if (whole && S.scene && S.scene.pruned && !T.replay) render(true);
+ }
+
+ // glide the camera onto the marked nodes, near enough to read them
+ function flyTo(nodes) {
+ const vp = $('viewport');
+ const bar = $('timeline');
+ const x0 = Math.min(...nodes.map(n => n.x));
+ const y0 = Math.min(...nodes.map(n => n.y));
+ const x1 = Math.max(...nodes.map(n => n.x + n.w));
+ const y1 = Math.max(...nodes.map(n => n.y + n.h));
+ const vw = vp.clientWidth - ($('panel').hidden ? 0 : 352);
+ const vh = vp.clientHeight - (bar.hidden ? 0 : bar.offsetHeight + 12);
+ const floor = S.route && S.route.level === 'flow' ? 0.6 : 0.3;
+ S.zoom = clamp(Math.min((vw - 120) / Math.max(1, x1 - x0), (vh - 120) / Math.max(1, y1 - y0)), floor, 1.1);
+ S.panX = vw / 2 - ((x0 + x1) / 2) * S.zoom;
+ S.panY = vh / 2 - ((y0 + y1) / 2) * S.zoom;
+ glide();
+ applyView();
+ }
+
+ let glideTimer = 0;
+ function glide() {
+ const vp = $('viewport');
+ vp.classList.add('glide');
+ clearTimeout(glideTimer);
+ glideTimer = setTimeout(() => vp.classList.remove('glide'), 700);
+ }
+
+ // a step just landed: show it where it happened, as the map holds it now
+ async function arrive(ev) {
+ const i = T.steps.indexOf(ev);
+ T.at = i < 0 ? T.steps.length - 1 : i;
+ T.side = ev.on_disk;
+ drawTimeline();
+ if (ev.status === 'discarded') {
+ // nothing moved; the marks of what it dropped go with it, and so does
+ // a preview of what it would have written
+ if (!S.marks || S.marks.ev.changeset !== ev.changeset) return;
+ S.marks = null;
+ if (!(T.replay && T.replay.live)) return applyMarks();
+ const route = focusRoute(ev, 'before');
+ return route ? go(route) : render(true);
+ }
+ // only its layout moved: nothing to show, though what is on screen may have shifted
+ if (layoutOnly(ev)) {
+ S.marks = null;
+ if (ev.status === 'staged') return applyMarks();
+ S.overview = null;
+ return render(true);
+ }
+ // a step not written yet: draw the map as it would leave it, to look over
+ if (ev.status === 'staged' && ev.snapshot && ev.focus) return replay(T.at, 'after', true);
+ // a look moved nothing, so the overview on show still holds
+ if (ev.status !== 'staged' && ev.status !== 'read') S.overview = null;
+ try {
+ await ensureOverview();
+ } catch {
+ return;
+ }
+ S.marks = { ev, side: ev.on_disk };
+ S.fly = true;
+ const route = focusRoute(ev, ev.on_disk);
+ if (route) go(route);
+ else render(true);
+ }
+
+ // the live map again, the newest step shown as if it had just landed, and
+ // each step after it followed
+ function followLive() {
+ stopPlay();
+ T.follow = true;
+ // live is now, whatever was still waiting its turn, in the latest session
+ T.queue = [];
+ T.shown = null;
+ T.session = undefined;
+ endReplay();
+ const last = T.steps[T.steps.length - 1];
+ if (!last || last.status === 'discarded') {
+ S.marks = null;
+ T.at = last ? T.steps.length - 1 : null;
+ drawTimeline();
+ return render(true);
+ }
+ return arrive(last);
+ }
+
+ // how long a step stays on show before the next, at the chosen speed
+ const pace = (ev, next) => clamp(next && ev ? next.at - ev.at : 0, STEP_MS, GAP_MS) / T.speed;
+
+ // Steps landed while following, shown one after another: live keeps the
+ // time between them, a slower speed stretches it, and parts that landed
+ // together still take their turn each.
+ async function pump() {
+ if (T.pumping) return;
+ T.pumping = true;
+ try {
+ let drawn = -1;
+ while (T.queue.length && T.follow && !T.playing) {
+ const ev = T.queue[0];
+ const wait = T.shown ? T.shown.at + pace(T.shown.ev, ev) - Date.now() : 0;
+ if (wait > 0) {
+ // waiting, only the count behind moves
+ if (drawn !== T.queue.length) {
+ drawn = T.queue.length;
+ drawTimeline();
+ }
+ await sleep(Math.min(wait, 250));
+ continue;
+ }
+ T.queue.shift();
+ if (!T.steps.includes(ev)) continue;
+ T.shown = { ev, at: Date.now() };
+ // following is the latest session
+ T.session = undefined;
+ await arrive(ev);
+ }
+ } finally {
+ T.pumping = false;
+ drawTimeline();
+ }
+ }
+
+ // how steps play: a speed from SPEEDS, and whether each is seen alone
+ function setReplay(speed, focus) {
+ if (SPEEDS.includes(speed)) {
+ T.speed = speed;
+ store.set('speed', speed);
+ }
+ if (typeof focus === 'boolean') {
+ T.focus = focus;
+ store.set('focus', focus);
+ if (!focus) unfocus(true);
+ }
+ drawTimeline();
+ }
+
+ let replaySeq = 0;
+
+ // A past step drawn from the snapshot the server took of it, on one side.
+ // `live` is the newest step drawn while following - a preview of what a
+ // staged step would write - so following carries on past it.
+ async function replay(i, side, live) {
+ const ev = T.steps[i];
+ if (!ev) return;
+ const mine = ++replaySeq;
+ if (!live) T.follow = false;
+ T.at = i;
+ drawTimeline();
+ let full = T.full.get(ev.seq);
+ if (!full && ev.snapshot) {
+ try {
+ full = await getJSON('/vis/event?seq=' + ev.seq);
+ T.full.set(ev.seq, full);
+ } catch {
+ full = null;
+ }
+ if (mine !== replaySeq) return;
+ }
+ const has = s => !!(full && full[s]);
+ side = side || T.side;
+ if (!has(side)) side = has('after') ? 'after' : has('before') ? 'before' : side;
+ T.side = side;
+ const keepPanel = S.stepPanel;
+ S.marks = { ev, side };
+ S.fly = true;
+ if (!has(side)) {
+ // nothing was drawn of it - no visualiser was open, or it is older than
+ // the snapshots kept - so show where it happened in the map as it is
+ try {
+ await ensureOverview();
+ } catch {
+ return;
+ }
+ const route = focusRoute(ev, ev.on_disk);
+ if (route) go(route);
+ else drawTimeline();
+ return;
+ }
+ const snap = full[side];
+ const f = ev.focus;
+ if (f.level !== 'overview' && S.overviewReplay) {
+ S.overview = S.liveOverview;
+ S.overviewReplay = false;
+ }
+ let scene;
+ try {
+ if (f.level === 'flow') {
+ S.route = { level: 'flow', file: f.file, line: snap.function.line, name: f.name };
+ scene = sceneFlow(snap);
+ } else if (f.level === 'code') {
+ S.route = { level: 'code', file: f.file };
+ scene = sceneCode(snap);
+ } else {
+ if (!S.overviewReplay) S.liveOverview = S.overview;
+ S.overview = snap;
+ S.overviewReplay = true;
+ const r = focusRoute(ev, side);
+ S.route = r && r.level === 'components' ? r : { level: 'containers' };
+ scene = S.route.level === 'components' ? sceneComponents(S.route.container) : sceneContainers();
+ }
+ } catch (e) {
+ showMessage('Could not draw this step', e.message);
+ return;
+ }
+ T.replay = { seq: ev.seq, side, live: !!live };
+ // a live render still in flight must not draw over the replay
+ renderSeq++;
+ mount(scene, false);
+ crumbs();
+ if (keepPanel) panelEvent(ev);
+ drawTimeline();
+ }
+
+ function endReplay() {
+ if (!T.replay && !S.overviewReplay) return;
+ T.replay = null;
+ if (S.overviewReplay) {
+ // the live map has likely moved on since; fetch it again
+ S.overview = null;
+ S.overviewReplay = false;
+ }
+ drawTimeline();
+ }
+
+ // the step before or after the one on show, within the session on show
+ function stepBy(d) {
+ const shown = shownSteps();
+ if (!shown.length) return;
+ stopPlay();
+ const k = shown.indexOf(T.at);
+ const from = k < 0 ? (d > 0 ? -1 : shown.length) : k;
+ const i = shown[clamp(from + d, 0, shown.length - 1)];
+ if (i !== T.at || !T.replay) replay(i, T.side);
+ }
+
+ // replay step after step - those given, or every one from the playhead on -
+ // then follow again at the end
+ async function play(list) {
+ if (T.playing) return stopPlay();
+ if (!T.steps.length) return;
+ T.playing = true;
+ const shown = shownSteps();
+ const k = shown.indexOf(T.at);
+ const queue = list || shown.slice(k < 0 || k >= shown.length - 1 ? 0 : k + 1);
+ drawTimeline();
+ for (const [k, i] of queue.entries()) {
+ if (!T.playing) break;
+ await replay(i, 'after');
+ await sleep(pace(T.steps[i], T.steps[queue[k + 1]]));
+ }
+ if (T.playing) {
+ T.playing = false;
+ followLive();
+ }
+ }
+
+ function stopPlay() {
+ if (!T.playing) return;
+ T.playing = false;
+ drawTimeline();
+ }
+
+ // Runs of asks by the agent session they came from. Changes made by hand,
+ // and steps no transcript explains, go with the session they fall in.
+ function sessionSpans() {
+ const spans = [];
+ for (const [gi, g] of A.groups.entries()) {
+ const s = g.ask ? g.ask.session : null;
+ let cur = spans[spans.length - 1];
+ if (!cur || (s && cur.session && cur.session !== s)) spans.push((cur = { session: s, groups: new Set() }));
+ else if (s && !cur.session) cur.session = s;
+ cur.groups.add(gi);
+ }
+ return spans;
+ }
+
+ // The steps of the session on show, as indices into `steps` - the latest
+ // unless one was picked. A step no ask is known for yet is the newest, so
+ // it goes with the latest.
+ function shownSteps() {
+ const spans = sessionSpans();
+ if (spans.length < 2) return T.steps.map((_, i) => i);
+ const latest = spans[spans.length - 1];
+ const want = (T.session !== undefined && spans.find(s => s.session === T.session)) || latest;
+ const out = [];
+ T.steps.forEach((ev, i) => {
+ const gi = A.of.get(ev.seq);
+ if (gi === undefined ? want === latest : want.groups.has(gi)) out.push(i);
+ });
+ return out;
+ }
+
+ // the session a group of steps belongs to
+ const sessionOf = gi => (sessionSpans().find(s => s.groups.has(gi)) || {}).session;
+
+ function drawTimeline() {
+ const bar = $('timeline');
+ const n = T.steps.length;
+ // with no edits yet there is no timeline, only the switches for what comes
+ bar.hidden = false;
+ bar.classList.toggle('empty', !n);
+ document.body.classList.toggle('with-timeline', n > 0);
+ document.body.classList.toggle('with-tl-switches', !n);
+ const ticks = $('tl-ticks');
+ ticks.textContent = '';
+ let run;
+ // one session at a time - a long history drawn whole is what lags
+ const shown = shownSteps();
+ shown.forEach((i, k) => {
+ const ev = T.steps[i];
+ const gi = A.of.get(ev.seq);
+ // each ask's steps sit together, set off from the next
+ if (k > 0 && gi !== run) ticks.append(h('b', 'tl-sep'));
+ run = gi;
+ const g = A.groups[gi];
+ const t = h('i', `tick ${ev.status}${byHand(ev) ? ' human' : ''}${layoutOnly(ev) ? ' ws' : ''}${i === T.at ? ' at' : ''}`);
+ t.title = `${g ? groupText(g) + '\n' : ''}#${ev.seq} ${ev.status} · ${ev.tool} · ${summary(ev)}${why(ev) ? ' · ' + why(ev) : ''}`;
+ t.dataset.i = i;
+ ticks.append(t);
+ });
+ const ev = T.at != null ? T.steps[T.at] : null;
+ const label = $('tl-step');
+ const who = ev && byHand(ev) ? [person()] : [];
+ // a part says what it is about before why its step was taken
+ const text = ev && `#${ev.seq} ${ev.status} · ${[partOf(ev), why(ev)].filter(Boolean).join(' · ') || summary(ev)}${ev.whitespace ? ' · layout only' : ''}`;
+ label.replaceChildren(...who, text || 'No edits yet - every change lands here, by ccc’s edit tools or by hand');
+ label.title = ev ? `${STATUS[ev.status]} · ${ev.tool} · ${summary(ev)}${ev.changeset ? ' · changeset ' + ev.changeset : ''} - click for its diff` : '';
+ label.disabled = !ev;
+ const g = groupOf(ev);
+ const chip = $('tl-ask');
+ chip.hidden = !A.groups.length;
+ chip.replaceChildren(...(g ? [groupIcon(g)] : []), h('span', 'ask-label', g ? groupText(g) : 'Asks'));
+ chip.title = `${g ? groupText(g) + '\n\n' : ''}Every ask and what it changed - [ and ] step between them`;
+ const cur = ev ? A.of.get(ev.seq) : undefined;
+ for (const row of document.querySelectorAll('#panel .ask-row')) row.classList.toggle('at', +row.dataset.g === cur);
+ const k = shown.indexOf(T.at);
+ $('tl-prev').disabled = !shown.length || k === 0;
+ $('tl-next').disabled = !shown.length || T.at == null || k >= shown.length - 1;
+ $('tl-play').disabled = !n;
+ $('tl-play').textContent = T.playing ? '❚❚' : '▶';
+ $('tl-play').title = T.playing ? 'Pause' : 'Replay the steps from here';
+ const full = ev && T.full.get(ev.seq);
+ $('tl-side').hidden = !T.replay;
+ for (const b of $('tl-side').children) {
+ b.setAttribute('aria-pressed', String(T.side === b.dataset.side));
+ b.disabled = !(full && full[b.dataset.side]);
+ }
+ const live = T.follow && (!T.replay || T.replay.live);
+ $('tl-follow').setAttribute('aria-pressed', String(live));
+ // slowed, following runs behind by the steps still waiting their turn
+ $('tl-follow').textContent = live ? (T.queue.length ? `Following · ${T.queue.length} behind` : 'Following') : 'Follow';
+ $('tl-ws').setAttribute('aria-pressed', String(T.ws));
+ $('tl-speed').textContent = SPEED_LABEL[T.speed];
+ $('tl-focus').setAttribute('aria-pressed', String(T.focus));
+ const badge = $('replay-badge');
+ badge.hidden = !T.replay || !ev;
+ if (!badge.hidden) {
+ badge.replaceChildren(
+ ...(byHand(ev) ? [person()] : []),
+ T.replay.live
+ ? `Proposed · step #${ev.seq}${why(ev) ? ' · ' + why(ev) : ' - staged, not written yet'}`
+ : `Replay · step #${ev.seq} ${ev.status} · ${T.side} it${why(ev) ? ' · ' + why(ev) : ''}`,
+ );
+ badge.title = (ev.intent || []).join('\n');
+ }
+ }
+
+ function diffView(ev) {
+ const box = h('div', 'diff');
+ for (const f of ev.files) {
+ const head = h('div', 'diff-file');
+ head.append(h('b', null, f.path), h('span', null, ` +${f.added} −${f.removed}`));
+ const pre = h('pre');
+ for (const line of f.diff) {
+ const cls = /^(\+\+\+|---)/.test(line) ? 'meta' : line.startsWith('@@') ? 'hunk' : line[0] === '+' ? 'add' : line[0] === '-' ? 'del' : null;
+ pre.append(h('span', cls, line + '\n'));
+ }
+ if (f.diff_truncated) pre.append(h('span', 'meta', '… cut short\n'));
+ box.append(head, pre);
+ }
+ if (ev.files_total > ev.files.length) box.append(h('p', 'hint', `and ${plural(ev.files_total - ev.files.length, 'more file')}`));
+ return box;
+ }
- // the server rescans a few seconds after a save; when the generation moves,
- // redraw the same view without losing the zoom
+ function panelEvent(ev) {
+ select(null);
+ const look = ev.status === 'read';
+ panel((look ? 'Look #' : 'Edit step #') + ev.seq, `${ev.status} · ${ev.tool}`, body => {
+ const by = byHand(ev) ? 'a person, by hand' : look ? 'an agent, through ccc’s read tools' : 'an agent, through ccc’s edit tools';
+ const rows = [['status', STATUS[ev.status]], ['by', by]];
+ if (ev.intent && ev.intent.length) rows.push([look ? 'asked' : 'why', ev.intent.join(' · ')]);
+ if (ev.part) rows.push(['part', `${ev.part.index + 1} of ${ev.part.of} - ${ev.part.kind} ${ev.part.change}`]);
+ const g = groupOf(ev);
+ if (g && g.ask) rows.push(['ask', g.ask.prompt]);
+ if (ev.changeset) rows.push(['changeset', ev.changeset]);
+ rows.push(['at', new Date(ev.at).toLocaleTimeString()], ['files', ev.files_total]);
+ body.append(dl(rows));
+ if (ev.functions.length) {
+ const rows = ev.functions.map(t => {
+ const pos = t[ev.on_disk];
+ const route = pos ? { level: 'flow', file: t.file, line: pos.line, name: t.name } : { level: 'code', file: t.file };
+ const row = h('span', 'chg-row');
+ row.append(h('span', 'chg-tag ' + (t.whitespace ? 'ws' : t.change), t.whitespace ? 'layout' : t.change), goBtn(`${t.owner ? t.owner + '::' : ''}${t.name} ${base(t.file)}`, route));
+ return row;
+ });
+ section(body, look ? 'Looked at' : 'Functions', list(rows));
+ }
+ if (ev.ops.length) section(body, 'Operations', list(ev.ops.map(o => h('code', null, o))));
+ if (look) section(body, 'Files', list(ev.files.map(f => goBtn(f.path, { level: 'code', file: f.path }))));
+ else section(body, 'Diff', diffView(ev));
+ });
+ S.stepPanel = true;
+ }
+
+ // Every ask the timeline holds, in order, under the session it was made in:
+ // pick one to walk through its steps, or play a whole session through.
+ function panelAsks() {
+ const keep = S.asksPanel ? $('panel-body').scrollTop : 0;
+ select(null);
+ const asks = new Set(A.groups.filter(g => g.ask).map(g => g.ask.id)).size;
+ panel('Asks', `${plural(asks, 'ask')} · ${plural(T.steps.length, 'step')}`, body => {
+ if (!A.groups.length) body.append(h('p', 'hint', 'No steps yet - each ask an agent works on lands here, and every change made by hand'));
+ const cur = A.of.get(T.steps[T.at]?.seq);
+ let session;
+ A.groups.forEach((g, gi) => {
+ if (g.ask && g.ask.session !== session) {
+ session = g.ask.session;
+ const s = session;
+ const head = h('div', 'ask-session');
+ head.append(h('span', null, `${g.ask.agent} session · ${new Date(g.ask.at).toLocaleString()}`));
+ const all = h('button', null, '▶ Play session');
+ all.title = 'Replay every step from this session’s first ask to its last';
+ all.addEventListener('click', () => playSession(s));
+ head.append(all);
+ body.append(head);
+ }
+ const row = h('div', 'ask-row' + (gi === cur ? ' at' : ''));
+ row.dataset.g = gi;
+ const go = h('button', 'ask-go');
+ go.append(groupIcon(g), h('span', 'ask-text', groupText(g)), h('span', 'ask-n', plural(g.steps.length, 'step')));
+ go.title = groupText(g);
+ go.addEventListener('click', () => goGroup(gi));
+ const one = h('button', 'ask-play', '▶');
+ one.title = 'Replay this ask’s steps';
+ one.addEventListener('click', () => play(indicesOf(g.steps)));
+ row.append(go, one);
+ body.append(row);
+ });
+ });
+ S.asksPanel = true;
+ $('panel-body').scrollTop = keep;
+ }
+
+ // where steps sit on the timeline, by their numbers
+ const indicesOf = seqs => seqs.map(seq => T.steps.findIndex(e => e.seq === seq)).filter(i => i >= 0);
+
+ // a group, from its first step
+ function goGroup(gi) {
+ const g = A.groups[gi];
+ const [i] = g ? indicesOf(g.steps) : [];
+ if (i == null) return;
+ stopPlay();
+ T.session = sessionOf(gi);
+ replay(i, 'after');
+ }
+
+ // the ask before or after the one on show
+ function groupBy(d) {
+ if (!A.groups.length) return;
+ const cur = A.of.get(T.steps[T.at]?.seq);
+ const from = cur == null ? (d > 0 ? -1 : A.groups.length) : cur;
+ goGroup(clamp(from + d, 0, A.groups.length - 1));
+ }
+
+ // every step from a session's first ask to its last, changes by hand between them included
+ function playSession(session) {
+ const at = A.groups.map((g, i) => (g.ask && g.ask.session === session ? i : -1)).filter(i => i >= 0);
+ if (!at.length) return;
+ stopPlay();
+ T.session = session;
+ play(indicesOf(A.groups.slice(at[0], at[at.length - 1] + 1).flatMap(g => g.steps)));
+ }
+
+ function bindTimeline() {
+ const track = $('tl-track');
+ let scrubbing = false;
+ let timer = 0;
+ const pick = x => {
+ if (!T.steps.length) return;
+ // the tick under the pointer - ticks are packed left, so a share of the
+ // track's width is not a step
+ const ticks = [...$('tl-ticks').querySelectorAll('.tick')];
+ let k = ticks.findIndex(t => x < t.getBoundingClientRect().right);
+ if (k < 0) k = ticks.length - 1;
+ if (k < 0) return;
+ const i = +ticks[k].dataset.i;
+ if (i === T.at && T.replay) return;
+ stopPlay();
+ T.follow = false;
+ T.at = i;
+ drawTimeline();
+ // a fast scrub draws only where it comes to rest
+ clearTimeout(timer);
+ timer = setTimeout(() => replay(i, T.side), 110);
+ };
+ track.addEventListener('pointerdown', e => {
+ scrubbing = true;
+ track.setPointerCapture(e.pointerId);
+ pick(e.clientX);
+ });
+ track.addEventListener('pointermove', e => {
+ if (scrubbing) pick(e.clientX);
+ });
+ track.addEventListener('pointerup', () => (scrubbing = false));
+ track.addEventListener('pointercancel', () => (scrubbing = false));
+ track.addEventListener('keydown', e => {
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ e.preventDefault();
+ e.stopPropagation();
+ stepBy(e.key === 'ArrowLeft' ? -1 : 1);
+ }
+ });
+ $('tl-prev').addEventListener('click', () => stepBy(-1));
+ $('tl-next').addEventListener('click', () => stepBy(1));
+ $('tl-play').addEventListener('click', () => play());
+ $('tl-follow').addEventListener('click', () => followLive());
+ $('tl-speed').addEventListener('click', e => {
+ // shift steps back up through the speeds
+ const i = SPEEDS.indexOf(T.speed);
+ setReplay(SPEEDS[(i + (e.shiftKey ? SPEEDS.length - 1 : 1)) % SPEEDS.length]);
+ });
+ $('tl-focus').addEventListener('click', () => setReplay(undefined, !T.focus));
+ $('tl-ask').addEventListener('click', () => (S.asksPanel ? hidePanel() : panelAsks()));
+ $('tl-ws').addEventListener('click', () => {
+ T.ws = !T.ws;
+ store.set('ws', T.ws);
+ applyMarks();
+ drawTimeline();
+ });
+ $('tl-step').addEventListener('click', () => {
+ const ev = T.steps[T.at];
+ if (ev) panelEvent(ev);
+ });
+ for (const b of $('tl-side').children) {
+ b.addEventListener('click', () => {
+ if (T.at != null) replay(T.at, b.dataset.side);
+ });
+ }
+ }
+
+ // One request a second carries both: new edit steps, and the map's
+ // generation - which a save or a branch switch moves without any step.
async function poll() {
+ let wait = 1000;
try {
- const health = await getJSON('/health');
- if (S.generated && health.generated !== S.generated) {
+ let r = await getJSON('/vis/events?since=' + T.seq);
+ if (r.boot !== T.boot) {
+ // the server started again - perhaps a newer build, serving a newer
+ // page than this one - so the page is loaded afresh; a host that
+ // cannot do that leaves this one running, its numbering started over
+ if (T.boot != null && !T.reloading) {
+ T.reloading = true;
+ if (vscode) vscode.postMessage({ type: 'reload' });
+ else location.reload();
+ }
+ if (T.boot != null && T.seq) r = await getJSON('/vis/events?since=0');
+ T.boot = r.boot;
+ T.steps = [];
+ T.full.clear();
+ T.at = null;
+ A.groups = [];
+ A.of = new Map();
+ }
+ const fresh = r.events || [];
+ T.steps.push(...fresh);
+ // past the cap a look goes before an edit does, as on the server
+ while (T.steps.length > MAX_TIMELINE) {
+ const look = T.steps.slice(0, T.steps.length >> 1).findIndex(e => e.status === 'read');
+ const cut = look < 0 ? 0 : look;
+ T.steps.splice(cut, 1);
+ if (T.at != null && T.at > cut) T.at--;
+ }
+ T.seq = r.latest;
+ if (fresh.length) asksSoon(T.primed ? 2500 : 0);
+ if (!T.primed) {
+ T.primed = true;
+ if (T.steps.length) T.at = T.steps.length - 1;
+ drawTimeline();
+ } else if (fresh.length) {
+ // each takes its turn at the chosen speed, the polling going on meanwhile
+ if (T.follow && !T.playing) {
+ T.queue.push(...fresh);
+ pump();
+ } else drawTimeline();
+ } else if (S.generated && r.generated !== S.generated && !T.replay) {
S.overview = null;
- S.generated = health.generated;
await render(true);
}
} catch {
- // not answering right now - the next poll tries again
+ // not answering right now - try again a little later
+ wait = 4000;
}
- setTimeout(poll, 4000);
+ setTimeout(poll, wait);
}
chips();
bindCanvas();
bindChrome();
+ bindTimeline();
applyView();
- render(false).then(() => setTimeout(poll, 4000));
+ if (vscode && !location.hash) {
+ const saved = store.get('hash', '');
+ try {
+ if (saved) history.replaceState(null, '', saved);
+ } catch {
+ // the default view will do
+ }
+ }
+ drawTimeline();
+ render(false).then(poll);
})();
diff --git a/docs/EXTENSION.md b/docs/EXTENSION.md
index 6761fe1..6022aca 100644
--- a/docs/EXTENSION.md
+++ b/docs/EXTENSION.md
@@ -44,7 +44,7 @@ the ten colours are contributed theme colours, so a theme or a `workbench.colorC
can restyle any of them.
Click the **Collateral Code Check** mark in the activity bar to open the panel, and again to close it. It has
-two views. **Triggers** is the tests your changes invoke: a triggered test usually lives in a
+three views. **Triggers** is the tests your changes invoke: a triggered test usually lives in a
different file from the change that triggered it, so this is the only place that shows the whole set
- **Run these** (click to open; the tooltip carries how many call hops it sits from the change and
why), **No test covers**, and **Commands** - the suggested command for running exactly that set,
@@ -57,6 +57,20 @@ but hidden by default; the beaker button shows them, and the clear button appear
filter is active. The view's subtitle always says how many of the measured functions you are
looking at, so a filtered list can never pass itself off as the whole map.
+**Visualiser** is the [architecture visualiser](../README.md#architecture-visualiser) - the system,
+its containers, components, code and the logic of each function - with the timeline of ccc's edit
+steps along its foot. While an agent edits through ccc's tools, it flies to each change as it lands
+and marks it, and any step can be replayed before and after. A staged change is drawn as
+**Proposed** - the code as it would be - before anything is written, beside the one-line intent the
+agent gave for it. Changes saved by hand land on the timeline too, marked with a person. Each edit
+plays a definition at a time and an agent's reads land as looks, at the pace the speed button sets -
+it is the same page as in a browser, so it behaves the same there (see the
+[README](../README.md#architecture-visualiser)). A change
+that only moves whitespace or wraps lines is not marked unless ¶ is on. Its title-bar buttons open the same
+page as a tab beside the editor (`ccc: Open Architecture Visualiser`), where a long function has
+room, or in a browser. "Open in editor" in its panels opens the file at the line. The page talks to
+the analyser through the extension, so it works the same when the workspace is remote.
+
The status bar entry on the right is the summary - counts, the base ref being compared, and, when a
file has no marks at all, which of the two reasons applies: nothing in it changed, or it is not in
the ccc map.
diff --git a/docs/MCP.md b/docs/MCP.md
index f32eb95..4d937ec 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -51,4 +51,6 @@ What every write is held to:
`file://` report) can read the map but never write through it.
- **Attributed.** Each applied changeset is appended to `.ccc/edits.jsonl`, which is kept out of git. `ccc prompts`
reads it to credit a ccc edit to the request behind it, as strongly as it credits an `Edit` or `Write`.
+- **Shared.** Every step - staged, applied, reverted, and each look a read tool took - is appended to
+ `.ccc/timeline.jsonl`, also kept out of git, so the visualiser of any ccc server on the project shows it.
diff --git a/extensions/vscode/media/ccc.svg b/extensions/vscode/media/ccc.svg
new file mode 100644
index 0000000..68084ea
--- /dev/null
+++ b/extensions/vscode/media/ccc.svg
@@ -0,0 +1 @@
+
diff --git a/extensions/vscode/media/run.svg b/extensions/vscode/media/run.svg
new file mode 100644
index 0000000..30a8c9c
--- /dev/null
+++ b/extensions/vscode/media/run.svg
@@ -0,0 +1 @@
+
diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json
index 7975abc..0bdb8c8 100644
--- a/extensions/vscode/package.json
+++ b/extensions/vscode/package.json
@@ -124,6 +124,18 @@
"title": "Open Insights UI in Browser",
"category": "ccc"
},
+ {
+ "command": "ccc.openVisualiser",
+ "title": "Open Architecture Visualiser",
+ "category": "ccc",
+ "icon": "$(open-preview)"
+ },
+ {
+ "command": "ccc.openVisualiserInBrowser",
+ "title": "Open Architecture Visualiser in Browser",
+ "category": "ccc",
+ "icon": "$(link-external)"
+ },
{
"command": "ccc.selectBaseRef",
"title": "Select Git Base Ref...",
@@ -283,6 +295,16 @@
}
],
"view/title": [
+ {
+ "command": "ccc.openVisualiser",
+ "when": "view == ccc.visualiser",
+ "group": "navigation@1"
+ },
+ {
+ "command": "ccc.openVisualiserInBrowser",
+ "when": "view == ccc.visualiser",
+ "group": "navigation@2"
+ },
{
"command": "ccc.refreshTestTriggers",
"when": "view == ccc.testTriggers",
@@ -517,7 +539,7 @@
{
"id": "ccc",
"title": "Collateral Code Check",
- "icon": "media/icon.png"
+ "icon": "media/run.svg"
}
]
},
@@ -530,6 +552,11 @@
{
"id": "ccc.complexity",
"name": "Complexity"
+ },
+ {
+ "type": "webview",
+ "id": "ccc.visualiser",
+ "name": "Visualiser"
}
]
}
diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts
index 5c0f2ad..37775d5 100644
--- a/extensions/vscode/src/client.ts
+++ b/extensions/vscode/src/client.ts
@@ -25,6 +25,11 @@ export function isAborted(err: unknown): boolean {
return err instanceof AbortedError;
}
+export interface RawReply {
+ status: number;
+ body: string;
+}
+
const TIMEOUT_FAST_MS = 5000;
// a cold rescan of a large repo is slow and waiting beats failing
const TIMEOUT_SLOW_MS = 60000;
@@ -82,12 +87,27 @@ export class CccClient {
return this.request('POST', '/refresh', TIMEOUT_SLOW_MS, signal);
}
+ // a GET answered as it came, any status - the visualiser page reads its own errors
+ raw(path: string, signal?: AbortSignal): Promise {
+ return this.send('GET', path, TIMEOUT_SLOW_MS, signal);
+ }
+
private getJson(path: string, timeoutMs: number, signal?: AbortSignal): Promise {
return this.request('GET', path, timeoutMs, signal);
}
- private request(method: string, path: string, timeoutMs: number, signal?: AbortSignal): Promise {
- return new Promise((resolve, reject) => {
+ private async request(method: string, path: string, timeoutMs: number, signal?: AbortSignal): Promise {
+ const { status, body } = await this.send(method, path, timeoutMs, signal);
+ if (status < 200 || status >= 300) throw new CccHttpError(status, path, body);
+ try {
+ return JSON.parse(body) as T;
+ } catch (err) {
+ throw new Error(`ccc ${path} returned malformed JSON: ${String(err)}`);
+ }
+ }
+
+ private send(method: string, path: string, timeoutMs: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new AbortedError());
return;
@@ -111,15 +131,7 @@ export class CccClient {
const body = Buffer.concat(chunks).toString('utf8');
const status = res.statusCode ?? 0;
this.log.trace(`${method} ${path} -> ${status} ${body.length}b in ${Date.now() - started}ms`);
- if (status < 200 || status >= 300) {
- reject(new CccHttpError(status, path, body));
- return;
- }
- try {
- resolve(JSON.parse(body) as T);
- } catch (err) {
- reject(new Error(`ccc ${path} returned malformed JSON: ${String(err)}`));
- }
+ resolve({ status, body });
});
},
);
diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts
index f0b9536..b665c1f 100644
--- a/extensions/vscode/src/extension.ts
+++ b/extensions/vscode/src/extension.ts
@@ -11,6 +11,7 @@ import { WorkspaceSession } from './session';
import { type ActiveFileState, StatusBar } from './status';
import { ComplexityPanel } from './complexitypanel';
import { TestTriggerPanel } from './testpanel';
+import { VisualiserView } from './visview';
import { VulnerabilityMarks } from './vulns';
// re-applying decorations while typing is cheap but not free
@@ -46,6 +47,7 @@ class Extension implements CommandHost {
private readonly testPanel: TestTriggerPanel;
private readonly vulns = new VulnerabilityMarks();
private readonly complexityPanel: ComplexityPanel;
+ private readonly visualiser: VisualiserView;
private cfg: Cfg;
private lastFocusRefresh = 0;
private dirtyTimer: NodeJS.Timeout | undefined;
@@ -86,6 +88,11 @@ class Extension implements CommandHost {
() => this.sessions(),
() => void this.wake(),
);
+ // opening the visualiser is intent enough to start the analyser, as a panel is
+ this.visualiser = new VisualiserView(async () => {
+ await this.wake();
+ return this.activeSession();
+ }, this.log);
}
async start(): Promise {
@@ -105,6 +112,21 @@ class Extension implements CommandHost {
this.vulns,
vscode.languages.registerCodeLensProvider({ scheme: 'file' }, this.codeLens),
vscode.languages.registerHoverProvider({ scheme: 'file' }, this.hover),
+ this.visualiser,
+ // kept alive while hidden, so the edit timeline and where it was looking survive
+ vscode.window.registerWebviewViewProvider(VisualiserView.viewId, this.visualiser, {
+ webviewOptions: { retainContextWhenHidden: true },
+ }),
+ vscode.commands.registerCommand('ccc.openVisualiser', () => this.visualiser.openPanel()),
+ vscode.commands.registerCommand('ccc.openVisualiserInBrowser', async () => {
+ await this.wake();
+ const url = this.activeSession()?.visualiserUrl;
+ if (!url) {
+ void vscode.window.showWarningMessage('ccc: the analyser is not running yet.');
+ return;
+ }
+ await vscode.env.openExternal(vscode.Uri.parse(url));
+ }),
vscode.commands.registerCommand('ccc.refreshTestTriggers', () =>
this.refreshAll('test triggers panel'),
),
diff --git a/extensions/vscode/src/mcpconfig.ts b/extensions/vscode/src/mcpconfig.ts
index f4f8001..3f717ad 100644
--- a/extensions/vscode/src/mcpconfig.ts
+++ b/extensions/vscode/src/mcpconfig.ts
@@ -43,6 +43,25 @@ export async function publishMcpConfig(
}
}
+// The port agents were last told for this folder, when it is on `host`. Agents
+// keep the address they were given when their session began, so a restarted
+// analyser asks for this one again rather than strand them.
+export async function publishedPort(folder: vscode.Uri, host: string): Promise {
+ // the address claude code was given - copilot's file says the same
+ const doc = await readJson(path.join(folder.fsPath, '.mcp.json')).catch(() => undefined);
+ if (doc === undefined || doc === MALFORMED) return undefined;
+ const entry = asRecord(asRecord(doc['mcpServers'])?.[SERVER_KEY]);
+ const url = entry?.['url'];
+ if (typeof url !== 'string') return undefined;
+ try {
+ const parsed = new URL(url);
+ const port = Number.parseInt(parsed.port, 10);
+ return parsed.hostname === host && port > 0 ? port : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
// returns true when the file was rewritten, false when it already said the right thing
async function writeOne(file: string, target: Target, url: string): Promise {
const existing = await readJson(file);
diff --git a/extensions/vscode/src/server.ts b/extensions/vscode/src/server.ts
index 6568321..a5d753f 100644
--- a/extensions/vscode/src/server.ts
+++ b/extensions/vscode/src/server.ts
@@ -1,10 +1,11 @@
import { type ChildProcessByStdio, spawn } from 'node:child_process';
+import * as net from 'node:net';
import type { Readable } from 'node:stream';
import * as vscode from 'vscode';
import { CccBinaryError, resolveCccBinary } from './binary';
import type { Cfg } from './config';
import { describe, type Log } from './log';
-import { publishMcpConfig } from './mcpconfig';
+import { publishedPort, publishMcpConfig } from './mcpconfig';
export interface ServerAddress {
host: string;
@@ -30,6 +31,9 @@ const MAX_FAILURES = 5;
const FAILURE_WINDOW_MS = 5 * 60 * 1000;
const STABLE_UPTIME_MS = 60 * 1000;
const STDERR_TAIL = 20;
+// how long a restart waits for the analyser before it to let go of its port
+const PORT_WAIT_MS = 2000;
+const PORT_POLL_MS = 200;
// owns one `ccc serve` child per folder - private to this window so a user's own server is undisturbed
export class ServerProcess implements vscode.Disposable {
@@ -83,14 +87,15 @@ export class ServerProcess implements vscode.Disposable {
async restart(): Promise {
this.clearRetry();
this.failures = [];
- this.kill();
+ // gone before the next asks for its port
+ await this.kill();
this.setState({ kind: 'stopped' });
return this.start();
}
stop(): void {
this.clearRetry();
- this.kill();
+ void this.kill();
this.setState({ kind: 'stopped' });
}
@@ -109,13 +114,14 @@ export class ServerProcess implements vscode.Disposable {
throw err;
}
+ const port = await this.stickyPort();
const args = [
this.folder.fsPath,
'--html',
'--addr',
this.cfg.server.address,
'--port',
- String(this.cfg.server.port),
+ String(port),
...(this.cfg.server.watchIntervalSec === 0
? ['--no-watch']
: ['--watch-interval', String(this.cfg.server.watchIntervalSec)]),
@@ -137,12 +143,28 @@ export class ServerProcess implements vscode.Disposable {
this.wireExit(child);
this.setState({ kind: 'running', address, pid: child.pid, startedAt: Date.now() });
this.log.info(`[${this.label}] analyser ready on ${address.base} (pid ${child.pid ?? '?'})`);
- // the port is ephemeral, so republish it for agents on every start
+ // republished on every start, in case the port had to move
// deliberately not awaited - a slow disk must not hold up a server that is already serving
void publishMcpConfig(this.folder, address, this.log);
return address;
}
+ // The port this folder's analyser had, so agents already talking to it are
+ // not left holding a dead address - waiting a moment for the analyser before
+ // to let go of it, and taking a free one only when it stays taken. A port
+ // set in the settings is always the one asked for.
+ private async stickyPort(): Promise {
+ if (this.cfg.server.port !== 0) return this.cfg.server.port;
+ const last = await publishedPort(this.folder, this.cfg.server.address);
+ if (last === undefined) return 0;
+ for (let waited = 0; waited <= PORT_WAIT_MS; waited += PORT_POLL_MS) {
+ if (await portFree(this.cfg.server.address, last)) return last;
+ await new Promise((resolve) => setTimeout(resolve, PORT_POLL_MS));
+ }
+ this.log.info(`[${this.label}] port ${last} stayed taken, so the analyser takes a free one`);
+ return 0;
+ }
+
private awaitListening(child: CccChild): Promise {
return new Promise((resolve, reject) => {
let settled = false;
@@ -262,10 +284,13 @@ export class ServerProcess implements vscode.Disposable {
}
}
- private kill(): void {
+ // stop the analyser, settling once it has gone
+ private kill(): Promise {
const child = this.child;
- if (!child) return;
+ if (!child) return Promise.resolve();
this.child = undefined;
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
+ const gone = new Promise((resolve) => child.once('exit', () => resolve()));
try {
child.kill('SIGTERM');
} catch {
@@ -280,17 +305,27 @@ export class ServerProcess implements vscode.Disposable {
}
}, 2000);
child.once('exit', () => clearTimeout(hard));
+ return gone;
}
dispose(): void {
this.disposing = true;
this.clearRetry();
- this.kill();
+ void this.kill();
process.removeListener('exit', this.exitGuard);
this.emitter.dispose();
}
}
+// can `port` on `host` be bound right now
+function portFree(host: string, port: number): Promise {
+ return new Promise((resolve) => {
+ const probe = net.createServer();
+ probe.once('error', () => resolve(false));
+ probe.listen({ port, host, exclusive: true }, () => probe.close(() => resolve(true)));
+ });
+}
+
// exported for the port-parsing edge cases (IPv6, non-default hosts)
export function parseListening(line: string): ServerAddress | undefined {
const match = LISTENING.exec(line.trim());
diff --git a/extensions/vscode/src/session.ts b/extensions/vscode/src/session.ts
index 1c27bc4..4eb7f9e 100644
--- a/extensions/vscode/src/session.ts
+++ b/extensions/vscode/src/session.ts
@@ -1,6 +1,6 @@
import * as path from 'node:path';
import * as vscode from 'vscode';
-import { CccClient, isAborted } from './client';
+import { CccClient, isAborted, type RawReply } from './client';
import { type Cfg, needsRebuild, needsServerRestart } from './config';
import { FileStructureCache, refineFileHints } from './enclosing';
import { describe, type Log } from './log';
@@ -269,6 +269,20 @@ export class WorkspaceSession implements vscode.Disposable {
return address ? `${address.base}/insights` : undefined;
}
+ get visualiserUrl(): string | undefined {
+ const address = this.server.address;
+ return address ? `${address.base}/vis` : undefined;
+ }
+
+ // The visualiser's requests, passed through to this folder's analyser as
+ // they are. It asks every second, so a running analyser is used as it is -
+ // starting one schedules a full refresh.
+ async fetchRaw(path: string): Promise {
+ if (!this.client) await this.ensureStarted();
+ if (!this.client) throw new Error('the ccc analyser is not running');
+ return this.client.raw(path);
+ }
+
async restartServer(): Promise {
this.client?.dispose();
this.client = undefined;
diff --git a/extensions/vscode/src/visview.ts b/extensions/vscode/src/visview.ts
new file mode 100644
index 0000000..d97882d
--- /dev/null
+++ b/extensions/vscode/src/visview.ts
@@ -0,0 +1,151 @@
+import { randomBytes } from 'node:crypto';
+import * as vscode from 'vscode';
+import { describe, type Log } from './log';
+import type { WorkspaceSession } from './session';
+
+// what the page may ask the analyser for: its own levels, the edit timeline and search
+const ALLOWED = ['/vis', '/find?'];
+
+// The architecture visualiser in VS Code - the analyser's own `/vis` page,
+// as a view in the ccc side bar and as a tab beside the editor. The page asks
+// this side for everything it reads and for files to open, so it works
+// wherever the analyser runs, local or remote, and follows each edit ccc's
+// tools make as it lands.
+export class VisualiserView implements vscode.WebviewViewProvider, vscode.Disposable {
+ static readonly viewId = 'ccc.visualiser';
+ private panel: vscode.WebviewPanel | undefined;
+
+ constructor(
+ private readonly session: () => Promise,
+ private readonly log: Log,
+ ) {}
+
+ resolveWebviewView(view: vscode.WebviewView): Promise {
+ const listening = this.attach(view.webview, undefined);
+ view.onDidDispose(() => listening.then((d) => d.dispose()));
+ return listening.then(() => undefined);
+ }
+
+ // the same page as an editor tab, with room to read a long function's logic
+ async openPanel(): Promise {
+ if (this.panel) {
+ this.panel.reveal(vscode.ViewColumn.Beside);
+ return;
+ }
+ const panel = vscode.window.createWebviewPanel(
+ 'ccc.visualiserPanel',
+ 'ccc Visualiser',
+ vscode.ViewColumn.Beside,
+ { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [] },
+ );
+ this.panel = panel;
+ const listening = this.attach(panel.webview, vscode.ViewColumn.One);
+ panel.onDidDispose(() => {
+ this.panel = undefined;
+ void listening.then((d) => d.dispose());
+ });
+ }
+
+ private async attach(webview: vscode.Webview, openIn: vscode.ViewColumn | undefined): Promise {
+ webview.options = { enableScripts: true, localResourceRoots: [] };
+ webview.html = notice('Starting the ccc analyser…');
+ const session = await this.session();
+ if (!session) {
+ webview.html = notice('Open a folder ccc can map to see its architecture here.');
+ return new vscode.Disposable(() => undefined);
+ }
+ const listener = webview.onDidReceiveMessage((m: unknown) => void this.onMessage(webview, session, openIn, m));
+ await this.load(webview, session);
+ return listener;
+ }
+
+ // The page as the analyser serves it now. The webview keeps the copy it was
+ // given, so a restarted analyser - perhaps a newer build - would otherwise
+ // be shown through the page of the one before it; the page asks for this
+ // again when it sees the analyser start over.
+ private async load(webview: vscode.Webview, session: WorkspaceSession): Promise {
+ try {
+ const page = await session.fetchRaw('/vis');
+ webview.html = page.status === 200 ? withPolicy(page.body) : notice(`The analyser would not serve the visualiser: ${errorOf(page.body)}`);
+ } catch (err) {
+ this.log.error('could not load the visualiser', err);
+ webview.html = notice(`Could not reach the ccc analyser: ${describe(err)}`);
+ }
+ }
+
+ private async onMessage(
+ webview: vscode.Webview,
+ session: WorkspaceSession,
+ openIn: vscode.ViewColumn | undefined,
+ raw: unknown,
+ ): Promise {
+ const m = raw as { type?: string; id?: number; url?: string; file?: string; line?: number };
+ if (m.type === 'get' && typeof m.id === 'number' && typeof m.url === 'string') {
+ const url = m.url;
+ if (!ALLOWED.some((p) => url.startsWith(p))) {
+ void webview.postMessage({ type: 'res', id: m.id, error: `the visualiser may not ask for ${url}` });
+ return;
+ }
+ try {
+ const res = await session.fetchRaw(url);
+ void webview.postMessage({ type: 'res', id: m.id, status: res.status, body: res.body });
+ } catch (err) {
+ void webview.postMessage({ type: 'res', id: m.id, error: describe(err) });
+ }
+ } else if (m.type === 'open' && typeof m.file === 'string') {
+ await this.open(session, m.file, typeof m.line === 'number' ? m.line : 1, openIn);
+ } else if (m.type === 'reload') {
+ await this.load(webview, session);
+ }
+ }
+
+ // a file the page names, relative to the folder and never outside it
+ private async open(
+ session: WorkspaceSession,
+ file: string,
+ line: number,
+ column: vscode.ViewColumn | undefined,
+ ): Promise {
+ const parts = file.split('/').filter((p) => p !== '' && p !== '.');
+ if (parts.some((p) => p === '..') || file.startsWith('/')) return;
+ const at = Math.max(0, line - 1);
+ try {
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(session.root, ...parts));
+ await vscode.window.showTextDocument(doc, {
+ viewColumn: column,
+ preview: true,
+ selection: new vscode.Range(at, 0, at, 0),
+ });
+ } catch (err) {
+ void vscode.window.showWarningMessage(`ccc: could not open ${file}: ${describe(err)}`);
+ }
+ }
+
+ dispose(): void {
+ this.panel?.dispose();
+ }
+}
+
+// The page as served, locked down for a webview: nothing loads from anywhere,
+// and only its own inline script runs.
+function withPolicy(html: string): string {
+ const nonce = randomBytes(16).toString('base64');
+ const policy = `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'nonce-${nonce}';`;
+ return html
+ .replace('', `\n`)
+ .replace(/ String {
+ const ABC: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
+ for chunk in bytes.chunks(3) {
+ let n = chunk.iter().enumerate().fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
+ // a short last chunk fills out with `=`
+ for i in 0..4 {
+ out.push(if i <= chunk.len() { ABC[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
+ }
+ }
+ out
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1377,6 +2349,30 @@ mod tests {
}
}
+ // the logo rides in the page as base64, padded the standard way
+ #[test]
+ fn base64_matches_the_standard_alphabet_and_padding() {
+ assert_eq!(base64(b""), "");
+ assert_eq!(base64(b"f"), "Zg==");
+ assert_eq!(base64(b"fo"), "Zm8=");
+ assert_eq!(base64(b"foo"), "Zm9v");
+ assert_eq!(base64(b"foobar"), "Zm9vYmFy");
+ assert_eq!(base64(&[0xff, 0xfe, 0xfd]), "//79");
+ let html = page("demo");
+ assert!(!html.contains("__CCC_LOGO__") && html.contains("data:image/png;base64,iVBORw0KGgo"), "the logo is inlined");
+ }
+
+ // An address written before its file changed holds a stale line; the
+ // function's name finds it again, wherever it moved to.
+ #[test]
+ fn a_function_that_moved_is_found_again_by_its_name() {
+ let (p, caches, m) = project("moved", &[("src/a.rs", "fn one() {}\n\n\nfn two() {\n one();\n}\n")]);
+ let v = m.flow(&caches, &p.0, "src/a.rs", 2, Some("two")).unwrap();
+ assert_eq!((v["function"]["name"].as_str(), v["function"]["line"].as_u64()), (Some("two"), Some(4)));
+ assert!(m.flow(&caches, &p.0, "src/a.rs", 2, None).is_err(), "a stale line alone still names nothing");
+ assert!(m.flow(&caches, &p.0, "src/a.rs", 2, Some("ghost")).is_err());
+ }
+
fn flow_of(p: &Project, caches: &[FileCache], m: &Model, file: &str, name: &str) -> Vec {
let id = m.by_file[file]
.iter()
@@ -1727,6 +2723,114 @@ f :: proc(x: int) -> int {
assert!(m.flow(&caches, &p.0, "api/server.py", 99, None).is_err());
}
+ fn step(status: &'static str, files: &[(&str, Option<&str>, Option<&str>)]) -> edit::Activity {
+ edit::Activity {
+ changeset: "c1-test".into(),
+ status,
+ ops: vec!["text".into()],
+ intent: Vec::new(),
+ files: files
+ .iter()
+ .map(|(path, before, after)| edit::FileText {
+ path: path.to_string(),
+ before: before.map(str::to_string),
+ after: after.map(str::to_string),
+ })
+ .collect(),
+ }
+ }
+
+ fn lang_of(path: &str) -> Option {
+ Language::from_path(Path::new(path))
+ }
+
+ // Reflowing a function - re-indenting it, wrapping a long call - moves none
+ // of its logic: the step says so, and is seen where something did change.
+ #[test]
+ fn a_change_of_layout_alone_is_no_change() {
+ let before = "fn a(x: i32) -> i32 {\n b(x, 1)\n}\n\nfn b(x: i32, y: i32) -> i32 {\n x + y\n}\n";
+ let after = "fn a(x: i32) -> i32 {\n b(\n x,\n 1,\n )\n}\n\nfn b(x: i32, y: i32) -> i32 {\n x - y\n}\n";
+ let ev = event(1, 0, "edit_text", &step("applied", &[("src/a.rs", Some(before), Some(after))]), lang_of);
+ let ws: Vec<(&str, bool)> = ev.json["functions"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|t| (t["name"].as_str().unwrap(), t["whitespace"].as_bool().unwrap()))
+ .collect();
+ assert_eq!(ws, [("a", true), ("b", false)], "wrapping a call is layout, a new operator is not");
+ assert_eq!(ev.json["whitespace"], false);
+ // seen where its logic changed, not where it was only rewrapped
+ let Focus::Flow(t) = &ev.focus else { panic!("{:?}", ev.focus) };
+ assert_eq!(t.name, "b");
+
+ let spaced = before.replace("b(x, 1)", "b(x, 1)");
+ let only = event(2, 0, "edit_text", &step("applied", &[("src/a.rs", Some(before), Some(&spaced))]), lang_of);
+ assert_eq!(only.json["whitespace"], true);
+ assert_eq!(only.json["files"][0]["whitespace"], true);
+ }
+
+ // One line written into one function: the step is seen at that function's
+ // logic, both sides of it draw, and the lines it moved are named on each.
+ #[test]
+ fn an_edit_inside_one_function_is_seen_at_its_logic() {
+ let before = "fn a() {\n b();\n}\n\nfn b() {}\n\nfn c() {\n b();\n}\n";
+ let after = "fn a() {\n b();\n c();\n}\n\nfn b() {}\n\nfn c() {\n b();\n}\n";
+ let (_p, caches, m) = project("event-one", &[("src/calls.rs", before)]);
+ let s = step("staged", &[("src/calls.rs", Some(before), Some(after))]);
+ let ev = event(1, 0, "edit_text", &s, lang_of);
+ let Focus::Flow(t) = &ev.focus else { panic!("{:?}", ev.focus) };
+ assert_eq!((t.name.as_str(), t.change), ("a", "modified"));
+ assert_eq!((t.before, t.after), (Some((1, 1, 3)), Some((1, 1, 4))));
+ // the new call is line 3 after; before, it lands ahead of the `}` on line 3
+ assert_eq!((t.lines_before.as_slice(), t.lines_after.as_slice()), (&[(3, 3)][..], &[(3, 3)][..]));
+ assert_eq!(ev.json["on_disk"], "before");
+ assert_eq!((ev.json["files"][0]["added"].as_u64(), ev.json["files"][0]["removed"].as_u64()), (Some(1), Some(0)));
+ assert_eq!(ev.json["functions"].as_array().map(Vec::len), Some(1), "b and c only moved");
+
+ let calls = |v: Option>| {
+ let mut out = Vec::new();
+ shape(&v.expect("a snapshot")["steps"], &mut out);
+ out
+ };
+ assert_eq!(calls(m.snapshot(&caches, &s, &ev.focus, false)), ["call b"]);
+ // the step is not written, yet its logic draws from the text it carries
+ assert_eq!(calls(m.snapshot(&caches, &s, &ev.focus, true)), ["call b", "call c"]);
+ }
+
+ // a step widens from one function to its file, to the whole map, and to
+ // nothing the map draws as it touches more
+ #[test]
+ fn a_step_is_seen_at_the_narrowest_level_that_holds_it() {
+ let two = ("a.rs", Some("fn a() { x(); }\nfn b() { x(); }\n"), Some("fn a() { y(); }\nfn b() { y(); }\n"));
+ let doc = ("README.md", Some("# a\n"), Some("# b\n"));
+ let other = ("b.rs", Some("fn c() {}\n"), Some("fn c() { z(); }\n"));
+ let focus = |files: &[(&str, Option<&str>, Option<&str>)]| event(1, 0, "t", &step("applied", files), lang_of).focus;
+ assert_eq!(focus(&[two]), Focus::Code("a.rs".into()));
+ assert_eq!(focus(&[two, other]), Focus::Overview);
+ assert_eq!(focus(&[doc]), Focus::Nowhere);
+ // one function and a note elsewhere is still that function's logic
+ let Focus::Flow(t) = focus(&[other, doc]) else { panic!() };
+ assert_eq!(t.name, "c");
+
+ // a new function has no before side; where it lands is kept instead
+ let add = ("a.rs", Some("fn a() {}\n"), Some("fn a() {}\n\nfn n() { a(); }\n"));
+ let Focus::Flow(t) = focus(&[add]) else { panic!() };
+ assert_eq!((t.name.as_str(), t.change, t.before), ("n", "added", None));
+ assert_eq!(t.lines_before, [(2, 2)]);
+ let gone = ("a.rs", Some("fn a() {}\n\nfn n() { a(); }\n"), Some("fn a() {}\n"));
+ let Focus::Flow(t) = focus(&[gone]) else { panic!() };
+ assert_eq!((t.change, t.after), ("removed", None));
+
+ // a change inside a nested function is seen at the innermost one
+ let nested = (
+ "n.py",
+ Some("def outer():\n def inner():\n x()\n inner()\n"),
+ Some("def outer():\n def inner():\n y()\n inner()\n"),
+ );
+ let Focus::Flow(t) = focus(&[nested]) else { panic!() };
+ assert_eq!(t.name, "inner");
+ }
+
#[test]
fn a_callee_reads_as_its_name_and_qualifier() {
assert_eq!(split_callee("billing::charge"), (Some("billing".into()), "charge".into()));
@@ -1739,4 +2843,76 @@ f :: proc(x: int) -> int {
assert_eq!(arm_text("default: e();"), "default");
assert_eq!(arm_text("case: e()"), "");
}
+
+ // A step that adds an import, a constant and a function, and calls the new
+ // function from an old one, plays as a part per definition - imports, then
+ // what it adds, then what it changes - each part starting where the last
+ // left off, and the last leaving exactly what the step wrote.
+ #[test]
+ fn a_step_comes_apart_a_definition_at_a_time() {
+ let before = "use std::fs;\n\npub fn build(files: &[u8]) -> usize {\n let n = files.len();\n n\n}\n\nfn other() {}\n";
+ let after = "use std::fs;\nuse std::thread;\n\npub fn build(files: &[u8]) -> usize {\n let n = parse_all(files);\n n\n}\n\n// how many workers\nconst WORKERS: usize = 4;\n\n// parse on every core\nfn parse_all(files: &[u8]) -> usize {\n files.len() / WORKERS\n}\n\nfn other() {}\n";
+ let parts = split(&step("applied", &[("src/a.rs", Some(before), Some(after))]), lang_of);
+ let labels: Vec<(&str, Option<&str>, &str)> = parts
+ .iter()
+ .map(|(_, p)| {
+ let p = p.as_ref().expect("a part");
+ (p.kind, p.name.as_deref(), p.change)
+ })
+ .collect();
+ assert_eq!(
+ labels,
+ [
+ ("imports", None, "added"),
+ ("const", Some("WORKERS"), "added"),
+ ("function", Some("parse_all"), "added"),
+ ("function", Some("build"), "modified"),
+ ]
+ );
+ for w in parts.windows(2) {
+ assert_eq!(w[0].0.files[0].after, w[1].0.files[0].before);
+ }
+ assert_eq!(parts[0].0.files[0].before.as_deref(), Some(before));
+ assert_eq!(parts[3].0.files[0].after.as_deref(), Some(after));
+ // a new function arrives whole, its comment with it, before anything calls it
+ let added = parts[2].0.files[0].after.as_deref().unwrap();
+ assert_eq!(added, after.replace("let n = parse_all(files);", "let n = files.len();"));
+ let ev = event(1, 0, "edit_text", &parts[2].0, lang_of);
+ let Focus::Flow(t) = &ev.focus else { panic!("{:?}", ev.focus) };
+ assert_eq!((t.name.as_str(), t.change), ("parse_all", "added"));
+
+ // a function added after the one that calls it comes apart from the call,
+ // though the diff paired its closing brace with the caller's
+ let before = "pub fn charge(n: i32) -> i32 {\n n\n}\n";
+ let after = "pub fn charge(n: i32) -> i32 {\n fee(n)\n}\n\npub fn fee(n: i32) -> i32 {\n n / 100\n}\n";
+ let parts = split(&step("staged", &[("src/pay.rs", Some(before), Some(after))]), lang_of);
+ let labels: Vec<(Option<&str>, &str)> =
+ parts.iter().filter_map(|(_, p)| p.as_ref()).map(|p| (p.name.as_deref(), p.change)).collect();
+ assert_eq!(labels, [(Some("fee"), "added"), (Some("charge"), "modified")]);
+ assert_eq!(parts[0].0.files[0].after.as_deref(), Some("pub fn charge(n: i32) -> i32 {\n n\n}\n\npub fn fee(n: i32) -> i32 {\n n / 100\n}\n"));
+
+ // a change to one definition is one part, so the step stays whole
+ let one = before.replace("files.len()", "files.len() + 1");
+ let whole = split(&step("applied", &[("src/a.rs", Some(before), Some(&one))]), lang_of);
+ assert_eq!((whole.len(), whole[0].1.is_none()), (1, true));
+ }
+
+ // what an agent read, it is seen looking at: the definition it looked up,
+ // with the functions that call it marked alongside
+ #[test]
+ fn a_look_is_seen_at_what_it_looked_up() {
+ let src = "fn charge() {}\n\nfn refund() {\n charge();\n}\n";
+ let (_p, caches, _m) = project("look", &[("src/pay.rs", src)]);
+ let sites = [("src/pay.rs".to_string(), 1, true), ("src/pay.rs".to_string(), 4, false)];
+ let ev = look(0, "references", "charge", &sites, &caches).expect("a look");
+ let Focus::Flow(t) = &ev.focus else { panic!("{:?}", ev.focus) };
+ assert_eq!((t.name.as_str(), t.change), ("charge", "read"));
+ assert_eq!(ev.json["status"], "read");
+ assert_eq!(ev.json["intent"], json!(["references charge"]));
+ let names: Vec<&str> = ev.json["functions"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
+ assert_eq!(names, ["charge", "refund"]);
+ let file = look(0, "file", "src/pay.rs", &[("src/pay.rs".to_string(), 0, true)], &caches).expect("a look");
+ assert_eq!(file.focus, Focus::Code("src/pay.rs".into()));
+ assert!(look(0, "file", "ghost.rs", &[("ghost.rs".to_string(), 0, true)], &caches).is_none());
+ }
}
diff --git a/visual.png b/visual.png
new file mode 100644
index 0000000..d267e3d
Binary files /dev/null and b/visual.png differ