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 23 saves: Save[];24};2526type AtlasFolder = {27 name: string;28 path: string;29 folders: AtlasFolder[];30 files: AtlasFile[];31 32 saves: Save[];33};3435type View = { repo: string; at: string | null };3637type Facts = { lines: number | null; description: string | null };383940414243const 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}5859606162636465export 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}117118119120function 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}265266267function 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}286287288function 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}