atlas.ts

9.4 KB · raw

1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";3import { isText } from "./files.ts";4import { type Html, html } from "./html.ts";5import {6	formatDate,7	formatSize,8	type PublicRepo,9	pathUrl,10	plural,11	relativeTime,12} from "./layout.ts";13import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts";1415type AtlasFile = {16	name: string;17	path: string;18	size: number;19	lines: number | null;20	description: string | null;21	/** The saves that touched it, oldest first. */22	saves: Save[];23};2425type AtlasFolder = {26	name: string;27	path: string;28	folders: AtlasFolder[];29	files: AtlasFile[];30	/** The saves that touched anything under it, removed files included, oldest first. */31	saves: Save[];32};3334type View = { repo: string; at: string | null };3536type Facts = { lines: number | null; description: string | null };3738// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An39// object never changes, so what it says about itself is kept for the life of the process, keyed by40// hash and file name (the name picks the comment syntax).41// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.42const known = new Map<string, Facts>();4344function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {45	const key = `${hash} ${name}`;46	const cached = known.get(key);47	if (cached) return cached;48	const bytes = objectBytes(db, hash);49	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;50	const facts =51		text === null52			? { lines: null, description: null }53			: { lines: countLines(text), description: describeFile(name, text) };54	known.set(key, facts);55	return facts;56}5758/**59 * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without60 * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser61 * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves to62 * view it at instead.63 */64export function atlasSection(65	repo: PublicRepo,66	saveId: string,67	at: string | null,68	saves: Save[],69): Html {70	const touches = fileTouches(repo.mirror, saveId);71	const foldersTouches = folderTouches(touches);72	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };73	// Koh's own rules file is bookkeeping, not part of the project; Koh never saves what it names.74	const entries = filesAt(repo.mirror, saveId).filter((entry) => entry.path !== ".kohignore");75	for (const entry of entries) {76		const parts = entry.path.split("/");77		let folder = root;78		for (const name of parts.slice(0, -1)) {79			let child = folder.folders.find((f) => f.name === name);80			if (!child) {81				const path = folder.path ? `${folder.path}/${name}` : name;82				child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };83				folder.folders.push(child);84			}85			folder = child;86		}87		const name = parts[parts.length - 1] as string;88		folder.files.push({89			name,90			path: entry.path,91			size: entry.size,92			...factsOf(repo.mirror, entry.hash, name),93			saves: touches.byPath.get(entry.path) ?? [],94		});95	}96	const view = { repo: repo.settings.name, at };97	return html`<section class="repo-section" id="atlas">98<details class="repo-section-toggle" open>99<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(100		entries.length,101		"file",102	)}</span></summary>103<div class="atlas-bar">104${picker(view, saves)}105${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}106<button type="button" class="atlas-search-btn mono" hidden>search</button>107<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>108</div>109<div class="atlas" data-repo="${view.repo}">110<div class="atlas-tree">${children(root, view)}</div>111<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>112</div>113</details>114</section>`;115}116117// Kepr 2's "explore through time": every save, newest first. The filter is kepr.js's.118function picker(view: View, saves: Save[]): Html {119	return html`<details class="atlas-picker">120<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>121<div class="atlas-picker-popover">122<input class="atlas-picker-filter mono" type="search" placeholder="filter saves" aria-label="Filter saves" hidden>123<div class="atlas-picker-list">${saves.toReversed().map((save) => {124		const message = save.message.split("\n")[0] as string;125		return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${126			view.repo127		}?at=${save.id}#atlas" data-q="${`${save.id} ${message}`.toLowerCase()}"><span class="atlas-picker-id">${save.id}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">· ${relativeTime(save.created_at)}</span></a>`;128	})}</div>129</div>130</details>`;131}132133function children(folder: AtlasFolder, view: View): Html[] {134	const byName = (a: { name: string }, b: { name: string }) =>135		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);136	return [137		...folder.folders.sort(byName).map((child) => folderRow(child, view)),138		...folder.files.sort(byName).map((file) => fileRow(file, view)),139	];140}141142function fileRow(file: AtlasFile, view: View): Html {143	const { description } = file;144	const base = markdownBase(view, file.path);145	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;146	return html`<a class="atlas-row atlas-file" href="${147		href148	}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>149<template>${panel(150		file.name,151		file.path,152		[153			["size", formatSize(file.size)],154			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],155			...saveFacts(file.saves, view),156		],157		description,158		base,159	)}</template>`;160}161162function folderRow(folder: AtlasFolder, view: View): Html {163	const description = describeFolder(folder.files);164	const base = markdownBase(view, `${folder.path}/`);165	const files = allFiles(folder);166	const size = files.reduce((sum, f) => sum + f.size, 0);167	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);168	return html`<details class="atlas-folder" data-path="${folder.path}">169<summary class="atlas-row">${row(170		`${folder.name}/`,171		description,172		base,173		size,174		folder.saves,175	)}</summary>176<template>${panel(177		`${folder.name}/`,178		folder.path,179		[180			["size", formatSize(size)],181			["lines", lines.toLocaleString("en-US")],182			["files", files.length.toLocaleString("en-US")],183			...saveFacts(folder.saves, view),184		],185		description,186		base,187	)}</template>188<div class="atlas-children">${children(folder, view)}</div>189</details>`;190}191192function row(193	name: string,194	description: string | null,195	base: MarkdownBase,196	size: number,197	saves: Save[],198): Html {199	const last = saves[saves.length - 1];200	const meta = [formatSize(size), last && relativeTime(last.created_at)]201		.filter(Boolean)202		.join(" · ");203	return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${204		description && renderLine(firstSentence(description), base)205	}</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;206}207208function panel(209	name: string,210	path: string,211	facts: [string, Html | string][],212	description: string | null,213	base: MarkdownBase,214): Html {215	return html`<h3 class="atlas-info-name">${name}</h3>216<p class="atlas-info-path mono">${path}</p>217<dl class="atlas-facts mono">${facts.map(218		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,219	)}</dl>220${221	description &&222	html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`223}`;224}225226function saveFacts(saves: Save[], view: View): [string, Html | string][] {227	const [first, last] = [saves[0], saves[saves.length - 1]];228	if (!first || !last) return [];229	return [230		["first save", saveLink(first, view)],231		["last save", saveLink(last, view)],232		["saves", saves.length.toLocaleString("en-US")],233	];234}235236function saveLink(save: Save, view: View): Html {237	return html`<a href="/${238		view.repo239	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;240}241242// Each folder's saves are the line's saves that touched any path under it, in line order.243function folderTouches(touches: Touches): Map<string, Save[]> {244	const ids = new Map<string, Set<string>>();245	for (const [path, saves] of touches.byPath) {246		const parts = path.split("/");247		for (let depth = 1; depth < parts.length; depth++) {248			const folder = parts.slice(0, depth).join("/");249			const set = ids.get(folder) ?? new Set<string>();250			for (const save of saves) set.add(save.id);251			ids.set(folder, set);252		}253	}254	return new Map(255		[...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),256	);257}258259function allFiles(folder: AtlasFolder): AtlasFile[] {260	return [...folder.files, ...folder.folders.flatMap(allFiles)];261}262263// Relative links in a description resolve from the file's own folder, as in a README.264function markdownBase(view: View, path: string): MarkdownBase {265	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };266}267268function countLines(text: string): number {269	if (text === "") return 0;270	let count = 0;271	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;272	return text.endsWith("\n") ? count : count + 1;273}