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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ Viewer must degrade gracefully when these 404 (e.g. static hosting): hide snippe

## Interaction v2

- **No cursor tooltip.** A persistent right sidebar shows hover info (top section, live) and pinned selection detail (on click): stats, PRs, recent commits for that path, and code — module source snippet + latest diff via the dev API. When the focused/selected node is file-level or deeper, the sidebar expands to a wide code pane showing the actual source (module span, or whole file capped).
- **No cursor tooltip.** A persistent right sidebar shows hover info (top section, live) and pinned selection detail (on click): stats, PRs, recent commits for that path, and code — module source snippet + latest diff via the dev API. When the focused/selected node is file-level or deeper, the sidebar expands to a wide code pane showing the actual source (module span, or whole file capped). **A module scopes all three to its lines**: the code pane shows its span, the PR list keeps only PRs whose hunks touch it (`Pr.spans`), and the commit list comes from `git log -L start,end:path` (`/api/log?start&end`, cached per span) with the latest diff taken from that list — so a function shows who touched *it*, not its file. When `-L` fails (a range past EOF, an unborn file) the endpoint falls back to the file-wide log and says so (`scoped: false`), and the list is titled *Recent commits · file* rather than silently lying. The per-span cache lives until the next working-tree refresh.
- **Double-click = isolate.** Rendering the focused node's subtree ONLY, at the footprint it already had (see *Scale-true drill-down*): folder → its city; file → its modules in reading order (see *File interior*); module → its members, the same way inside its plate. Breadcrumb/Esc rebuilds the parent scene. This is the hierarchy: org → repo → folder → file → module → member.
- **Map-style labels**: labels chosen dynamically from what's in view (projected size within a readable band, capped count, fade in/out) — district names give way to file names give way to building names as you zoom, like a map engine.
- **PR markers** connect visibly: avatar at centroid, thin beams down to EACH affected file plate + glowing ground ring per file. **Selecting a PR** (click its avatar) lights its files and darkens everything else — the search highlight paint with the PR layer left on — until Escape or another selection. One highlight channel, so a search or a tour step drops the PR first, Escape drops a lit PR before anything else, and drilling into a scope the PR does not touch drops it too. PRs gain `additions`/`deletions` in the data; the central beam's radius and glow scale with log(additions+deletions) so big PRs read as big pillars of light.
Expand Down
8 changes: 5 additions & 3 deletions shared/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface CityHost {
/** Source lines of a repo-relative path, or null when unavailable. */
getSource(path: string, start?: number, end?: number): Promise<SourceResponse | null>;
/** Recent commits touching a path, or null when unavailable. */
getLog(path: string): Promise<LogResponse | null>;
getLog(path: string, start?: number, end?: number): Promise<LogResponse | null>;
/** `git show <hash> -- <path>`, or null when unavailable. */
getDiff(path: string, hash: string): Promise<DiffResponse | null>;
/** Uncommitted working-tree changes ("now"), or null when unavailable. */
Expand Down Expand Up @@ -54,8 +54,10 @@ export class HttpHost implements CityHost {
return this.#getJson<SourceResponse>('/api/source?' + q.toString());
}

getLog(path: string): Promise<LogResponse | null> {
return this.#getJson<LogResponse>('/api/log?' + new URLSearchParams({ path }).toString());
getLog(path: string, start?: number, end?: number): Promise<LogResponse | null> {
const q = new URLSearchParams({ path });
if (start !== undefined && end !== undefined) q.set('start', String(start)), q.set('end', String(end));
return this.#getJson<LogResponse>('/api/log?' + q.toString());
}

getDiff(path: string, hash: string): Promise<DiffResponse | null> {
Expand Down
2 changes: 2 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export interface LogCommit {

export interface LogResponse {
commits: LogCommit[];
/** True only when a line range was honoured (`git log -L`); false = file-wide fallback. */
scoped?: boolean;
}

export interface DiffResponse {
Expand Down
20 changes: 16 additions & 4 deletions viewer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4075,6 +4075,9 @@ function describe(target: Target | null): Descriptor | null {
// A strata level stands for one commit on that file — say which one.
const level = target.level?.commit;
const match = filterMatchCount(node);
const modSpan = mod && mod.line !== undefined && Number.isFinite(mod.line)
? { start: Math.max(1, mod.line), end: Math.max(1, mod.line) + Math.max(mod.loc, 1) - 1 }
: null;
return {
name: mod ? mod.name : node.name,
kind,
Expand All @@ -4091,16 +4094,25 @@ function describe(target: Target | null): Descriptor | null {
churn: node.churn,
fixChurn: node.fixChurn,
recentChurn: state.timeCursor === null ? node.recentChurn : recentValue(node).count,
prs: real ? index.prsByFile.get(real.path) || [] : index.prsByNode.get(node) || [],
prs: real ? prsTouching(real.path, mod) : index.prsByNode.get(node) || [],
coupling: state.coupling ? couplingSummary(node) : null,
codePath: real ? real.path : null,
span: mod && mod.line !== undefined && Number.isFinite(mod.line)
? { start: Math.max(1, mod.line), end: Math.max(1, mod.line) + Math.max(mod.loc, 1) }
: null,
span: modSpan,
logSpan: modSpan ?? undefined, // revealPath widens `span` into a reading window; the log keeps the real lines
deep: !!real,
};
}

/** A file's PRs — or, for a module, only those whose hunks touch its lines (PRs without spans stay). */
function prsTouching(path: string, mod: VMod | undefined): Pr[] {
const list = index.prsByFile.get(path) || [];
if (!mod || typeof mod.line !== 'number') return list;
return list.filter((pr) => {
const spans = pr.spans?.[path];
return !spans || touchesSpans(mod, spans);
});
}

// ---------------------------------------------------------------------------
// Labels
// ---------------------------------------------------------------------------
Expand Down
53 changes: 47 additions & 6 deletions viewer/src/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export interface Descriptor {
coupling?: { out: number; in: number } | null;
codePath: string | null;
span: { start: number; end: number } | null;
/** The module's own lines — scopes the commit log; `span` is only the reading window. */
logSpan?: { start: number; end: number };
/** File-level or deeper — the code pane is worth widening for. */
deep: boolean;
/** Filled in from the host when there is no commit stream. */
Expand Down Expand Up @@ -145,6 +147,7 @@ export function createSidebar(
const secCode = section('code');
body.append(secInspect, secTour, secSearch, secSelected, secWork, secCode);

const spanLogs = new Map<string, { commits: LogCommit[]; scoped: boolean }>(); // span key -> its log
const state: {
hover: Descriptor | null;
selected: Descriptor | null;
Expand Down Expand Up @@ -247,6 +250,7 @@ export function createSidebar(
state.openPrs.clear();
state.allCommits = false;
renderSelected();
if (desc) loadSpanCommits(desc); // before loadCode: the tour skips renderSelected
loadCode(desc);
applyWidth();
},
Expand All @@ -264,6 +268,7 @@ export function createSidebar(
return state.search !== null;
},
setWorkingTree(view) {
spanLogs.clear(); // a re-read of the tree re-reads the per-span logs too
state.work = view;
renderWork();
},
Expand Down Expand Up @@ -381,9 +386,13 @@ export function createSidebar(

function commitsHtml(d: Descriptor): string {
const list = commitsFor(d);
const key = spanKey(d);
const log = key ? spanLogs.get(key) : undefined;
if (key && !log) return `<div class="sb-sub">Commits on these lines…</div>`;
if (!list.length) return '';
const t = state.cursor;
const title = t === null ? 'Recent commits' : 'Commits near cursor';
const title = log ? (log.scoped ? 'Commits on these lines' : 'Recent commits · file')
: t === null ? 'Recent commits' : 'Commits near cursor';
const shown = state.allCommits ? list.slice(0, MAX_COMMITS) : list.slice(0, COMMIT_PEEK);
const rows = shown.map((c) => {
const near = t !== null && Math.abs(c.ts - t) < 2 * 86400;
Expand Down Expand Up @@ -506,9 +515,33 @@ export function createSidebar(
return ' \u00b7 ' + parts.join(' ');
}

// A module's commits are the ones that touched its lines (`git log -L`),
// fetched once per span and remembered across hovers and selections.
function spanKey(d: Descriptor): string | null {
const s = d.logSpan;
return s && d.codePath && opts.host.available() ? `${d.codePath}:${s.start}-${s.end}` : null;
}
function loadSpanCommits(d: Descriptor): void {
const key = spanKey(d);
const span = d.logSpan;
if (!key || !span || !d.codePath || spanLogs.has(key)) return;
opts.host.getLog(d.codePath, span.start, span.end).then((json) => {
spanLogs.set(key, {
commits: json && Array.isArray(json.commits) ? json.commits : [],
scoped: !!json?.scoped,
});
if (state.selected === d) {
renderSelected();
void loadLatestDiff(d, state.codeToken); // the source pane already stands
}
});
}

function commitsFor(d: Descriptor): LogCommit[] {
const codePath = d.codePath;
if (!codePath) return [];
const key = spanKey(d);
if (key) return spanLogs.get(key)?.commits ?? [];
const tl = opts.timeline;
if (tl && tl.enabled) {
return state.cursor === null ? tl.commitsFor(codePath).slice(0, MAX_COMMITS)
Expand All @@ -520,7 +553,7 @@ export function createSidebar(
/** Without a commit stream, ask the host for this path's log (once). */
function loadFallbackCommits(d: Descriptor): void {
const tl = opts.timeline;
if ((tl && tl.enabled) || !d.codePath || d.logCommits || !opts.host.available()) return;
if ((tl && tl.enabled) || d.logSpan || !d.codePath || d.logCommits || !opts.host.available()) return;
const token = ++state.codeToken;
opts.host.getLog(d.codePath).then((json) => {
if (!json || token !== state.codeToken || state.selected !== d) return;
Expand Down Expand Up @@ -554,19 +587,27 @@ export function createSidebar(
`<div class="h"><span>Code</span><em>${escapeHtml(baseName(codePath))} ` +
`${Number(src.start) || start}–${Number(src.end) || end}</em></div>`;
secCode.innerHTML = head + sourceHtml(src, Number(src.start) || start);
await loadLatestDiff(d, token);
}

const latest = latestHashFor(d);
if (!latest) return;
/** Appends the newest commit's diff under the source pane — the tail of loadCode. */
async function loadLatestDiff(d: Descriptor, token: number): Promise<void> {
const codePath = d.codePath;
const latest = codePath ? latestHashFor(d) : null;
// Both loadCode and the span-log callback can reach here; append once.
if (!codePath || !latest || token !== state.codeToken || secCode.querySelector('.sb-diff-head')) return;
const diff = await opts.host.getDiff(codePath, latest);
if (token !== state.codeToken || !diff || !diff.diff) return;
if (token !== state.codeToken || !diff || !diff.diff || secCode.querySelector('.sb-diff-head')) return;
secCode.insertAdjacentHTML(
'beforeend',
`<div class="sb-sub">Latest diff · ${escapeHtml(latest.slice(0, 7))}</div>` + diffHtml(diff.diff)
`<div class="sb-sub sb-diff-head">Latest diff · ${escapeHtml(latest.slice(0, 7))}</div>` + diffHtml(diff.diff)
);
}

function latestHashFor(d: Descriptor): string | null {
const codePath = d.codePath;
const key = spanKey(d);
if (key) return spanLogs.get(key)?.commits[0]?.h ?? null;
const tl = opts.timeline;
if (tl && tl.enabled && codePath) {
const list = state.cursor === null ? tl.commitsFor(codePath) : tl.commitsNear(state.cursor, codePath, 1);
Expand Down
26 changes: 20 additions & 6 deletions viewer/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,30 @@ const ROUTES = {
send(res, 200, { path: rel, start, end, total, lines: all.slice(start - 1, end) });
},

'/log': async ({ res, root, rel }) => {
const { stdout } = await git(root, [
'log', '-n', String(LOG_COUNT), '--follow',
`--pretty=format:%h${'\t'}%ct${'\t'}%an${'\t'}%s`, '--', rel,
]);
'/log': async ({ res, root, rel, params }) => {
const pretty = `--pretty=format:%h${'\t'}%ct${'\t'}%an${'\t'}%s`;
const start = intParam(params.get('start'), 0);
const end = intParam(params.get('end'), 0);
// A line range asks which commits touched those lines (`-L`); it cannot
// be combined with --follow, and a range past EOF falls back to the file.
const forFile = ['log', '-n', String(LOG_COUNT), '--follow', pretty, '--', rel];
let stdout;
let scoped = false; // did `-L` actually answer? the caller labels the fallback
if (start >= 1 && end >= start) {
try {
({ stdout } = await git(root, ['log', '-n', String(LOG_COUNT), '--no-patch', `-L${start},${end}:${rel}`, pretty]));
scoped = true;
} catch {
({ stdout } = await git(root, forFile));
}
} else {
({ stdout } = await git(root, forFile));
}
const commits = stdout.split('\n').filter(Boolean).map((line) => {
const [h, ts, a, ...rest] = line.split('\t');
return { h, ts: Number(ts), a, s: rest.join('\t') };
});
send(res, 200, { commits });
send(res, 200, { commits, scoped });
},

'/diff': async ({ res, root, rel, params }) => {
Expand Down