/**
* 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`, and
* the page-only `browse-*` rules from `styles.ts`.
*/
import { GLYPHS } from "../../lib/tui/glyphs.ts";
import { brandLockup, docHead, type KeyHint, keysSheet, themeToggle } 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 type { ChangeIndex } from "./live.ts";
import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
import { renderMarkdown } from "./markdown.ts";
import { encodePath } from "./paths.ts";
import { BROWSE_CSS } from "./styles.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. It is
* the same index the live status feed sends, so the page and its updates agree
* on what "changed" means.
*/
export type ChangeSummary = ChangeIndex;
/** 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 live chrome a page renders: how often its stream is sent a frame. */
export interface LivePage {
intervalMs: number;
}
/**
* The header's connection chip. It starts hidden and says "connecting": the
* client reveals and paints it from the event stream, so a page with JS off — or
* with live status disabled — never shows a state it cannot keep true.
*
* It is deliberately not a `role="status"` region: the header is where a reader
* looks for the page's state, not a place to announce every change to it.
*/
function renderLiveChip(live: LivePage | undefined): string {
if (!live) return "";
return `${escapeHtml(GLYPHS.browse)}connecting`;
}
/**
* The footer's live line, hidden until the first frame: uptime and requests are
* server facts the rendered page does not know at build time.
*/
function renderLiveStatus(): string {
return ``;
}
/**
* The header's changes chip, linking to the diff overview. With live status on,
* a hidden shell is always rendered so the chip can appear and disappear as the
* working tree changes, without the client inserting elements into the header.
*/
function renderChanges(summary: ChangeSummary | undefined, live: LivePage | undefined): string {
const count = summary?.count ?? 0;
if (count <= 0) {
return live
? ``
: "";
}
const label = `${count} change${count === 1 ? "" : "s"}`;
const insertions = summary?.insertions ?? 0;
const deletions = summary?.deletions ?? 0;
const totals =
insertions + deletions > 0
? ` +${insertions}−${deletions}`
: "";
const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${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)}`;
}
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;
/** Live status chrome; omitted when the channel is off. */
live?: LivePage;
}
/** A full two-pane page. */
function renderLayout(layout: Layout): string {
return `
${docHead({ title: layout.title, css: BROWSE_CSS })}
Skip to content
${SPRITE}
${layout.keys && layout.keys.length > 0 ? keysSheet(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;
/** Live status chrome; omitted when the channel is off. */
live?: LivePage;
}
/** 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 = `