/** * 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 { baseAttribute, brandLockup, docHead, type KeyHint, keysSheet, tabTitle, themeToggle, } from "../../../lib/web/index.ts"; import { escapeHtml } from "../../../lib/escape.ts"; import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts"; import { type DirEntry, type FileView, type Listing, type TreeNode } from "../files/files.ts"; import { formatSize } from "../../../lib/format.ts"; import { DIFF_MODES, statusLetter, type ChangedFile, type DiffMode } from "../text/diff.ts"; import { renderDiffLines } from "./diff-html.ts"; import type { DiffFileView, GitStatus } from "../git/git.ts"; import type { ChangeIndex } from "../live/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 "../files/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 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 URL one tree or table entry links to, written under the page's `base`. * * Exported because the JSON `/api/tree` route answers with the same hrefs, so the rendered * tree and the client's refresh cannot disagree about where an entry lives. */ export function hrefFor(entry: { rel: string; isDir: boolean }, base: string): string { return entry.isDir ? `${base}/browse/${encodePath(entry.rel)}` : `${base}/view/${encodePath(entry.rel)}`; } interface Layout { /** The mount prefix this page answers under, e.g. `/files`; `""` for the origin's root. */ base: string; 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, base: layout.base })}

${SPRITE}
${brandLockup("browse")} ${escapeHtml(layout.rootLabel)} ${renderGit(layout.git)} ${renderChanges(layout.summary, layout.live, layout.base)} read-only ${renderLiveChrome(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, base: 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, base: string, summary?: ChangeSummary): string { return ``; } function renderTreeNode(node: TreeNode, currentRel: string, base: 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, base); 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, base: string, 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 { base: string; 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.base, 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({ base: page.base, title: tabTitle("browse", page.rel || folderName), rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel, page.base), 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 { base: string; 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" }, ]; } /** Whether the page renders lines with a number beside them: plain text, or raw Markdown. */ function numberedLines(page: FilePage): boolean { return page.file.kind === "text" || (page.file.kind === "markdown" && page.markdownRaw === true); } /** The card-head controls: Markdown mode, line wrapping, and the copy actions, * plus the download the diff head keeps in the same group. */ function renderFileActions(page: FilePage, download: string): string { const isMarkdown = page.file.kind === "markdown"; const rawHref = `${page.base}/raw/${encodePath(page.rel)}`; const viewHref = `${page.base}/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 (numberedLines(page)) { groups.push( ``, ); } if (page.file.kind === "text" || isMarkdown) { groups.push( ``, ); } groups.push( ``, ); if (numberedLines(page)) { groups.push( ``, ); } return `${groups.join("")}${download}`; } /** 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 numbered = numberedLines(page); const keys = pageKeys( ...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []), ...(numbered ? [{ keys: "u", label: "Toggle line wrapping" }] : []), ...(numbered ? [{ keys: "y", label: "Copy a reference to the selected lines" }] : []), ); 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, base: page.base, }); 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({ base: page.base, title: tabTitle("browse", page.rel), rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel, page.base), 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 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 { base: string; 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" ? `${page.base}/diff` : `${page.base}/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, page.base)).join("\n")}
    StatusPathChanges
    `; const stat = page.stat ? `
    ${escapeHtml(page.stat)}
    ` : ""; return renderLayout({ base: page.base, title: tabTitle("browse", "changes"), rootLabel: page.rootLabel, tree: page.tree, currentRel: "", breadcrumbs: renderBreadcrumbs("", page.base), 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, base: string): 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 = `${base}/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`; return `${escapeHtml(letter)}${iconMarkup(languageOf(file.rel))}${escapeHtml(file.rel)}${orig}${escapeHtml(label)}`; } export interface FileDiffPage { base: string; 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 diffBase = `${page.base}/diff/${encodePath(page.rel)}`; const actions = [ renderModeControl(page.mode, (mode) => (mode === "head" ? diffBase : `${diffBase}?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({ base: page.base, title: tabTitle("browse", page.rel), rootLabel: page.rootLabel, tree: page.tree, currentRel: page.rel, breadcrumbs: renderBreadcrumbs(page.rel, page.base), 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 { base: string; 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({ base: page.base, title: tabTitle("browse", String(page.status)), rootLabel: page.rootLabel, tree: page.tree, currentRel: "", breadcrumbs: renderBreadcrumbs("", page.base), main: `
    ${page.status}

    ${escapeHtml(page.message)}

    back to the served folder

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