;
}
/** Whether a path carries a change marker: a changed file, or a directory above one. */
export 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}
@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 Close
${rows}
`;
}
/** A full two-pane page. */
function renderLayout(layout: Layout): string {
return `
${docHead({ title: layout.title, css: STYLES })}
Skip to content
${SPRITE}
${layout.breadcrumbs}
${layout.main}
${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 `${renderTreeNode(node, currentRel, summary)} `;
}
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 = isDir ? (node.rel ? `/browse/${encodePath(node.rel)}` : "/browse/") : `/view/${encodePath(node.rel)}`;
const current = node.rel === currentRel ? ` aria-current="page"` : "";
const twisty = isDir
? `${expanded ? "▾" : "▸"} `
: ` `;
const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
const children = expanded
? `${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")} `
: "";
const label = node.broken
? `${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;
}
/** 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 = `
Name Size Modified
${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,
});
}
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;
/** 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;
}
/** 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" }, ...extra, { keys: "?", label: "This sheet" }];
}
/** An "on this page" outline from the headings the Markdown renderer emitted. */
function renderOutline(headings: MarkdownHeading[]): string {
if (headings.length < 4) return "";
const base = Math.min(...headings.map((heading) => heading.level));
const items = headings
.map((heading) => {
const depth = Math.min(heading.level - base, 3);
const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
return `${escapeHtml(label)} `;
})
.join("");
return `On this page `;
}
/** 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(
`wrap `,
);
}
if (page.file.kind === "text" || isMarkdown) {
groups.push(
`copy contents `,
);
}
groups.push(
`copy path `,
);
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 document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
content = `${renderOutline(document.headings)}${document.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 = ``;
} 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: escapeHtml(page.rel),
git: page.git,
summary: page.summary,
});
}
/** 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 = `${line.oldNo ?? ""} ${line.newNo ?? ""} `;
const marker = `${sign} `;
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;
}
/** 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.
`
: `
Status Path Changes
${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
`;
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,
});
}
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;
}
/** 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 `,
`wrap `,
`download `,
`copy path `,
];
// 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 · ${escapeHtml(modeScope(page.mode))}`,
git: page.git,
summary: page.summary,
});
}
export interface ErrorPage {
rootLabel: string;
status: number;
message: string;
tree: TreeNode;
rel: string;
git?: GitStatus;
}
/** 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: ` `,
footerNote: "error",
git: page.git,
});
}