/**
* 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 `
${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 `
`;
}
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 = `