import { describeFolder, firstSentence } from "../describe/describe.ts"; import { isTestPath, type Outline, repoIndex, resolveImports } from "../describe/links.ts"; import { filesWithFacts, fileTouches, type Save, type Touches } from "../mirror/queries.ts"; import { type Html, html } from "./html.ts"; import { formatDate, formatSize, type PublicRepo, pathUrl, plural, relativeTime, } from "./layout.ts"; import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts"; type AtlasFile = { name: string; path: string; size: number; lines: number | null; description: string | null; outline: Outline | null; /** The saves that touched it, oldest first. */ saves: Save[]; }; type AtlasFolder = { name: string; path: string; folders: AtlasFolder[]; files: AtlasFile[]; /** The saves that touched anything under it, removed files included, oldest first. */ saves: Save[]; }; /** Each file's resolved imports, and who imports each file or `folder/`. */ type Links = { imports: Map; usedBy: Map }; type View = { repo: string; at: string | null; links: Links }; // A panel lists this many names or links, then says how many more. const LISTED = 12; /** The tree at one save with each file's facts and the links between files. */ type AtlasModel = { root: AtlasFolder; links: Links; count: number }; // Built per request from what each save recorded as it landed (touches and facts), so nothing is // kept between requests. In one read snapshot: the sync's thread can change the mirror between any // two statements. function atlasModel(repo: PublicRepo, saveId: string): AtlasModel { return repo.mirror.transaction(() => buildAtlasModel(repo, saveId))(); } function buildAtlasModel(repo: PublicRepo, saveId: string): AtlasModel { const touches = fileTouches(repo.mirror, saveId); const foldersTouches = folderTouches(touches); const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] }; // Koh's own rules file is bookkeeping, not part of the project; Koh never saves what it names. const entries = filesWithFacts(repo.mirror, saveId).filter( (entry) => entry.path !== ".kohignore", ); for (const entry of entries) { const parts = entry.path.split("/"); let folder = root; for (const name of parts.slice(0, -1)) { let child = folder.folders.find((f) => f.name === name); if (!child) { const path = folder.path ? `${folder.path}/${name}` : name; child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] }; folder.folders.push(child); } folder = child; } folder.files.push({ name: parts[parts.length - 1] as string, path: entry.path, size: entry.size, lines: entry.lines, description: entry.description, outline: entry.outline, saves: touches.byPath.get(entry.path) ?? [], }); } return { root, links: linksOf(allFiles(root)), count: entries.length }; } /** * §21.5's Atlas at one save: the folder tree as `
`, so it opens and links without * JavaScript. A row's detail panel is fetched from `atlasPanel` on hover or select (kepr.js), so * a big repo's page carries rows only. `HEAD` (or the save shown) opens Kepr 2's picker of saves * to view it at instead, its rows fetched from `atlasPickerRows` when first opened. */ export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html { const model = atlasModel(repo, saveId); const view = { repo: repo.settings.name, at, links: model.links }; return html`
${plural( model.count, "file", )}
${picker(view)} ${at && html`back to HEAD`}
${children(model.root, view)}
`; } /** One row's detail panel: a file's, or a folder's when `path` ends in a slash; null if absent. */ export function atlasPanel( repo: PublicRepo, saveId: string, at: string | null, path: string, ): Html | null { const model = atlasModel(repo, saveId); const view = { repo: repo.settings.name, at, links: model.links }; const parts = path.replace(/\/$/, "").split("/"); let folder: AtlasFolder | undefined = model.root; for (const name of path.endsWith("/") ? parts : parts.slice(0, -1)) { folder = folder?.folders.find((f) => f.name === name); } if (!folder) return null; if (path.endsWith("/")) return folderPanel(folder, view); const file = folder.files.find((f) => f.path === path); return file ? filePanel(file, view) : null; } // Kepr 2's "explore through time". Its rows arrive when first opened (kepr.js): a big repo's // every save was most of its page. Without JavaScript, History's save pages link to the Atlas. function picker(view: View): Html { const query = view.at ? `?at=${view.at}` : ""; return html`
${view.at ?? "HEAD"}
`; } /** The picker's rows: every save, newest first, `at` marked. kepr.js filters on their text. */ export function atlasPickerRows(repo: string, saves: Save[], at: string | null): Html[] { return saves.toReversed().map((save) => { const message = save.message.split("\n")[0] as string; return html`${save.id}${message}· ${relativeTime(save.created_at)}`; }); } function children(folder: AtlasFolder, view: View): Html[] { const byName = (a: { name: string }, b: { name: string }) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name); return [ ...folder.folders.sort(byName).map((child) => folderRow(child, view)), ...folder.files.sort(byName).map((file) => fileRow(file, view)), ]; } function fileRow(file: AtlasFile, view: View): Html { const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`; return html`${row(file.name, file.description, markdownBase(view, file.path), file.size, file.saves)}`; } function filePanel(file: AtlasFile, view: View): Html { return panel( file.name, file.path, [ ["size", formatSize(file.size)], ["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")], ...saveFacts(file.saves, view), ], file.description, markdownBase(view, file.path), fileMap(file, view), ); } // Kepr 2's map of the repo, only where a file has something to say: what it offers, what it // leans on, and what leans on it, tests apart. function fileMap(file: AtlasFile, view: View): [string, Html[]][] { const users = view.links.usedBy.get(file.path) ?? []; return [ ["exports", listed((file.outline?.exports ?? []).map((name) => html`${name}`))], ["imports", targets(view.links.imports.get(file.path) ?? [], view)], [ "used by", targets( users.filter((path) => !isTestPath(path)), view, ), ], ["tested by", targets(users.filter(isTestPath), view)], ]; } function targets(paths: string[], view: View): Html[] { return listed(paths.toSorted().map((path) => targetLink(path, view))); } // A file links to its page and a folder to the Atlas with it open; kepr.js jumps to the row. function targetLink(target: string, view: View): Html { const folder = target.endsWith("/"); const path = folder ? target.slice(0, -1) : target; const query = new URLSearchParams(view.at ? { at: view.at } : {}); if (folder) query.set("open", path); const href = folder ? `/${view.repo}?${query}#atlas` : `/${view.repo}/files/${pathUrl(path)}${view.at ? `?${query}` : ""}`; const name = `${path.split("/").pop()}${folder ? "/" : ""}`; return html`${name}`; } function listed(items: Html[]): Html[] { const more = items.length - LISTED; return more > 0 ? [...items.slice(0, LISTED), html`+${more} more`] : items; } function linksOf(files: AtlasFile[]): Links { const index = repoIndex(files.map((file) => file.path)); const imports = new Map(); const usedBy = new Map(); for (const file of files) { const found = resolveImports(file.path, file.outline?.imports ?? [], index); imports.set(file.path, found); for (const target of found) usedBy.set(target, [...(usedBy.get(target) ?? []), file.path]); } return { imports, usedBy }; } function folderRow(folder: AtlasFolder, view: View): Html { const files = allFiles(folder); const size = files.reduce((sum, f) => sum + f.size, 0); return html`
${row( `${folder.name}/`, describeFolder(folder.files), markdownBase(view, `${folder.path}/`), size, folder.saves, )}
${children(folder, view)}
`; } function folderPanel(folder: AtlasFolder, view: View): Html { const files = allFiles(folder); const size = files.reduce((sum, f) => sum + f.size, 0); const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0); return panel( `${folder.name}/`, folder.path, [ ["size", formatSize(size)], ["lines", lines.toLocaleString("en-US")], ["files", files.length.toLocaleString("en-US")], ...saveFacts(folder.saves, view), ], describeFolder(folder.files), markdownBase(view, `${folder.path}/`), [["used by", targets(folderUsers(folder, files, view), view)]], ); } function row( name: string, description: string | null, base: MarkdownBase, size: number, saves: Save[], ): Html { const last = saves[saves.length - 1]; const meta = [formatSize(size), last && relativeTime(last.created_at)] .filter(Boolean) .join(" · "); return html`${name}${html`${ description && renderLine(firstSentence(description), base) }`}${html`${meta}`}`; } // Files outside a folder that import it as a module, or anything inside it. function folderUsers(folder: AtlasFolder, files: AtlasFile[], view: View): string[] { const inside = (path: string) => path.startsWith(`${folder.path}/`); const users = new Set(view.links.usedBy.get(`${folder.path}/`) ?? []); for (const file of files) { for (const user of view.links.usedBy.get(file.path) ?? []) if (!inside(user)) users.add(user); } return [...users]; } function panel( name: string, path: string, facts: [string, Html | string][], description: string | null, base: MarkdownBase, map: [string, Html[]][], ): Html { const shown = map.filter(([, items]) => items.length > 0); return html`

