atlas.ts

13.6 KB · raw

1import { describeFolder, firstSentence } from "../describe/describe.ts";2import { isTestPath, type Outline, repoIndex, resolveImports } from "../describe/links.ts";3import { filesWithFacts, fileTouches, type Save, type Touches } from "../mirror/queries.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	outline: Outline | 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};3435/** Each file's resolved imports, and who imports each file or `folder/`. */36type Links = { imports: Map<string, string[]>; usedBy: Map<string, string[]> };3738type View = { repo: string; at: string | null; links: Links };3940// A panel lists this many names or links, then says how many more.41const LISTED = 12;4243/** The tree at one save with each file's facts and the links between files. */44type AtlasModel = { root: AtlasFolder; links: Links; count: number };4546// Built per request from what each save recorded as it landed (touches and facts), so nothing is47// kept between requests. In one read snapshot: the sync's thread can change the mirror between any48// two statements.49function atlasModel(repo: PublicRepo, saveId: string): AtlasModel {50	return repo.mirror.transaction(() => buildAtlasModel(repo, saveId))();51}5253function buildAtlasModel(repo: PublicRepo, saveId: string): AtlasModel {54	const touches = fileTouches(repo.mirror, saveId);55	const foldersTouches = folderTouches(touches);56	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };57	// Koh's own rules file is bookkeeping, not part of the project; Koh never saves what it names.58	const entries = filesWithFacts(repo.mirror, saveId).filter(59		(entry) => entry.path !== ".kohignore",60	);61	for (const entry of entries) {62		const parts = entry.path.split("/");63		let folder = root;64		for (const name of parts.slice(0, -1)) {65			let child = folder.folders.find((f) => f.name === name);66			if (!child) {67				const path = folder.path ? `${folder.path}/${name}` : name;68				child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };69				folder.folders.push(child);70			}71			folder = child;72		}73		folder.files.push({74			name: parts[parts.length - 1] as string,75			path: entry.path,76			size: entry.size,77			lines: entry.lines,78			description: entry.description,79			outline: entry.outline,80			saves: touches.byPath.get(entry.path) ?? [],81		});82	}83	return { root, links: linksOf(allFiles(root)), count: entries.length };84}8586/**87 * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without88 * JavaScript. A row's detail panel is fetched from `atlasPanel` on hover or select (kepr.js), so89 * a big repo's page carries rows only. `HEAD` (or the save shown) opens Kepr 2's picker of saves90 * to view it at instead, its rows fetched from `atlasPickerRows` when first opened.91 */92export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {93	const model = atlasModel(repo, saveId);94	const view = { repo: repo.settings.name, at, links: model.links };95	return html`<section class="repo-section" id="atlas">96<details class="repo-section-toggle" open>97<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(98		model.count,99		"file",100	)}</span></summary>101<div class="atlas-bar">102${picker(view)}103${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}104<button type="button" class="atlas-fold-btn mono" hidden>expand all</button>105<button type="button" class="atlas-search-btn mono" hidden>search</button>106<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>107</div>108<div class="atlas" data-repo="${view.repo}"${at && html` data-at="${at}"`}>109<div class="atlas-tree">${children(model.root, view)}</div>110<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>111</div>112</details>113</section>`;114}115116/** One row's detail panel: a file's, or a folder's when `path` ends in a slash; null if absent. */117export function atlasPanel(118	repo: PublicRepo,119	saveId: string,120	at: string | null,121	path: string,122): Html | null {123	const model = atlasModel(repo, saveId);124	const view = { repo: repo.settings.name, at, links: model.links };125	const parts = path.replace(/\/$/, "").split("/");126	let folder: AtlasFolder | undefined = model.root;127	for (const name of path.endsWith("/") ? parts : parts.slice(0, -1)) {128		folder = folder?.folders.find((f) => f.name === name);129	}130	if (!folder) return null;131	if (path.endsWith("/")) return folderPanel(folder, view);132	const file = folder.files.find((f) => f.path === path);133	return file ? filePanel(file, view) : null;134}135136// Kepr 2's "explore through time". Its rows arrive when first opened (kepr.js): a big repo's137// every save was most of its page. Without JavaScript, History's save pages link to the Atlas.138function picker(view: View): Html {139	const query = view.at ? `?at=${view.at}` : "";140	return html`<details class="atlas-picker">141<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>142<div class="atlas-picker-popover">143<input class="atlas-picker-filter mono" type="search" placeholder="search" aria-label="Search saves" hidden>144<div class="atlas-picker-list" data-src="/${view.repo}/atlas/saves${query}"><a class="atlas-picker-row" href="#history">pick a save from the history</a></div>145</div>146</details>`;147}148149/** The picker's rows: every save, newest first, `at` marked. kepr.js filters on their text. */150export function atlasPickerRows(repo: string, saves: Save[], at: string | null): Html[] {151	return saves.toReversed().map((save) => {152		const message = save.message.split("\n")[0] as string;153		return html`<a class="atlas-picker-row${save.id === at ? " active" : ""}" href="/${154			repo155		}?at=${save.id}#atlas"><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>`;156	});157}158159function children(folder: AtlasFolder, view: View): Html[] {160	const byName = (a: { name: string }, b: { name: string }) =>161		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);162	return [163		...folder.folders.sort(byName).map((child) => folderRow(child, view)),164		...folder.files.sort(byName).map((file) => fileRow(file, view)),165	];166}167168function fileRow(file: AtlasFile, view: View): Html {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()}" data-path="${file.path}">${row(file.name, file.description, markdownBase(view, file.path), file.size, file.saves)}</a>`;173}174175function filePanel(file: AtlasFile, view: View): Html {176	return panel(177		file.name,178		file.path,179		[180			["size", formatSize(file.size)],181			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],182			...saveFacts(file.saves, view),183		],184		file.description,185		markdownBase(view, file.path),186		fileMap(file, view),187	);188}189190// Kepr 2's map of the repo, only where a file has something to say: what it offers, what it191// leans on, and what leans on it, tests apart.192function fileMap(file: AtlasFile, view: View): [string, Html[]][] {193	const users = view.links.usedBy.get(file.path) ?? [];194	return [195		["exports", listed((file.outline?.exports ?? []).map((name) => html`${name}`))],196		["imports", targets(view.links.imports.get(file.path) ?? [], view)],197		[198			"used by",199			targets(200				users.filter((path) => !isTestPath(path)),201				view,202			),203		],204		["tested by", targets(users.filter(isTestPath), view)],205	];206}207208function targets(paths: string[], view: View): Html[] {209	return listed(paths.toSorted().map((path) => targetLink(path, view)));210}211212// A file links to its page and a folder to the Atlas with it open; kepr.js jumps to the row.213function targetLink(target: string, view: View): Html {214	const folder = target.endsWith("/");215	const path = folder ? target.slice(0, -1) : target;216	const query = new URLSearchParams(view.at ? { at: view.at } : {});217	if (folder) query.set("open", path);218	const href = folder219		? `/${view.repo}?${query}#atlas`220		: `/${view.repo}/files/${pathUrl(path)}${view.at ? `?${query}` : ""}`;221	const name = `${path.split("/").pop()}${folder ? "/" : ""}`;222	return html`<a class="atlas-jump" href="${href}" data-path="${target}" title="${path}">${name}</a>`;223}224225function listed(items: Html[]): Html[] {226	const more = items.length - LISTED;227	return more > 0 ? [...items.slice(0, LISTED), html`+${more} more`] : items;228}229230function linksOf(files: AtlasFile[]): Links {231	const index = repoIndex(files.map((file) => file.path));232	const imports = new Map<string, string[]>();233	const usedBy = new Map<string, string[]>();234	for (const file of files) {235		const found = resolveImports(file.path, file.outline?.imports ?? [], index);236		imports.set(file.path, found);237		for (const target of found) usedBy.set(target, [...(usedBy.get(target) ?? []), file.path]);238	}239	return { imports, usedBy };240}241242function folderRow(folder: AtlasFolder, view: View): Html {243	const files = allFiles(folder);244	const size = files.reduce((sum, f) => sum + f.size, 0);245	return html`<details class="atlas-folder" data-path="${folder.path}">246<summary class="atlas-row">${row(247		`${folder.name}/`,248		describeFolder(folder.files),249		markdownBase(view, `${folder.path}/`),250		size,251		folder.saves,252	)}</summary>253<div class="atlas-children">${children(folder, view)}</div>254</details>`;255}256257function folderPanel(folder: AtlasFolder, view: View): Html {258	const files = allFiles(folder);259	const size = files.reduce((sum, f) => sum + f.size, 0);260	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);261	return panel(262		`${folder.name}/`,263		folder.path,264		[265			["size", formatSize(size)],266			["lines", lines.toLocaleString("en-US")],267			["files", files.length.toLocaleString("en-US")],268			...saveFacts(folder.saves, view),269		],270		describeFolder(folder.files),271		markdownBase(view, `${folder.path}/`),272		[["used by", targets(folderUsers(folder, files, view), view)]],273	);274}275276function row(277	name: string,278	description: string | null,279	base: MarkdownBase,280	size: number,281	saves: Save[],282): Html {283	const last = saves[saves.length - 1];284	const meta = [formatSize(size), last && relativeTime(last.created_at)]285		.filter(Boolean)286		.join(" · ");287	return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${288		description && renderLine(firstSentence(description), base)289	}</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;290}291292// Files outside a folder that import it as a module, or anything inside it.293function folderUsers(folder: AtlasFolder, files: AtlasFile[], view: View): string[] {294	const inside = (path: string) => path.startsWith(`${folder.path}/`);295	const users = new Set(view.links.usedBy.get(`${folder.path}/`) ?? []);296	for (const file of files) {297		for (const user of view.links.usedBy.get(file.path) ?? []) if (!inside(user)) users.add(user);298	}299	return [...users];300}301302function panel(303	name: string,304	path: string,305	facts: [string, Html | string][],306	description: string | null,307	base: MarkdownBase,308	map: [string, Html[]][],309): Html {310	const shown = map.filter(([, items]) => items.length > 0);311	return html`<h3 class="atlas-info-name">${name}</h3>312<p class="atlas-info-path mono">${path}</p>313<dl class="atlas-facts mono">${facts.map(314		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,315	)}</dl>316${317	description &&318	html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`319}320${321	shown.length > 0 &&322	html`<dl class="atlas-facts atlas-map mono">${shown.map(323		([term, items]) =>324			html`<dt>${term}</dt><dd>${items.map((item, i) => html`${i > 0 && " · "}${item}`)}</dd>`,325	)}</dl>`326}`;327}328329function saveFacts(saves: Save[], view: View): [string, Html | string][] {330	const [first, last] = [saves[0], saves[saves.length - 1]];331	if (!first || !last) return [];332	return [333		["first save", saveLink(first, view)],334		["last save", saveLink(last, view)],335		["saves", saves.length.toLocaleString("en-US")],336	];337}338339function saveLink(save: Save, view: View): Html {340	return html`<a href="/${341		view.repo342	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;343}344345// Each folder's saves are the line's saves that touched any path under it, in line order.346function folderTouches(touches: Touches): Map<string, Save[]> {347	const order = new Map(touches.line.map((save, i) => [save, i]));348	const saves = new Map<string, Set<Save>>();349	for (const [path, touched] of touches.byPath) {350		const parts = path.split("/");351		for (let depth = 1; depth < parts.length; depth++) {352			const folder = parts.slice(0, depth).join("/");353			const set = saves.get(folder) ?? new Set<Save>();354			for (const save of touched) set.add(save);355			saves.set(folder, set);356		}357	}358	const byLine = (a: Save, b: Save) => (order.get(a) ?? 0) - (order.get(b) ?? 0);359	return new Map([...saves].map(([folder, set]) => [folder, [...set].sort(byLine)]));360}361362function allFiles(folder: AtlasFolder): AtlasFile[] {363	return [...folder.files, ...folder.folders.flatMap(allFiles)];364}365366// Relative links in a description resolve from the file's own folder, as in a README.367function markdownBase(view: View, path: string): MarkdownBase {368	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };369}