atlas.ts

10.1 KB · raw

1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";3import type { Release } from "../releases/releases.ts";4import { isText } from "./files.ts";5import { type Html, html } from "./html.ts";6import {7	formatDate,8	formatSize,9	type PublicRepo,10	pathUrl,11	plural,12	relativeTime,13} from "./layout.ts";14import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts";1516type AtlasFile = {17	name: string;18	path: string;19	size: number;20	lines: number | null;21	description: string | null;22	/** The saves that touched it, oldest first. */23	saves: Save[];24};2526type AtlasFolder = {27	name: string;28	path: string;29	folders: AtlasFolder[];30	files: AtlasFile[];31	/** The saves that touched anything under it, removed files included, oldest first. */32	saves: Save[];33};3435type View = { repo: string; at: string | null };3637type Facts = { lines: number | null; description: string | null };3839// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An40// object never changes, so what it says about itself is kept for the life of the process, keyed by41// hash and file name (the name picks the comment syntax).42// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.43const known = new Map<string, Facts>();4445function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {46	const key = `${hash} ${name}`;47	const cached = known.get(key);48	if (cached) return cached;49	const bytes = objectBytes(db, hash);50	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;51	const facts =52		text === null53			? { lines: null, description: null }54			: { lines: countLines(text), description: describeFile(name, text) };55	known.set(key, facts);56	return facts;57}5859/**60 * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without61 * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser62 * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves and63 * versions to view it at instead.64 */65export function atlasSection(66	repo: PublicRepo,67	saveId: string,68	at: string | null,69	saves: Save[],70	releases: Release[],71): Html {72	const touches = fileTouches(repo.mirror, saveId);73	const foldersTouches = folderTouches(touches);74	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };75	const entries = filesAt(repo.mirror, saveId);76	for (const entry of entries) {77		const parts = entry.path.split("/");78		let folder = root;79		for (const name of parts.slice(0, -1)) {80			let child = folder.folders.find((f) => f.name === name);81			if (!child) {82				const path = folder.path ? `${folder.path}/${name}` : name;83				child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };84				folder.folders.push(child);85			}86			folder = child;87		}88		const name = parts[parts.length - 1] as string;89		folder.files.push({90			name,91			path: entry.path,92			size: entry.size,93			...factsOf(repo.mirror, entry.hash, name),94			saves: touches.byPath.get(entry.path) ?? [],95		});96	}97	const view = { repo: repo.settings.name, at };98	return html`<section class="repo-section" id="atlas">99<details class="repo-section-toggle" open>100<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(101		entries.length,102		"file",103	)}</span></summary>104<div class="atlas-bar">105${picker(view, saves, releases)}106${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}107<button type="button" class="atlas-search-btn mono" hidden>search</button>108<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>109</div>110<div class="atlas" data-repo="${view.repo}">111<div class="atlas-tree">${children(root, view)}</div>112<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>113</div>114</details>115</section>`;116}117118// Kepr 2's "explore through time": every save, newest first, and every version tied to a save119// still on the line. The tabs and the filter are kepr.js's; without it, the saves list alone.120function picker(view: View, saves: Save[], releases: Release[]): Html {121	const ids = new Set(saves.map((save) => save.id));122	const row = (id: string, label: string, message: string, age: string, active: boolean) =>123		html`<a class="atlas-picker-row${active ? " active" : ""}" href="/${124			view.repo125		}?at=${id}#atlas" data-q="${`${label} ${message}`.toLowerCase()}"><span class="atlas-picker-id">${label}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">${age}</span></a>`;126	return html`<details class="atlas-picker">127<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>128<div class="atlas-picker-popover">129<div class="atlas-picker-tabs mono" hidden><button type="button" class="active" data-tab="saves">saves</button><button type="button" data-tab="versions">versions</button></div>130<input class="atlas-picker-filter mono" type="search" placeholder="filter saves" aria-label="Filter saves" hidden>131<div class="atlas-picker-list" data-tab="saves">${saves132		.toReversed()133		.map((save) =>134			row(135				save.id,136				save.id,137				save.message.split("\n")[0] as string,138				`· ${relativeTime(save.created_at)}`,139				save.id === view.at,140			),141		)}</div>142<div class="atlas-picker-list" data-tab="versions" hidden>${releases143		.filter((release) => ids.has(release.save))144		.map((release) =>145			row(146				release.save,147				release.version,148				release.save,149				`· ${relativeTime(release.publishedAt)}`,150				release.save === view.at,151			),152		)}</div>153</div>154</details>`;155}156157function children(folder: AtlasFolder, view: View): Html[] {158	const byName = (a: { name: string }, b: { name: string }) =>159		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);160	return [161		...folder.folders.sort(byName).map((child) => folderRow(child, view)),162		...folder.files.sort(byName).map((file) => fileRow(file, view)),163	];164}165166function fileRow(file: AtlasFile, view: View): Html {167	const { description } = file;168	const base = markdownBase(view, file.path);169	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;170	return html`<a class="atlas-row atlas-file" href="${171		href172	}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>173<template>${panel(174		file.name,175		file.path,176		[177			["size", formatSize(file.size)],178			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],179			...saveFacts(file.saves, view),180		],181		description,182		base,183	)}</template>`;184}185186function folderRow(folder: AtlasFolder, view: View): Html {187	const description = describeFolder(folder.files);188	const base = markdownBase(view, `${folder.path}/`);189	const files = allFiles(folder);190	const size = files.reduce((sum, f) => sum + f.size, 0);191	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);192	return html`<details class="atlas-folder" data-path="${folder.path}">193<summary class="atlas-row">${row(194		`${folder.name}/`,195		description,196		base,197		size,198		folder.saves,199	)}</summary>200<template>${panel(201		`${folder.name}/`,202		folder.path,203		[204			["size", formatSize(size)],205			["lines", lines.toLocaleString("en-US")],206			["files", files.length.toLocaleString("en-US")],207			...saveFacts(folder.saves, view),208		],209		description,210		base,211	)}</template>212<div class="atlas-children">${children(folder, view)}</div>213</details>`;214}215216function row(217	name: string,218	description: string | null,219	base: MarkdownBase,220	size: number,221	saves: Save[],222): Html {223	const last = saves[saves.length - 1];224	const meta = [formatSize(size), last && relativeTime(last.created_at)]225		.filter(Boolean)226		.join(" · ");227	return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${228		description && renderLine(firstSentence(description), base)229	}</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;230}231232function panel(233	name: string,234	path: string,235	facts: [string, Html | string][],236	description: string | null,237	base: MarkdownBase,238): Html {239	return html`<h3 class="atlas-info-name">${name}</h3>240<p class="atlas-info-path mono">${path}</p>241<dl class="atlas-facts mono">${facts.map(242		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,243	)}</dl>244${245	description &&246	html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`247}`;248}249250function saveFacts(saves: Save[], view: View): [string, Html | string][] {251	const [first, last] = [saves[0], saves[saves.length - 1]];252	if (!first || !last) return [];253	return [254		["first save", saveLink(first, view)],255		["last save", saveLink(last, view)],256		["saves", saves.length.toLocaleString("en-US")],257	];258}259260function saveLink(save: Save, view: View): Html {261	return html`<a href="/${262		view.repo263	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;264}265266// Each folder's saves are the line's saves that touched any path under it, in line order.267function folderTouches(touches: Touches): Map<string, Save[]> {268	const ids = new Map<string, Set<string>>();269	for (const [path, saves] of touches.byPath) {270		const parts = path.split("/");271		for (let depth = 1; depth < parts.length; depth++) {272			const folder = parts.slice(0, depth).join("/");273			const set = ids.get(folder) ?? new Set<string>();274			for (const save of saves) set.add(save.id);275			ids.set(folder, set);276		}277	}278	return new Map(279		[...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),280	);281}282283function allFiles(folder: AtlasFolder): AtlasFile[] {284	return [...folder.files, ...folder.folders.flatMap(allFiles)];285}286287// Relative links in a description resolve from the file's own folder, as in a README.288function markdownBase(view: View, path: string): MarkdownBase {289	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };290}291292function countLines(text: string): number {293	if (text === "") return 0;294	let count = 0;295	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;296	return text.endsWith("\n") ? count : count + 1;297}