atlas.ts

7.7 KB · raw

1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import {3	type FileHistory,4	fileHistories,5	filesAt,6	objectBytes,7	type Save,8} from "../mirror/queries.ts";9import { isText } from "./files.ts";10import { type Html, html } from "./html.ts";11import {12	formatDate,13	formatSize,14	type PublicRepo,15	pathUrl,16	plural,17	relativeTime,18} from "./layout.ts";19import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts";2021type AtlasFile = {22	name: string;23	path: string;24	size: number;25	lines: number | null;26	description: string | null;27	history: FileHistory | undefined;28};2930type AtlasFolder = { name: string; path: string; folders: AtlasFolder[]; files: AtlasFile[] };3132type View = { repo: string; at: string | null };3334type Facts = { lines: number | null; description: string | null };3536// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An37// object never changes, so what it says about itself is kept for the life of the process, keyed by38// hash and file name (the name picks the comment syntax).39// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.40const known = new Map<string, Facts>();4142function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {43	const key = `${hash} ${name}`;44	const cached = known.get(key);45	if (cached) return cached;46	const bytes = objectBytes(db, hash);47	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;48	const facts =49		text === null50			? { lines: null, description: null }51			: { lines: countLines(text), description: describeFile(name, text) };52	known.set(key, facts);53	return facts;54}5556/**57 * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without58 * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser59 * island shows on hover or select.60 */61export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {62	const histories = fileHistories(repo.mirror, saveId);63	const root: AtlasFolder = { name: "", path: "", folders: [], files: [] };64	const entries = filesAt(repo.mirror, saveId);65	for (const entry of entries) {66		const parts = entry.path.split("/");67		let folder = root;68		for (const name of parts.slice(0, -1)) {69			let child = folder.folders.find((f) => f.name === name);70			if (!child) {71				const path = folder.path ? `${folder.path}/${name}` : name;72				child = { name, path, folders: [], files: [] };73				folder.folders.push(child);74			}75			folder = child;76		}77		const name = parts[parts.length - 1] as string;78		folder.files.push({79			name,80			path: entry.path,81			size: entry.size,82			...factsOf(repo.mirror, entry.hash, name),83			history: histories.get(entry.path),84		});85	}86	const view = { repo: repo.settings.name, at };87	const where = at88		? html`at <a href="/${view.repo}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`89		: "HEAD";90	return html`<section class="repo-section" id="atlas">91<details class="repo-section-toggle" open>92<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(entries.length, "file")}</span></summary>93<p class="atlas-where mono">${where}</p>94<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>95<div class="atlas" data-repo="${view.repo}">96<div class="atlas-tree">${children(root, view)}</div>97<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>98</div>99</details>100</section>`;101}102103function children(folder: AtlasFolder, view: View): Html[] {104	const byName = (a: { name: string }, b: { name: string }) =>105		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);106	return [107		...folder.folders.sort(byName).map((child) => folderRow(child, view)),108		...folder.files.sort(byName).map((file) => fileRow(file, view)),109	];110}111112function fileRow(file: AtlasFile, view: View): Html {113	const { description } = file;114	const base = markdownBase(view, file.path);115	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;116	const last = file.history?.last;117	return html`<a class="atlas-row atlas-file" href="${href}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, last)}</a>118<template>${panel(119		file.name,120		file.path,121		[122			["size", formatSize(file.size)],123			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],124			...saveFacts(file.history, view),125		],126		description,127		base,128	)}${file.history && recent(file.history.recent, view)}</template>`;129}130131function folderRow(folder: AtlasFolder, view: View): Html {132	const description = describeFolder(folder.files);133	const base = markdownBase(view, `${folder.path}/`);134	const files = allFiles(folder);135	const size = files.reduce((sum, f) => sum + f.size, 0);136	const last = files137		.map((f) => f.history?.last)138		.reduce<Save | undefined>(139			(a, b) => (b && (!a || b.created_at > a.created_at) ? b : a),140			undefined,141		);142	return html`<details class="atlas-folder" data-path="${folder.path}">143<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, last)}</summary>144<template>${panel(145		`${folder.name}/`,146		folder.path,147		[148			["size", formatSize(size)],149			["files", files.length.toLocaleString("en-US")],150			...(last ? ([["last save", saveLink(last, view)]] as [string, Html][]) : []),151		],152		description,153		base,154	)}</template>155<div class="atlas-children">${children(folder, view)}</div>156</details>`;157}158159function row(160	name: string,161	description: string | null,162	base: MarkdownBase,163	size: number,164	last: Save | undefined,165): Html {166	const meta = [formatSize(size), last && relativeTime(last.created_at)]167		.filter(Boolean)168		.join(" · ");169	return html`<span class="atlas-name">${name}</span><span class="atlas-desc">${description && renderLine(firstSentence(description), base)}</span><span class="atlas-meta mono">${meta}</span>`;170}171172function panel(173	name: string,174	path: string,175	facts: [string, Html | string][],176	description: string | null,177	base: MarkdownBase,178): Html {179	return html`<h3 class="atlas-info-name">${name}</h3>180<p class="atlas-info-path mono">${path}</p>181<dl class="atlas-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>182${description && html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`}`;183}184185function saveFacts(history: FileHistory | undefined, view: View): [string, Html | string][] {186	if (!history) return [];187	return [188		["first save", saveLink(history.first, view)],189		["last save", saveLink(history.last, view)],190		["saves", history.touches.toLocaleString("en-US")],191	];192}193194function saveLink(save: Save, view: View): Html {195	return html`<a href="/${view.repo}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;196}197198function recent(saves: Save[], view: View): Html {199	return html`<ul class="atlas-recent">${saves.map(200		(save) =>201			html`<li><a class="mono" href="/${view.repo}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,202	)}</ul>`;203}204205function allFiles(folder: AtlasFolder): AtlasFile[] {206	return [...folder.files, ...folder.folders.flatMap(allFiles)];207}208209// Relative links in a description resolve from the file's own folder, as in a README.210function markdownBase(view: View, path: string): MarkdownBase {211	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };212}213214function countLines(text: string): number {215	if (text === "") return 0;216	let count = 0;217	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;218	return text.endsWith("\n") ? count : count + 1;219}