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 23 saves: Save[];24};2526type AtlasFolder = {27 name: string;28 path: string;29 folders: AtlasFolder[];30 files: AtlasFile[];31 32 saves: Save[];33};343536type Links = { imports: Map<string, string[]>; usedBy: Map<string, string[]> };3738type View = { repo: string; at: string | null; links: Links };394041const LISTED = 12;424344type AtlasModel = { root: AtlasFolder; links: Links; count: number };4546474849function 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 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}8586878889909192export 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}115116117export 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}135136137138function 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}148149150export 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}189190191192function 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}211212213function 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}291292293function 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}344345346function 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}365366367function markdownBase(view: View, path: string): MarkdownBase {368 return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };369}