/** * 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(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 ``; } /** * 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 })}

${SPRITE}
${brandLockup("browse")} ${escapeHtml(layout.rootLabel)} ${renderGit(layout.git)} ${renderChanges(layout.summary, layout.live)} read-only ${renderLiveChip(layout.live)} ${themeToggle()}
Files
${layout.main}
${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 ``; } function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string { const expanded = node.children.length > 0; const isDir = node.isDir; const spec = isDir ? FOLDER_ICON : languageOf(node.name); const href = hrefFor(node); const current = node.rel === currentRel ? ` aria-current="page"` : ""; const twisty = isDir ? `` : ``; const dataPath = ` data-path="${escapeHtml(node.rel)}"`; const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name); const children = expanded ? `` : ""; const label = node.broken ? `${name}↩` : isDir ? `${name}` : `${name}`; return `
  • ${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}
    ${children}
  • `; } function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string { const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name); const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes; const visual = showThumb ? `${iconMarkup(spec, true)}` : iconMarkup(spec); const name = entry.isDir ? `${escapeHtml(entry.name)}/` : escapeHtml(entry.name); const classes = [entry.name.startsWith(".") ? "browse-dotfile" : "", entry.isSymlink ? "browse-symlink" : ""] .filter(Boolean) .join(" "); const link = entry.broken ? `${name}` : `${name}`; const mark = entry.isSymlink ? `↩` : ""; const attrs = `data-name="${escapeHtml(entry.name)}" data-path="${escapeHtml(entry.rel)}" data-dir="${entry.isDir ? "1" : "0"}" data-size="${entry.size}" data-mtime="${Math.round(entry.mtimeMs)}"`; return `${visual}${link}${changeMark(summary, entry.rel, entry.isDir)}${mark}${entry.isDir ? "—" : escapeHtml(formatSize(entry.size))}${entry.isDir ? "—" : escapeHtml(formatDate(entry.mtimeMs))}`; } 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 = `

    ${escapeHtml(folderName)}

    `; let body = ` ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.summary)).join("\n")}
    `; if (page.listing.entries.length === 0) body = `
    This folder is empty.
    `; else if (page.listing.truncated) body += `

    Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.

    `; return renderLayout({ title: `browse · ${page.rel || folderName}`, rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel), keys: pageKeys(), main: ` ${heading}\n ${body}`, footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`, git: page.git, summary: page.summary, live: page.live, }); } export interface FilePage { rootLabel: string; rel: string; file: FileView; tree: TreeNode; maxFileBytes: number; git?: GitStatus; /** Show the Markdown source instead of the rendered HTML. */ markdownRaw?: boolean; /** Re-emit sanitized raw HTML in rendered Markdown; `false` escapes it as text. */ markdownHtml?: boolean; /** True when this file has uncommitted changes, for the changes action. */ changed?: boolean; /** Changed-path index for the header chip and the tree markers. */ summary?: ChangeSummary; /** Live status chrome; omitted when the channel is off. */ live?: LivePage; } /** The root-relative directory of a file, for resolving its Markdown links. */ function markdownBaseDir(rel: string): string { const slash = rel.lastIndexOf("/"); return slash === -1 ? "" : rel.slice(0, slash); } function renderCodeLines(lines: string[], language?: HighlightLanguage): string { const rendered = language ? highlightLines(lines, language) : lines.map((line) => escapeHtml(line)); return rendered.map((html) => `${html.length > 0 ? html : " "}`).join(""); } /** The last segment of a root-relative path, or the path itself when it has none. */ function lastSegment(path: string): string { const trimmed = path.replace(/[/\\]+$/, ""); const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); return slash === -1 ? trimmed : trimmed.slice(slash + 1); } /** The shortcuts every page shares, plus whatever the page adds. */ function pageKeys(...extra: KeyHint[]): KeyHint[] { return [ { keys: "/ ⌘K", label: "Quick open a file" }, { keys: "-", label: "Fold or expand every folder" }, ...extra, { keys: "?", label: "This sheet" }, ]; } /** The card-head controls: Markdown mode, line wrapping, and copy actions. */ function renderFileActions(page: FilePage): string { const isMarkdown = page.file.kind === "markdown"; const rawHref = `/raw/${encodePath(page.rel)}`; const viewHref = `/view/${encodePath(page.rel)}`; const groups: string[] = []; if (page.changed) { groups.push( `changes`, ); } if (isMarkdown) { const rendered = page.markdownRaw ? `rendered` : `rendered`; const raw = page.markdownRaw ? `raw` : `raw`; groups.push(`${rendered}${raw}`); } if (page.file.kind === "text" || (isMarkdown && page.markdownRaw)) { groups.push( ``, ); } if (page.file.kind === "text" || isMarkdown) { groups.push( ``, ); } groups.push( ``, ); return `${groups.join("")}`; } /** Render a file page (text, Markdown, image, binary, or over-size). */ export function renderFilePage(page: FilePage): string { const name = page.rel.slice(page.rel.lastIndexOf("/") + 1); const spec = languageOf(name); const language = page.file.kind === "text" ? detectLanguage(name) : undefined; // Text is always served as octet-stream, so the response type says nothing // about the file; the language label, size, and date are the useful metadata. const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)}`; const download = `download`; const head = `
    ${iconMarkup(spec)}

    ${escapeHtml(name)}

    ${escapeHtml(meta)}${renderFileActions(page)}${download}
    `; const isMarkdown = page.file.kind === "markdown"; const rendered = isMarkdown && !page.markdownRaw; const wrappable = page.file.kind === "text" || (isMarkdown && page.markdownRaw); const keys = pageKeys( ...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []), ...(wrappable ? [{ keys: "u", label: "Toggle line wrapping" }] : []), ); let content: string; let note = ""; if (page.file.kind === "text" || page.file.kind === "markdown") { if (page.file.kind === "markdown" && !page.markdownRaw) { const html = renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel), { html: page.markdownHtml !== false, }); content = `
    ${html}
    `; } else { content = `
    ${renderCodeLines(page.file.lines, language)}
    `; } if (page.file.truncated) note = `

    Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.

    `; } else if (page.file.kind === "image") { content = `
    ${escapeHtml(name)}
    `; } else if (page.file.kind === "tooLarge") { content = `

    File is ${escapeHtml(formatSize(page.file.size))}.

    Larger than the ${escapeHtml(formatSize(page.maxFileBytes))} inline limit.

    ${download}
    `; } else { content = `

    Looks like a binary file.

    ${escapeHtml(formatSize(page.file.size))} · ${escapeHtml(page.file.mime)}

    ${download}
    `; } return renderLayout({ title: `browse · ${page.rel}`, rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel), mainClass: rendered ? "browse-page browse-measure" : "browse-page", keys, main: `
    ${head}${content}
    \n ${note}`, footerNote: page.rel, git: page.git, summary: page.summary, live: page.live, }); } /** Map a status letter to an accessible label. */ const STATUS_LABEL: Record = { M: "modified", A: "added", D: "deleted", R: "renamed", C: "copied", T: "type changed", U: "unmerged", "?": "untracked", }; function statusWord(letter: string): string { return STATUS_LABEL[letter] ?? "changed"; } /** * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the * old side (context + deleted) and the new side (context + added), so the * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain. * State does not cross hunks, since the unchanged lines between them are absent. */ function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] { if (!language) return lines.map((line) => escapeHtml(line.text)); const out: string[] = new Array(lines.length); let index = 0; while (index < lines.length) { const kind = lines[index].kind; if (kind !== "ctx" && kind !== "add" && kind !== "del") { out[index] = escapeHtml(lines[index].text); index++; continue; } const start = index; const oldDoc: string[] = []; const newDoc: string[] = []; while (index < lines.length) { const body = lines[index].kind; if (body !== "ctx" && body !== "add" && body !== "del") break; if (body === "ctx") { oldDoc.push(lines[index].text); newDoc.push(lines[index].text); } else if (body === "add") { newDoc.push(lines[index].text); } else { oldDoc.push(lines[index].text); } index++; } const oldHtml = highlightLines(oldDoc, language); const newHtml = highlightLines(newDoc, language); let oldIndex = 0; let newIndex = 0; for (let cursor = start; cursor < index; cursor++) { const body = lines[cursor].kind; if (body === "ctx") { out[cursor] = newHtml[newIndex]; newIndex++; oldIndex++; } else if (body === "add") { out[cursor] = newHtml[newIndex]; newIndex++; } else { out[cursor] = oldHtml[oldIndex]; oldIndex++; } } } return out; } /** Render unified-diff lines with a two-column gutter and per-kind styling. */ export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string { const html = highlightDiffText(lines, language); // Every hunk is a section so its sticky header is bounded by its own body; // the section also gives the keyboard a stable, non-sticky scroll target. const out: string[] = []; let hunks = 0; let open = false; lines.forEach((line, index) => { if (line.kind === "hunk") { if (open) out.push(""); hunks += 1; out.push(``); open = true; } const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : ""; const gutter = ``; const marker = ``; out.push( `${gutter}${marker}${html[index]}`, ); }); if (open) out.push(""); return out.join(""); } /** The scope label for a mode. */ function modeScope(mode: DiffMode): string { if (mode === "staged") return "staged vs HEAD"; if (mode === "worktree") return "unstaged"; return "vs HEAD"; } /** The `head | staged | worktree` segmented control; `build` returns each href. */ function renderModeControl(mode: DiffMode, build: (mode: DiffMode) => string): string { const items = DIFF_MODES.map((candidate) => { const current = candidate === mode ? ` aria-current="page"` : ""; return `${candidate}`; }).join(""); return `${items}`; } export interface DiffPage { rootLabel: string; mode: DiffMode; files: ChangedFile[]; stat: string; tree: TreeNode; git?: GitStatus; summary?: ChangeSummary; /** Live status chrome; omitted when the channel is off. */ live?: LivePage; } /** The change overview: `git diff --stat` plus every changed path. */ export function renderDiffPage(page: DiffPage): string { const modeHref = (mode: DiffMode) => (mode === "head" ? "/diff" : `/diff?mode=${mode}`); const body = page.files.length === 0 ? `

    No changes ${escapeHtml(modeScope(page.mode))}.

    The working tree matches this scope.

    ` : ` ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
    StatusPathChanges
    `; const stat = page.stat ? `
    ${escapeHtml(page.stat)}
    ` : ""; return renderLayout({ title: "browse · changes", rootLabel: page.rootLabel, tree: page.tree, currentRel: "", breadcrumbs: renderBreadcrumbs(""), mainClass: "browse-page", keys: pageKeys(), main: `

    Changes ${escapeHtml(modeScope(page.mode))}

    \n
    ${renderModeControl(page.mode, modeHref)}
    \n ${stat}\n ${body}`, footerNote: `${page.files.length} changed`, git: page.git, summary: page.summary, live: page.live, }); } function renderChangedRow(file: ChangedFile, mode: DiffMode): string { const letter = statusLetter(file); const label = statusWord(letter); const badge = `browse-diff-badge browse-diff-badge--${letter === "?" ? "Q" : letter}`; const orig = file.origRel ? ` from ${escapeHtml(file.origRel)}` : ""; const href = `/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`; return `${escapeHtml(letter)}${iconMarkup(languageOf(file.rel))}${escapeHtml(file.rel)}${orig}${escapeHtml(label)}`; } export interface FileDiffPage { rootLabel: string; rel: string; mode: DiffMode; diff: DiffFileView; tree: TreeNode; git?: GitStatus; summary?: ChangeSummary; /** Live status chrome; omitted when the channel is off. */ live?: LivePage; } /** A single file's unified diff, with the scope control and file actions. */ export function renderFileDiffPage(page: FileDiffPage): string { const name = page.rel.slice(page.rel.lastIndexOf("/") + 1); const spec = languageOf(name); const base = `/diff/${encodePath(page.rel)}`; const actions = [ renderModeControl(page.mode, (mode) => (mode === "head" ? base : `${base}?mode=${mode}`)), `view file`, ``, `download`, ``, ]; // A per-file change summary and hunk count, so the scope is visible before scrolling. const adds = page.diff.lines.filter((line) => line.kind === "add").length; const dels = page.diff.lines.filter((line) => line.kind === "del").length; const hunks = page.diff.lines.filter((line) => line.kind === "hunk").length; const summary = hunks > 0 ? `+${adds} −${dels}${hunks} hunk${hunks === 1 ? "" : "s"}` : ""; const keys = pageKeys( { keys: "u", label: "Toggle line wrapping" }, ...(hunks > 0 ? [{ keys: "n p", label: "Next and previous change" }] : []), ); const head = `
    ${iconMarkup(spec)}

    ${escapeHtml(name)}

    ${escapeHtml(modeScope(page.mode))}${summary}${actions.join("")}
    `; let content: string; let note = ""; if (page.diff.binary) { content = `

    Binary or unreadable change.

    No text diff is shown for this file.

    download
    `; } else if (page.diff.lines.length === 0) { content = `

    No changes for this file.

    It is unchanged in the selected scope.

    `; } else { content = `
    ${renderDiffLines(page.diff.lines, detectLanguage(name))}
    `; if (page.diff.truncated) note = `

    Showing the first ${page.diff.lines.length} diff lines.

    `; } return renderLayout({ title: `browse · ${page.rel}`, rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel), mainClass: "browse-page", keys, main: `
    ${head}${content}
    \n ${note}`, footerNote: `diff · ${modeScope(page.mode)}`, git: page.git, summary: page.summary, live: page.live, }); } export interface ErrorPage { rootLabel: string; status: number; message: string; tree: TreeNode; git?: GitStatus; /** Live status chrome; omitted when the channel is off. */ live?: LivePage; } /** Render a status/error page with the same chrome. */ export function renderErrorPage(page: ErrorPage): string { return renderLayout({ title: `browse · ${page.status}`, rootLabel: page.rootLabel, tree: page.tree, currentRel: "", breadcrumbs: renderBreadcrumbs(""), main: `
    ${page.status}

    ${escapeHtml(page.message)}

    back to the served folder

    `, footerNote: "error", git: page.git, live: page.live, }); }