1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";3import { isText } from "./files.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 22 saves: Save[];23};2425type AtlasFolder = {26 name: string;27 path: string;28 folders: AtlasFolder[];29 files: AtlasFile[];30 31 saves: Save[];32};3334type View = { repo: string; at: string | null };3536type Facts = { lines: number | null; description: string | null };373839404142const known = new Map<string, Facts>();4344function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {45 const key = `${hash} ${name}`;46 const cached = known.get(key);47 if (cached) return cached;48 const bytes = objectBytes(db, hash);49 const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;50 const facts =51 text === null52 ? { lines: null, description: null }53 : { lines: countLines(text), description: describeFile(name, text) };54 known.set(key, facts);55 return facts;56}5758596061626364export function atlasSection(65 repo: PublicRepo,66 saveId: string,67 at: string | null,68 saves: Save[],69): Html {70 const touches = fileTouches(repo.mirror, saveId);71 const foldersTouches = folderTouches(touches);72 const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };73 const entries = filesAt(repo.mirror, saveId);74 for (const entry of entries) {75 const parts = entry.path.split("/");76 let folder = root;77 for (const name of parts.slice(0, -1)) {78 let child = folder.folders.find((f) => f.name === name);79 if (!child) {80 const path = folder.path ? `${folder.path}/${name}` : name;81 child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };82 folder.folders.push(child);83 }84 folder = child;85 }86 const name = parts[parts.length - 1] as string;87 folder.files.push({88 name,89 path: entry.path,90 size: entry.size,91 ...factsOf(repo.mirror, entry.hash, name),92 saves: touches.byPath.get(entry.path) ?? [],93 });94 }95 const view = { repo: repo.settings.name, at };96 return html`<section class="repo-section" id="atlas">97<details class="repo-section-toggle" open>98<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(99 entries.length,100 "file",101 )}</span></summary>102<div class="atlas-bar">103${picker(view, saves)}104${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}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}">109<div class="atlas-tree">${children(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}115116117function picker(view: View, saves: Save[]): Html {118 return html`<details class="atlas-picker">119<summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>120<div class="atlas-picker-popover">121<input class="atlas-picker-filter mono" type="search" placeholder="filter saves" aria-label="Filter saves" hidden>122<div class="atlas-picker-list">${saves.toReversed().map((save) => {123 const message = save.message.split("\n")[0] as string;124 return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${125 view.repo126 }?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>`;127 })}</div>128</div>129</details>`;130}131132function children(folder: AtlasFolder, view: View): Html[] {133 const byName = (a: { name: string }, b: { name: string }) =>134 a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);135 return [136 ...folder.folders.sort(byName).map((child) => folderRow(child, view)),137 ...folder.files.sort(byName).map((file) => fileRow(file, view)),138 ];139}140141function fileRow(file: AtlasFile, view: View): Html {142 const { description } = file;143 const base = markdownBase(view, file.path);144 const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;145 return html`<a class="atlas-row atlas-file" href="${146 href147 }" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>148<template>${panel(149 file.name,150 file.path,151 [152 ["size", formatSize(file.size)],153 ["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],154 ...saveFacts(file.saves, view),155 ],156 description,157 base,158 )}</template>`;159}160161function folderRow(folder: AtlasFolder, view: View): Html {162 const description = describeFolder(folder.files);163 const base = markdownBase(view, `${folder.path}/`);164 const files = allFiles(folder);165 const size = files.reduce((sum, f) => sum + f.size, 0);166 const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);167 return html`<details class="atlas-folder" data-path="${folder.path}">168<summary class="atlas-row">${row(169 `${folder.name}/`,170 description,171 base,172 size,173 folder.saves,174 )}</summary>175<template>${panel(176 `${folder.name}/`,177 folder.path,178 [179 ["size", formatSize(size)],180 ["lines", lines.toLocaleString("en-US")],181 ["files", files.length.toLocaleString("en-US")],182 ...saveFacts(folder.saves, view),183 ],184 description,185 base,186 )}</template>187<div class="atlas-children">${children(folder, view)}</div>188</details>`;189}190191function row(192 name: string,193 description: string | null,194 base: MarkdownBase,195 size: number,196 saves: Save[],197): Html {198 const last = saves[saves.length - 1];199 const meta = [formatSize(size), last && relativeTime(last.created_at)]200 .filter(Boolean)201 .join(" · ");202 return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${203 description && renderLine(firstSentence(description), base)204 }</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;205}206207function panel(208 name: string,209 path: string,210 facts: [string, Html | string][],211 description: string | null,212 base: MarkdownBase,213): Html {214 return html`<h3 class="atlas-info-name">${name}</h3>215<p class="atlas-info-path mono">${path}</p>216<dl class="atlas-facts mono">${facts.map(217 ([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,218 )}</dl>219${220 description &&221 html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`222}`;223}224225function saveFacts(saves: Save[], view: View): [string, Html | string][] {226 const [first, last] = [saves[0], saves[saves.length - 1]];227 if (!first || !last) return [];228 return [229 ["first save", saveLink(first, view)],230 ["last save", saveLink(last, view)],231 ["saves", saves.length.toLocaleString("en-US")],232 ];233}234235function saveLink(save: Save, view: View): Html {236 return html`<a href="/${237 view.repo238 }/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;239}240241242function folderTouches(touches: Touches): Map<string, Save[]> {243 const ids = new Map<string, Set<string>>();244 for (const [path, saves] of touches.byPath) {245 const parts = path.split("/");246 for (let depth = 1; depth < parts.length; depth++) {247 const folder = parts.slice(0, depth).join("/");248 const set = ids.get(folder) ?? new Set<string>();249 for (const save of saves) set.add(save.id);250 ids.set(folder, set);251 }252 }253 return new Map(254 [...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),255 );256}257258function allFiles(folder: AtlasFolder): AtlasFile[] {259 return [...folder.files, ...folder.folders.flatMap(allFiles)];260}261262263function markdownBase(view: View, path: string): MarkdownBase {264 return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };265}266267function countLines(text: string): number {268 if (text === "") return 0;269 let count = 0;270 for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;271 return text.endsWith("\n") ? count : count + 1;272}