atlas.ts

12.8 KB · raw

1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import { isTestPath, type Outline, outline, repoIndex, resolveImports } from "../describe/links.ts";3import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.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	outline: Outline | null;23	/** The saves that touched it, oldest first. */24	saves: Save[];25};2627type AtlasFolder = {28	name: string;29	path: string;30	folders: AtlasFolder[];31	files: AtlasFile[];32	/** The saves that touched anything under it, removed files included, oldest first. */33	saves: Save[];34};3536/** Each file's resolved imports, and who imports each file or `folder/`. */37type Links = { imports: Map<string, string[]>; usedBy: Map<string, string[]> };3839type View = { repo: string; at: string | null; links: Links };4041type Facts = { lines: number | null; description: string | null; outline: Outline | null };4243// A panel lists this many names or links, then says how many more.44const LISTED = 12;4546// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An47// object never changes, so what it says about itself is kept for the life of the process, keyed by48// hash and file name (the name picks the comment syntax).49// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.50const known = new Map<string, Facts>();5152function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {53	const key = `${hash} ${name}`;54	const cached = known.get(key);55	if (cached) return cached;56	const bytes = objectBytes(db, hash);57	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;58	const facts =59		text === null60			? { lines: null, description: null, outline: null }61			: {62					lines: countLines(text),63					description: describeFile(name, text),64					outline: outline(name, text),65				};66	known.set(key, facts);67	return facts;68}6970/**71 * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without72 * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser73 * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves to74 * view it at instead.75 */76export function atlasSection(77	repo: PublicRepo,78	saveId: string,79	at: string | null,80	saves: Save[],81): Html {82	const touches = fileTouches(repo.mirror, saveId);83	const foldersTouches = folderTouches(touches);84	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };85	// Koh's own rules file is bookkeeping, not part of the project; Koh never saves what it names.86	const entries = filesAt(repo.mirror, saveId).filter((entry) => entry.path !== ".kohignore");87	for (const entry of entries) {88		const parts = entry.path.split("/");89		let folder = root;90		for (const name of parts.slice(0, -1)) {91			let child = folder.folders.find((f) => f.name === name);92			if (!child) {93				const path = folder.path ? `${folder.path}/${name}` : name;94				child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };95				folder.folders.push(child);96			}97			folder = child;98		}99		const name = parts[parts.length - 1] as string;100		folder.files.push({101			name,102			path: entry.path,103			size: entry.size,104			...factsOf(repo.mirror, entry.hash, name),105			saves: touches.byPath.get(entry.path) ?? [],106		});107	}108	const view = { repo: repo.settings.name, at, links: linksOf(allFiles(root)) };109	return html`<section class="repo-section" id="atlas">110<details class="repo-section-toggle" open>111<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(112		entries.length,113		"file",114	)}</span></summary>115<div class="atlas-bar">116${picker(view, saves)}117${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}118<button type="button" class="atlas-fold-btn mono" hidden>expand all</button>119<button type="button" class="atlas-search-btn mono" hidden>search</button>120<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>121</div>122<div class="atlas" data-repo="${view.repo}">123<div class="atlas-tree">${children(root, view)}</div>124<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>125</div>126</details>127</section>`;128}129130// Kepr 2's "explore through time": every save, newest first. The filter is kepr.js's.131function picker(view: View, saves: Save[]): Html {132	return html`<details class="atlas-picker">133<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>134<div class="atlas-picker-popover">135<input class="atlas-picker-filter mono" type="search" placeholder="search" aria-label="Search saves" hidden>136<div class="atlas-picker-list">${saves.toReversed().map((save) => {137		const message = save.message.split("\n")[0] as string;138		return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${139			view.repo140		}?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>`;141	})}</div>142</div>143</details>`;144}145146function children(folder: AtlasFolder, view: View): Html[] {147	const byName = (a: { name: string }, b: { name: string }) =>148		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);149	return [150		...folder.folders.sort(byName).map((child) => folderRow(child, view)),151		...folder.files.sort(byName).map((file) => fileRow(file, view)),152	];153}154155function fileRow(file: AtlasFile, view: View): Html {156	const { description } = file;157	const base = markdownBase(view, file.path);158	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;159	return html`<a class="atlas-row atlas-file" href="${160		href161	}" data-name="${file.name.toLowerCase()}" data-path="${file.path}">${row(file.name, description, base, file.size, file.saves)}</a>162<template>${panel(163		file.name,164		file.path,165		[166			["size", formatSize(file.size)],167			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],168			...saveFacts(file.saves, view),169		],170		description,171		base,172		fileMap(file, view),173	)}</template>`;174}175176// Kepr 2's map of the repo, only where a file has something to say: what it offers, what it177// leans on, and what leans on it, tests apart.178function fileMap(file: AtlasFile, view: View): [string, Html[]][] {179	const users = view.links.usedBy.get(file.path) ?? [];180	return [181		["exports", listed((file.outline?.exports ?? []).map((name) => html`${name}`))],182		["imports", targets(view.links.imports.get(file.path) ?? [], view)],183		[184			"used by",185			targets(186				users.filter((path) => !isTestPath(path)),187				view,188			),189		],190		["tested by", targets(users.filter(isTestPath), view)],191	];192}193194function targets(paths: string[], view: View): Html[] {195	return listed(paths.toSorted().map((path) => targetLink(path, view)));196}197198// A file links to its page and a folder to the Atlas with it open; kepr.js jumps to the row.199function targetLink(target: string, view: View): Html {200	const folder = target.endsWith("/");201	const path = folder ? target.slice(0, -1) : target;202	const query = new URLSearchParams(view.at ? { at: view.at } : {});203	if (folder) query.set("open", path);204	const href = folder205		? `/${view.repo}?${query}#atlas`206		: `/${view.repo}/files/${pathUrl(path)}${view.at ? `?${query}` : ""}`;207	const name = `${path.split("/").pop()}${folder ? "/" : ""}`;208	return html`<a class="atlas-jump" href="${href}" data-path="${target}" title="${path}">${name}</a>`;209}210211function listed(items: Html[]): Html[] {212	const more = items.length - LISTED;213	return more > 0 ? [...items.slice(0, LISTED), html`+${more} more`] : items;214}215216function linksOf(files: AtlasFile[]): Links {217	const index = repoIndex(files.map((file) => file.path));218	const imports = new Map<string, string[]>();219	const usedBy = new Map<string, string[]>();220	for (const file of files) {221		const found = resolveImports(file.path, file.outline?.imports ?? [], index);222		imports.set(file.path, found);223		for (const target of found) usedBy.set(target, [...(usedBy.get(target) ?? []), file.path]);224	}225	return { imports, usedBy };226}227228function folderRow(folder: AtlasFolder, view: View): Html {229	const description = describeFolder(folder.files);230	const base = markdownBase(view, `${folder.path}/`);231	const files = allFiles(folder);232	const size = files.reduce((sum, f) => sum + f.size, 0);233	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);234	return html`<details class="atlas-folder" data-path="${folder.path}">235<summary class="atlas-row">${row(236		`${folder.name}/`,237		description,238		base,239		size,240		folder.saves,241	)}</summary>242<template>${panel(243		`${folder.name}/`,244		folder.path,245		[246			["size", formatSize(size)],247			["lines", lines.toLocaleString("en-US")],248			["files", files.length.toLocaleString("en-US")],249			...saveFacts(folder.saves, view),250		],251		description,252		base,253		[["used by", targets(folderUsers(folder, files, view), view)]],254	)}</template>255<div class="atlas-children">${children(folder, view)}</div>256</details>`;257}258259function row(260	name: string,261	description: string | null,262	base: MarkdownBase,263	size: number,264	saves: Save[],265): Html {266	const last = saves[saves.length - 1];267	const meta = [formatSize(size), last && relativeTime(last.created_at)]268		.filter(Boolean)269		.join(" · ");270	return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${271		description && renderLine(firstSentence(description), base)272	}</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;273}274275// Files outside a folder that import it as a module, or anything inside it.276function folderUsers(folder: AtlasFolder, files: AtlasFile[], view: View): string[] {277	const inside = (path: string) => path.startsWith(`${folder.path}/`);278	const users = new Set(view.links.usedBy.get(`${folder.path}/`) ?? []);279	for (const file of files) {280		for (const user of view.links.usedBy.get(file.path) ?? []) if (!inside(user)) users.add(user);281	}282	return [...users];283}284285function panel(286	name: string,287	path: string,288	facts: [string, Html | string][],289	description: string | null,290	base: MarkdownBase,291	map: [string, Html[]][],292): Html {293	const shown = map.filter(([, items]) => items.length > 0);294	return html`<h3 class="atlas-info-name">${name}</h3>295<p class="atlas-info-path mono">${path}</p>296<dl class="atlas-facts mono">${facts.map(297		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,298	)}</dl>299${300	description &&301	html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`302}303${304	shown.length > 0 &&305	html`<dl class="atlas-facts atlas-map mono">${shown.map(306		([term, items]) =>307			html`<dt>${term}</dt><dd>${items.map((item, i) => html`${i > 0 && " · "}${item}`)}</dd>`,308	)}</dl>`309}`;310}311312function saveFacts(saves: Save[], view: View): [string, Html | string][] {313	const [first, last] = [saves[0], saves[saves.length - 1]];314	if (!first || !last) return [];315	return [316		["first save", saveLink(first, view)],317		["last save", saveLink(last, view)],318		["saves", saves.length.toLocaleString("en-US")],319	];320}321322function saveLink(save: Save, view: View): Html {323	return html`<a href="/${324		view.repo325	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;326}327328// Each folder's saves are the line's saves that touched any path under it, in line order.329function folderTouches(touches: Touches): Map<string, Save[]> {330	const ids = new Map<string, Set<string>>();331	for (const [path, saves] of touches.byPath) {332		const parts = path.split("/");333		for (let depth = 1; depth < parts.length; depth++) {334			const folder = parts.slice(0, depth).join("/");335			const set = ids.get(folder) ?? new Set<string>();336			for (const save of saves) set.add(save.id);337			ids.set(folder, set);338		}339	}340	return new Map(341		[...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),342	);343}344345function allFiles(folder: AtlasFolder): AtlasFile[] {346	return [...folder.files, ...folder.folders.flatMap(allFiles)];347}348349// Relative links in a description resolve from the file's own folder, as in a README.350function markdownBase(view: View, path: string): MarkdownBase {351	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };352}353354function countLines(text: string): number {355	if (text === "") return 0;356	let count = 0;357	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;358	return text.endsWith("\n") ? count : count + 1;359}