/**
* HTML rendering for the browse pages.
*
* Pure string building: the router reads the filesystem and hands these
* renderers plain view models. Everything user-controlled is escaped, links are
* percent-encoded per segment, and the only script is the same-origin
* `/app.js`. The shared Bytefly shell and components come from `lib/web`; the
* `browse-*` rules below are the page-only layer.
*/
import { GLYPHS } from "../../lib/tui/glyphs.ts";
import {
brandLockup,
docHead,
SIDEBAR_COLLAPSE_MAX,
WEB_BASE_CSS,
WEB_COMPONENTS_CSS,
WEB_LAYOUT_CSS,
WEB_THEME_CSS,
} from "../../lib/web/index.ts";
import { escapeHtml } from "../../lib/escape.ts";
import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
import type { DiffFileView, GitStatus } from "./git.ts";
import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
import { encodePath } from "./paths.ts";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** A short local date like `Oct 8 20:31`. */
export function formatDate(ms: number): string {
const date = new Date(ms);
if (Number.isNaN(date.getTime())) return "—";
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`;
}
function iconMarkup(spec: IconSpec, hidden = false): string {
return ``;
}
/** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
function renderGit(git: GitStatus | undefined): string {
if (!git) return "";
const name = git.branch ?? git.head;
if (!name) return "";
const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
return `${escapeHtml(glyph)}${escapeHtml(name)}`;
}
/** The changed-path index for markers, plus the totals shown in the chip. */
export interface ChangeSummary {
count: number;
insertions: number;
deletions: number;
files: ReadonlySet;
dirs: ReadonlySet;
}
/** Whether a path carries a change marker: a changed file, or a directory above one. */
function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
if (!summary || !rel) return false;
return isDir ? summary.dirs.has(rel) : summary.files.has(rel);
}
/** A small dot marking a changed file or a directory that contains one. */
function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: boolean): string {
return isChanged(summary, rel, isDir)
? `●`
: "";
}
/** The header's changes chip, linking to the diff overview when there are changes. */
function renderChanges(summary: ChangeSummary | undefined): string {
if (!summary || summary.count <= 0) return "";
const label = `${summary.count} change${summary.count === 1 ? "" : "s"}`;
const totals =
summary.insertions + summary.deletions > 0
? ` +${summary.insertions}−${summary.deletions}`
: "";
const aria = `${label} against HEAD${summary.insertions + summary.deletions > 0 ? `, ${summary.insertions} insertions and ${summary.deletions} deletions` : ""}`;
return `●${escapeHtml(label)}${totals}`;
}
function hrefFor(entry: { rel: string; isDir: boolean }): string {
return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
}
/** The shared shell, components, and the page-only `browse-*` rules. */
const STYLES = `${WEB_BASE_CSS}
${WEB_THEME_CSS}
${WEB_LAYOUT_CSS}
${WEB_COMPONENTS_CSS}
.browse-side{padding:var(--space-3)}
.browse-root{flex:1 1 auto;direction:rtl;text-align:left}
.browse-git{font-family:var(--font-mono)}
.browse-quick-open{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;margin-bottom:var(--space-2);padding:6px var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);color:var(--muted);font:var(--fs-sm) var(--font-ui);cursor:pointer}
.browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
.browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
.browse-cat{width:16px;height:16px;flex:0 0 16px;color:var(--accent)}
.browse-cat.doc{color:var(--muted)}.browse-cat.img{color:var(--success)}.browse-cat.cfg{color:var(--warn)}.browse-cat.arc{color:var(--warn)}
.browse-heading{margin:0 0 var(--space-3);font-size:var(--fs-lg)}
.browse-listing th.bf-table__size,.browse-listing th.bf-table__when{text-align:right}
.browse-listing th.bf-table__size{width:96px}
.browse-listing th.bf-table__when{width:140px}
.browse-dotfile .bf-table__name a{opacity:.72}
.browse-broken{color:var(--muted);text-decoration:line-through}
.browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
.browse-when{display:none}
.browse-md-outline{max-width:80ch;margin:0 0 var(--space-3) var(--space-4);border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--fs-sm)}
.browse-md-outline>summary{padding:var(--space-2) var(--space-3);cursor:pointer;color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs)}
.browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
.browse-md-outline a{display:block;padding:2px 0}
.browse-md-outline a.lvl1{padding-left:var(--space-3)}
.browse-md-outline a.lvl2{padding-left:var(--space-5)}
.browse-md-outline a.lvl3{padding-left:var(--space-7)}
body.palette-open{overflow:hidden}
.browse-diff{font-family:var(--font-mono);gap:5px}
.browse-diff:hover{color:var(--accent);text-decoration:none}
.browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
.browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
.browse-diff-totals{font-family:var(--font-mono)}
.browse-diff-add{color:var(--success)}
.browse-diff-del{color:var(--danger)}
.browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
.browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
.browse-diff-stat{margin:0 0 var(--space-3);font:var(--fs-xs)/1.5 var(--font-mono);color:var(--muted);white-space:pre-wrap;overflow-x:auto}
.browse-diff th.browse-diff-status{width:72px}
.browse-diff th.browse-diff-word{width:130px}
.browse-diff-badge{display:inline-block;min-width:16px;text-align:center;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);border-radius:var(--radius-sm);padding:1px 5px;border:1px solid var(--border)}
.browse-diff-badge--M{color:var(--warn);border-color:currentColor}
.browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
.browse-diff-badge--D{color:var(--danger);border-color:currentColor}
.browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
.browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
.browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
.browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
.browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
.bf-diff-line--add{background:var(--success-soft)}
.bf-diff-line--add .browse-diff-sign{color:var(--success)}
.bf-diff-line--del{background:var(--danger-soft)}
.bf-diff-line--del .browse-diff-sign{color:var(--danger)}
.bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
.bf-diff-line--meta{color:var(--muted)}
.bf-diff-line--note{color:var(--muted);font-style:italic}
/* A hunk header pins to the top of the diff viewport while its body scrolls.
Each hunk is a section so the pinned header is bounded by its own body and
cannot stack on the next one. */
.bf-diff-hunk{display:block}
.bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
/* Reading progress, pinned to the sticky header's bottom edge. */
.browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
.browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
/* File and diff pages: a measured reading column inside the card. */
/* GitHub's markdown body width: one content column that headings, prose,
tables, code, and images all live in, with wide tables scrolling inside it. */
.browse-measure{--browse-measure:1012px}
.browse-measure .bf-md{max-width:var(--browse-measure);margin-inline:auto;padding-inline:0;padding-block:var(--space-4) var(--space-6)}
.browse-measure .browse-md-outline{max-width:var(--browse-measure);margin:0 auto var(--space-3)}
.bf-card__meta{font-variant-numeric:tabular-nums}
.browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
.browse-diff-summary .browse-diff-add{color:var(--success)}
.browse-diff-summary .browse-diff-del{color:var(--danger)}
.browse-hunks{color:var(--muted)}
.browse-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
.browse-keys dt{white-space:nowrap}
.browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
.browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
/* Wide screens: pin quick open and the filter, and scroll the tree on its own
box. The sidebar is a details element, whose content sits in an anonymous
wrapper with no definite height, so the panel fills the sidebar by absolute
positioning; below the sidebar breakpoint the disclosure keeps normal page
scroll. */
@media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
.browse-sidebar{position:relative;overflow:hidden}
.browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
.browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
}
@media (max-width:820px){
.browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
}
@media (max-width:520px){
.bf-toolbar .bf-theme-toggle{margin-left:auto}
}`;
/** One row of the keyboard sheet: the key or chord, and what it does. */
export interface KeyHint {
keys: string;
label: string;
}
interface Layout {
title: string;
rootLabel: string;
tree: TreeNode;
currentRel: string;
breadcrumbs: string;
main: string;
footerNote: string;
/** Extra classes on `main`, for page-scoped rules. */
mainClass?: string;
/** Rows for the `?` keyboard sheet; omitted when the page has no shortcuts. */
keys?: KeyHint[];
git?: GitStatus;
/** Changed-path index for the header chip and the tree markers; omitted when clean. */
summary?: ChangeSummary;
}
/** The `?` keyboard sheet, hidden until the client opens it. */
function renderKeysSheet(keys: KeyHint[]): string {
const rows = keys
.map(
(key) =>
`
${escapeHtml(key.keys)}
${escapeHtml(key.label)}
`,
)
.join("");
return `
Keyboard shortcuts
${rows}
`;
}
/** A full two-pane page. */
function renderLayout(layout: Layout): string {
return `
${docHead({ title: layout.title, css: STYLES })}
Skip to content
${SPRITE}
${layout.keys && layout.keys.length > 0 ? renderKeysSheet(layout.keys) : ""}
`;
}
/** The breadcrumb trail for a root-relative path, up to (not including) the current page. */
export function renderBreadcrumbs(rel: string): string {
const segments = rel.split("/").filter(Boolean);
if (segments.length === 0) return `home`;
// The last segment is the current page, which the heading or card title names;
// the trail stops at its parent so the name is not shown twice.
const parts = [`home`];
let accumulated = "";
for (const segment of segments.slice(0, -1)) {
accumulated = accumulated ? `${accumulated}/${segment}` : segment;
parts.push(`${escapeHtml(segment)}`);
}
return parts.join(`/`);
}
function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
return `
`;
}
export interface DirectoryPage {
rootLabel: string;
rel: string;
listing: Listing;
tree: TreeNode;
thumbnails: boolean;
maxThumbBytes: number;
git?: GitStatus;
/** Changed-path index for the header chip and markers. */
summary?: ChangeSummary;
}
/** Render a directory listing page. */
export function renderDirectoryPage(page: DirectoryPage): string {
// At the root there is no folder name to show, so the served folder names it.
const folderName = page.rel ? lastSegment(page.rel) : lastSegment(page.rootLabel) || "/";
const heading = `