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 24 saves: Save[];25};2627type AtlasFolder = {28 name: string;29 path: string;30 folders: AtlasFolder[];31 files: AtlasFile[];32 33 saves: Save[];34};353637type 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 };424344const LISTED = 12;454647484950const 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}6970717273747576export 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 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-search-btn mono" hidden>search</button>119<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>120</div>121<div class="atlas" data-repo="${view.repo}">122<div class="atlas-tree">${children(root, view)}</div>123<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>124</div>125</details>126</section>`;127}128129130function picker(view: View, saves: Save[]): Html {131 return html`<details class="atlas-picker">132<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>133<div class="atlas-picker-popover">134<input class="atlas-picker-filter mono" type="search" placeholder="filter saves" aria-label="Filter saves" hidden>135<div class="atlas-picker-list">${saves.toReversed().map((save) => {136 const message = save.message.split("\n")[0] as string;137 return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${138 view.repo139 }?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>`;140 })}</div>141</div>142</details>`;143}144145function children(folder: AtlasFolder, view: View): Html[] {146 const byName = (a: { name: string }, b: { name: string }) =>147 a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);148 return [149 ...folder.folders.sort(byName).map((child) => folderRow(child, view)),150 ...folder.files.sort(byName).map((file) => fileRow(file, view)),151 ];152}153154function fileRow(file: AtlasFile, view: View): Html {155 const { description } = file;156 const base = markdownBase(view, file.path);157 const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;158 return html`<a class="atlas-row atlas-file" href="${159 href160 }" data-name="${file.name.toLowerCase()}" data-path="${file.path}">${row(file.name, description, base, file.size, file.saves)}</a>161<template>${panel(162 file.name,163 file.path,164 [165 ["size", formatSize(file.size)],166 ["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],167 ...saveFacts(file.saves, view),168 ],169 description,170 base,171 fileMap(file, view),172 )}</template>`;173}174175176177function fileMap(file: AtlasFile, view: View): [string, Html[]][] {178 const users = view.links.usedBy.get(file.path) ?? [];179 return [180 ["exports", listed((file.outline?.exports ?? []).map((name) => html`${name}`))],181 ["imports", targets(view.links.imports.get(file.path) ?? [], view)],182 [183 "used by",184 targets(185 users.filter((path) => !isTestPath(path)),186 view,187 ),188 ],189 ["tested by", targets(users.filter(isTestPath), view)],190 ];191}192193function targets(paths: string[], view: View): Html[] {194 return listed(paths.toSorted().map((path) => targetLink(path, view)));195}196197198function targetLink(target: string, view: View): Html {199 const folder = target.endsWith("/");200 const path = folder ? target.slice(0, -1) : target;201 const query = new URLSearchParams(view.at ? { at: view.at } : {});202 if (folder) query.set("open", path);203 const href = folder204 ? `/${view.repo}?${query}#atlas`205 : `/${view.repo}/files/${pathUrl(path)}${view.at ? `?${query}` : ""}`;206 const name = `${path.split("/").pop()}${folder ? "/" : ""}`;207 return html`<a class="atlas-jump" href="${href}" data-path="${target}" title="${path}">${name}</a>`;208}209210function listed(items: Html[]): Html[] {211 const more = items.length - LISTED;212 return more > 0 ? [...items.slice(0, LISTED), html`+${more} more`] : items;213}214215function linksOf(files: AtlasFile[]): Links {216 const index = repoIndex(files.map((file) => file.path));217 const imports = new Map<string, string[]>();218 const usedBy = new Map<string, string[]>();219 for (const file of files) {220 const found = resolveImports(file.path, file.outline?.imports ?? [], index);221 imports.set(file.path, found);222 for (const target of found) usedBy.set(target, [...(usedBy.get(target) ?? []), file.path]);223 }224 return { imports, usedBy };225}226227function folderRow(folder: AtlasFolder, view: View): Html {228 const description = describeFolder(folder.files);229 const base = markdownBase(view, `${folder.path}/`);230 const files = allFiles(folder);231 const size = files.reduce((sum, f) => sum + f.size, 0);232 const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);233 return html`<details class="atlas-folder" data-path="${folder.path}">234<summary class="atlas-row">${row(235 `${folder.name}/`,236 description,237 base,238 size,239 folder.saves,240 )}</summary>241<template>${panel(242 `${folder.name}/`,243 folder.path,244 [245 ["size", formatSize(size)],246 ["lines", lines.toLocaleString("en-US")],247 ["files", files.length.toLocaleString("en-US")],248 ...saveFacts(folder.saves, view),249 ],250 description,251 base,252 [["used by", targets(folderUsers(folder, files, view), view)]],253 )}</template>254<div class="atlas-children">${children(folder, view)}</div>255</details>`;256}257258function row(259 name: string,260 description: string | null,261 base: MarkdownBase,262 size: number,263 saves: Save[],264): Html {265 const last = saves[saves.length - 1];266 const meta = [formatSize(size), last && relativeTime(last.created_at)]267 .filter(Boolean)268 .join(" · ");269 return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${270 description && renderLine(firstSentence(description), base)271 }</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;272}273274275function folderUsers(folder: AtlasFolder, files: AtlasFile[], view: View): string[] {276 const inside = (path: string) => path.startsWith(`${folder.path}/`);277 const users = new Set(view.links.usedBy.get(`${folder.path}/`) ?? []);278 for (const file of files) {279 for (const user of view.links.usedBy.get(file.path) ?? []) if (!inside(user)) users.add(user);280 }281 return [...users];282}283284function panel(285 name: string,286 path: string,287 facts: [string, Html | string][],288 description: string | null,289 base: MarkdownBase,290 map: [string, Html[]][],291): Html {292 const shown = map.filter(([, items]) => items.length > 0);293 return html`<h3 class="atlas-info-name">${name}</h3>294<p class="atlas-info-path mono">${path}</p>295<dl class="atlas-facts mono">${facts.map(296 ([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,297 )}</dl>298${299 description &&300 html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`301}302${303 shown.length > 0 &&304 html`<dl class="atlas-facts atlas-map mono">${shown.map(305 ([term, items]) =>306 html`<dt>${term}</dt><dd>${items.map((item, i) => html`${i > 0 && " · "}${item}`)}</dd>`,307 )}</dl>`308}`;309}310311function saveFacts(saves: Save[], view: View): [string, Html | string][] {312 const [first, last] = [saves[0], saves[saves.length - 1]];313 if (!first || !last) return [];314 return [315 ["first save", saveLink(first, view)],316 ["last save", saveLink(last, view)],317 ["saves", saves.length.toLocaleString("en-US")],318 ];319}320321function saveLink(save: Save, view: View): Html {322 return html`<a href="/${323 view.repo324 }/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;325}326327328function folderTouches(touches: Touches): Map<string, Save[]> {329 const ids = new Map<string, Set<string>>();330 for (const [path, saves] of touches.byPath) {331 const parts = path.split("/");332 for (let depth = 1; depth < parts.length; depth++) {333 const folder = parts.slice(0, depth).join("/");334 const set = ids.get(folder) ?? new Set<string>();335 for (const save of saves) set.add(save.id);336 ids.set(folder, set);337 }338 }339 return new Map(340 [...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),341 );342}343344function allFiles(folder: AtlasFolder): AtlasFile[] {345 return [...folder.files, ...folder.folders.flatMap(allFiles)];346}347348349function markdownBase(view: View, path: string): MarkdownBase {350 return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };351}352353function countLines(text: string): number {354 if (text === "") return 0;355 let count = 0;356 for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;357 return text.endsWith("\n") ? count : count + 1;358}