1import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2import {3 type FileHistory,4 fileHistories,5 filesAt,6 objectBytes,7 type Save,8} from "../mirror/queries.ts";9import { isText } from "./files.ts";10import { type Html, html } from "./html.ts";11import {12 formatDate,13 formatSize,14 type PublicRepo,15 pathUrl,16 plural,17 relativeTime,18} from "./layout.ts";19import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts";2021type AtlasFile = {22 name: string;23 path: string;24 size: number;25 lines: number | null;26 description: string | null;27 history: FileHistory | undefined;28};2930type AtlasFolder = { name: string; path: string; folders: AtlasFolder[]; files: AtlasFile[] };3132type View = { repo: string; at: string | null };3334type Facts = { lines: number | null; description: string | null };353637383940const known = new Map<string, Facts>();4142function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {43 const key = `${hash} ${name}`;44 const cached = known.get(key);45 if (cached) return cached;46 const bytes = objectBytes(db, hash);47 const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;48 const facts =49 text === null50 ? { lines: null, description: null }51 : { lines: countLines(text), description: describeFile(name, text) };52 known.set(key, facts);53 return facts;54}55565758596061export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {62 const histories = fileHistories(repo.mirror, saveId);63 const root: AtlasFolder = { name: "", path: "", folders: [], files: [] };64 const entries = filesAt(repo.mirror, saveId);65 for (const entry of entries) {66 const parts = entry.path.split("/");67 let folder = root;68 for (const name of parts.slice(0, -1)) {69 let child = folder.folders.find((f) => f.name === name);70 if (!child) {71 const path = folder.path ? `${folder.path}/${name}` : name;72 child = { name, path, folders: [], files: [] };73 folder.folders.push(child);74 }75 folder = child;76 }77 const name = parts[parts.length - 1] as string;78 folder.files.push({79 name,80 path: entry.path,81 size: entry.size,82 ...factsOf(repo.mirror, entry.hash, name),83 history: histories.get(entry.path),84 });85 }86 const view = { repo: repo.settings.name, at };87 const where = at88 ? html`at <a href="/${view.repo}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`89 : "HEAD";90 return html`<section class="repo-section" id="atlas">91<details class="repo-section-toggle" open>92<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(entries.length, "file")}</span></summary>93<p class="atlas-where mono">${where}</p>94<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>95<div class="atlas" data-repo="${view.repo}">96<div class="atlas-tree">${children(root, view)}</div>97<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>98</div>99</details>100</section>`;101}102103function children(folder: AtlasFolder, view: View): Html[] {104 const byName = (a: { name: string }, b: { name: string }) =>105 a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);106 return [107 ...folder.folders.sort(byName).map((child) => folderRow(child, view)),108 ...folder.files.sort(byName).map((file) => fileRow(file, view)),109 ];110}111112function fileRow(file: AtlasFile, view: View): Html {113 const { description } = file;114 const base = markdownBase(view, file.path);115 const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;116 const last = file.history?.last;117 return html`<a class="atlas-row atlas-file" href="${href}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, last)}</a>118<template>${panel(119 file.name,120 file.path,121 [122 ["size", formatSize(file.size)],123 ["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],124 ...saveFacts(file.history, view),125 ],126 description,127 base,128 )}${file.history && recent(file.history.recent, view)}</template>`;129}130131function folderRow(folder: AtlasFolder, view: View): Html {132 const description = describeFolder(folder.files);133 const base = markdownBase(view, `${folder.path}/`);134 const files = allFiles(folder);135 const size = files.reduce((sum, f) => sum + f.size, 0);136 const last = files137 .map((f) => f.history?.last)138 .reduce<Save | undefined>(139 (a, b) => (b && (!a || b.created_at > a.created_at) ? b : a),140 undefined,141 );142 return html`<details class="atlas-folder" data-path="${folder.path}">143<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, last)}</summary>144<template>${panel(145 `${folder.name}/`,146 folder.path,147 [148 ["size", formatSize(size)],149 ["files", files.length.toLocaleString("en-US")],150 ...(last ? ([["last save", saveLink(last, view)]] as [string, Html][]) : []),151 ],152 description,153 base,154 )}</template>155<div class="atlas-children">${children(folder, view)}</div>156</details>`;157}158159function row(160 name: string,161 description: string | null,162 base: MarkdownBase,163 size: number,164 last: Save | undefined,165): Html {166 const meta = [formatSize(size), last && relativeTime(last.created_at)]167 .filter(Boolean)168 .join(" · ");169 return html`<span class="atlas-name">${name}</span><span class="atlas-desc">${description && renderLine(firstSentence(description), base)}</span><span class="atlas-meta mono">${meta}</span>`;170}171172function panel(173 name: string,174 path: string,175 facts: [string, Html | string][],176 description: string | null,177 base: MarkdownBase,178): Html {179 return html`<h3 class="atlas-info-name">${name}</h3>180<p class="atlas-info-path mono">${path}</p>181<dl class="atlas-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>182${description && html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`}`;183}184185function saveFacts(history: FileHistory | undefined, view: View): [string, Html | string][] {186 if (!history) return [];187 return [188 ["first save", saveLink(history.first, view)],189 ["last save", saveLink(history.last, view)],190 ["saves", history.touches.toLocaleString("en-US")],191 ];192}193194function saveLink(save: Save, view: View): Html {195 return html`<a href="/${view.repo}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;196}197198function recent(saves: Save[], view: View): Html {199 return html`<ul class="atlas-recent">${saves.map(200 (save) =>201 html`<li><a class="mono" href="/${view.repo}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,202 )}</ul>`;203}204205function allFiles(folder: AtlasFolder): AtlasFile[] {206 return [...folder.files, ...folder.folders.flatMap(allFiles)];207}208209210function markdownBase(view: View, path: string): MarkdownBase {211 return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };212}213214function countLines(text: string): number {215 if (text === "") return 0;216 let count = 0;217 for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;218 return text.endsWith("\n") ? count : count + 1;219}