${name}

${path}

${facts.map( ([term, value]) => html`
${term}
${value}
`, )}
${ description && html`
${renderMarkdown(description, base)}
` } ${ shown.length > 0 && html`
${shown.map( ([term, items]) => html`
${term}
${items.map((item, i) => html`${i > 0 && " · "}${item}`)}
`, )}
` }`; } function saveFacts(saves: Save[], view: View): [string, Html | string][] { const [first, last] = [saves[0], saves[saves.length - 1]]; if (!first || !last) return []; return [ ["first save", saveLink(first, view)], ["last save", saveLink(last, view)], ["saves", saves.length.toLocaleString("en-US")], ]; } function saveLink(save: Save, view: View): Html { return html`${save.id} ${formatDate(save.created_at)}`; } // Each folder's saves are the line's saves that touched any path under it, in line order. function folderTouches(touches: Touches): Map { const order = new Map(touches.line.map((save, i) => [save, i])); const saves = new Map>(); for (const [path, touched] of touches.byPath) { const parts = path.split("/"); for (let depth = 1; depth < parts.length; depth++) { const folder = parts.slice(0, depth).join("/"); const set = saves.get(folder) ?? new Set(); for (const save of touched) set.add(save); saves.set(folder, set); } } const byLine = (a: Save, b: Save) => (order.get(a) ?? 0) - (order.get(b) ?? 0); return new Map([...saves].map(([folder, set]) => [folder, [...set].sort(byLine)])); } function allFiles(folder: AtlasFolder): AtlasFile[] { return [...folder.files, ...folder.folders.flatMap(allFiles)]; } // Relative links in a description resolve from the file's own folder, as in a README. function markdownBase(view: View, path: string): MarkdownBase { return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at }; }