Something went wrong. Try again.
Terminal markdown pager with syntax highlighting
Something went wrong. Try again.
19 kB · 603 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604import * as fs from "node:fs";import * as path from "node:path";import type { Component } from "@earendil-works/pi-tui";import { pmdMatches } from "./keybindings.js";
const MD_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
// Header lines rendered above the file list.const HEADER_HEIGHT = 2;
// Lines rendered per list item: filename, metadata, blank separator.const ITEM_HEIGHT = 3;
const MINI_HELP = " enter open • j/k move • / filter • ? help • s cycle sort • r reverse sort • q quit";
// Two-column help displayed as a full-screen overlay.const HELP_LINES = [ " j / ↓ move down g / Home go to top", " k / ↑ move up G / End go to bottom", " f / PgDn page down / filter files", " b / PgUp page up Esc clear filter", " Enter open file ? close help", " s cycle sort r reverse sort",];
function relativeTime(mtime: Date): string { const diff = Date.now() - mtime.getTime(); const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const months = Math.floor(days / 30); const years = Math.floor(days / 365);
if (seconds < 60) return "just now"; if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`; if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`; return `${years} year${years === 1 ? "" : "s"} ago`;}
function formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`;}
function hasValidCreationTime(createdAt: Date, updatedAt: Date): boolean { return createdAt.getTime() > 0 && createdAt.getTime() <= updatedAt.getTime();}
export interface Entry { absolutePath: string; relativePath: string; createdAt: Date; updatedAt: Date;}
/** * Recursively scan baseDir for markdown files up to maxDepth levels. * Depth 1 means only files directly in baseDir. Skips hidden entries, * .git, and uses realpath-based cycle detection for symlinked directories. * Returns entries sorted by relative path. */export function scanDirectory(baseDir: string, maxDepth: number): Entry[] { const entries: Entry[] = []; // Track real paths of visited directories to prevent symlink cycles. const visitedRealPaths = new Set<string>();
function recurse(dir: string, currentDepth: number): void { if (currentDepth > maxDepth) return;
// Resolve the real path to detect cycles introduced by symlinks. let realDir: string; try { realDir = fs.realpathSync(dir); } catch { return; } if (visitedRealPaths.has(realDir)) return; visitedRealPaths.add(realDir);
let items: fs.Dirent[]; try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const item of items) { // Skip hidden files/dirs (including .git) if (item.name.startsWith(".")) continue;
const fullPath = path.join(dir, item.name);
// For symlinks, resolve via stat to get the target type. // Directories are followed (cycle guard above handles loops). // Files are included if they have a markdown extension. if (item.isSymbolicLink()) { let stat: fs.Stats; try { stat = fs.statSync(fullPath); } catch { continue; // Broken symlink — skip. } if (stat.isDirectory()) { recurse(fullPath, currentDepth + 1); } else if (stat.isFile()) { const ext = path.extname(item.name).toLowerCase(); if (MD_EXTENSIONS.has(ext)) { entries.push({ absolutePath: fullPath, relativePath: path.relative(baseDir, fullPath), createdAt: stat.birthtime, updatedAt: stat.mtime, }); } } continue; }
if (item.isDirectory()) { recurse(fullPath, currentDepth + 1); } else if (item.isFile()) { const ext = path.extname(item.name).toLowerCase(); if (MD_EXTENSIONS.has(ext)) { let createdAt = new Date(0); let updatedAt = new Date(0); try { const stat = fs.statSync(fullPath); createdAt = stat.birthtime; updatedAt = stat.mtime; } catch { // Leave timestamps as epoch on error. } entries.push({ absolutePath: fullPath, relativePath: path.relative(baseDir, fullPath), createdAt, updatedAt, }); } } } }
recurse(baseDir, 0); entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); return entries;}
export interface BrowserColors { bgColor?: (text: string) => string; fgColor?: (text: string) => string; // Color used for the selected item's gutter marker and filename. accentColor?: (text: string) => string; // Color used for timestamps and mini help text. dimColor?: (text: string) => string; helpBgColor?: (text: string) => string; helpFgColor?: (text: string) => string; filterBgColor?: (text: string) => string; filterFgColor?: (text: string) => string;}
export interface BrowserOptions extends BrowserColors { entries: Entry[]; baseDir: string; onOpen: (entry: Entry) => void; onQuit: () => void;}
export class Browser implements Component { private entries: Entry[]; private baseDir: string; private filtered: Entry[]; private cursor = 0; private scrollOffset = 0; // In items, not lines. private viewportHeight = 0; private filterQuery = ""; private filterMode = false; private showingHelp = false; private sortKey: "path" | "created" | "updated" = "path"; private sortDirection: "asc" | "desc" = "asc"; private onOpen: (entry: Entry) => void; private onQuit: () => void; private bgColor: (text: string) => string; private fgColor: (text: string) => string; private accentColor: (text: string) => string; private dimColor: (text: string) => string; private helpBgColor: (text: string) => string; private helpFgColor: (text: string) => string; private filterBgColor: (text: string) => string; private filterFgColor: (text: string) => string;
constructor(options: BrowserOptions) { this.entries = options.entries; this.baseDir = options.baseDir; this.filtered = [...options.entries]; this.onOpen = options.onOpen; this.onQuit = options.onQuit; this.bgColor = options.bgColor ?? ((t) => t); this.fgColor = options.fgColor ?? ((t) => t); this.accentColor = options.accentColor ?? ((t) => t); this.dimColor = options.dimColor ?? ((t) => t); this.helpBgColor = options.helpBgColor ?? ((t) => t); this.helpFgColor = options.helpFgColor ?? ((t) => t); this.filterBgColor = options.filterBgColor ?? ((t) => t); this.filterFgColor = options.filterFgColor ?? ((t) => t); }
invalidate(): void {}
setViewportHeight(height: number): void { this.viewportHeight = height; }
updateColors(colors: BrowserColors): void { if (colors.bgColor !== undefined) this.bgColor = colors.bgColor; if (colors.fgColor !== undefined) this.fgColor = colors.fgColor; if (colors.accentColor !== undefined) this.accentColor = colors.accentColor; if (colors.dimColor !== undefined) this.dimColor = colors.dimColor; if (colors.helpBgColor !== undefined) this.helpBgColor = colors.helpBgColor; if (colors.helpFgColor !== undefined) this.helpFgColor = colors.helpFgColor; if (colors.filterBgColor !== undefined) this.filterBgColor = colors.filterBgColor; if (colors.filterFgColor !== undefined) this.filterFgColor = colors.filterFgColor; }
// Lines available for list content. Top header and bottom help/filter lines // are reserved. When help overlay is shown it takes the full viewport. private getListHeight(): number { if (this.showingHelp) return 0; return Math.max(0, this.viewportHeight - HEADER_HEIGHT - 1); }
// How many 3-line items fit in the current list area. private getVisibleItemCount(): number { return Math.floor(this.getListHeight() / ITEM_HEIGHT); }
private applyFilter(): void { if (this.filterQuery.length === 0) { this.filtered = [...this.entries]; } else { const q = this.filterQuery.toLowerCase(); this.filtered = this.entries.filter((e) => e.relativePath.toLowerCase().includes(q), ); } this.cursor = 0; this.scrollOffset = 0; }
private applySort(): void { const comparator = (a: Entry, b: Entry): number => { let comparison = 0; switch (this.sortKey) { case "path": comparison = a.relativePath.localeCompare(b.relativePath); break; case "created": comparison = a.createdAt.getTime() - b.createdAt.getTime(); break; case "updated": comparison = a.updatedAt.getTime() - b.updatedAt.getTime(); break; } return this.sortDirection === "asc" ? comparison : -comparison; }; this.entries.sort(comparator); this.applyFilter(); }
private ensureCursorVisible(): void { const visible = this.getVisibleItemCount(); if (visible <= 0) return; if (this.cursor < this.scrollOffset) { this.scrollOffset = this.cursor; } else if (this.cursor >= this.scrollOffset + visible) { this.scrollOffset = this.cursor - visible + 1; } const maxScroll = Math.max(0, this.filtered.length - visible); this.scrollOffset = Math.min(Math.max(0, this.scrollOffset), maxScroll); }
// Render a single 3-line item: gutter+bullet+name, timestamp, blank. private renderItem( entry: Entry, isSelected: boolean, width: number, ): string[] { const gutter = isSelected ? "│ " : " "; const bullet = "\u2022 "; // • const prefix = gutter + bullet; // 4 chars
// Line 1: filename, truncated if needed. const nameAvail = Math.max(0, width - prefix.length); let name = entry.relativePath; if (name.length > nameAvail) { name = `${name.slice(0, nameAvail - 1)}\u2026`; } const nameLine = prefix + name + " ".repeat(Math.max(0, nameAvail - name.length));
// Line 2: created + updated metadata, indented to align with filename. const timeIndent = " "; // same width as prefix const created = hasValidCreationTime(entry.createdAt, entry.updatedAt) ? formatDate(entry.createdAt) : "unknown"; const updated = `${formatDate(entry.updatedAt)} (${relativeTime(entry.updatedAt)})`; const meta = `${timeIndent}c ${created} • u ${updated}`; const timeLine = meta.length < width ? meta + " ".repeat(width - meta.length) : `${meta.slice(0, Math.max(0, width - 1))}…`;
// Line 3: blank separator. const blankLine = " ".repeat(width);
const bg = this.bgColor; if (isSelected) { return [ bg(this.accentColor(nameLine)), bg(this.dimColor(timeLine)), bg(blankLine), ]; } return [ bg(this.fgColor(nameLine)), bg(this.dimColor(timeLine)), bg(blankLine), ]; }
private renderHeader(width: number): string[] { const total = this.filtered.length; const selected = total > 0 ? this.cursor + 1 : 0;
const home = process.env.HOME ?? ""; const displayBase = home && this.baseDir.startsWith(home) ? `~${this.baseDir.slice(home.length)}` : this.baseDir;
const filterLabel = this.filterQuery.length > 0 ? ` • /${this.filterQuery}` : ""; const sortLabel = this.getSortLabel(); const left = ` ${displayBase}${filterLabel}${sortLabel}`; const right = ` ${selected}/${total} docs `;
const available = Math.max(0, width - right.length); const leftTruncated = left.length > available ? `${left.slice(0, Math.max(0, available - 1))}…` : left; const padding = Math.max(0, width - leftTruncated.length - right.length); const firstLine = leftTruncated + " ".repeat(padding) + right;
const divider = "─".repeat(Math.max(0, width));
return [ this.bgColor(this.fgColor(firstLine)), this.bgColor(this.dimColor(divider)), ]; }
private getSortLabel(): string { let keyLabel = ""; switch (this.sortKey) { case "path": keyLabel = "name"; break; case "created": keyLabel = "created"; break; case "updated": keyLabel = "updated"; break; } const arrow = this.sortDirection === "asc" ? "▲" : "▼"; return ` • ${keyLabel}${arrow}`; }
private renderMiniHelp(width: number): string { const text = MINI_HELP; const padded = text.length < width ? text + " ".repeat(width - text.length) : text.slice(0, width); return this.bgColor(this.dimColor(padded)); }
private renderFilterInput(width: number): string { const content = `/${this.filterQuery}\u2588`; // █ cursor const padding = " ".repeat(Math.max(0, width - content.length)); return this.filterBgColor(this.filterFgColor(content + padding)); }
private renderHelpOverlay(width: number): string[] { const lines: string[] = []; const bg = this.helpBgColor; const fg = this.helpFgColor; const emptyLine = bg(" ".repeat(width));
// Two blank lines at the top as padding. lines.push(emptyLine); lines.push(emptyLine);
for (const line of HELP_LINES) { const padded = line + " ".repeat(Math.max(0, width - line.length)); lines.push(bg(fg(padded))); }
// Fill remaining viewport. while (lines.length < this.viewportHeight) { lines.push(emptyLine); }
return lines; }
render(width: number): string[] { if (this.showingHelp) { return this.renderHelpOverlay(width); }
const listHeight = this.getListHeight(); const visibleCount = this.getVisibleItemCount(); const lines: string[] = []; const emptyLine = this.bgColor(" ".repeat(width));
lines.push(...this.renderHeader(width));
if (this.filtered.length === 0) { const msg = this.filterQuery.length > 0 ? "No files match your filter." : "No markdown files found."; for (let i = 0; i < listHeight; i++) { if (i === Math.floor(listHeight / 2)) { const padded = ` ${msg}${" ".repeat(Math.max(0, width - msg.length - 2))}`; lines.push(this.bgColor(this.dimColor(padded))); } else { lines.push(emptyLine); } } } else { this.ensureCursorVisible(); const start = this.scrollOffset; const end = Math.min(start + visibleCount, this.filtered.length);
for (let i = start; i < end; i++) { const entry = this.filtered[i]; if (!entry) continue; lines.push(...this.renderItem(entry, i === this.cursor, width)); }
// Pad remaining list area. while (lines.length < HEADER_HEIGHT + listHeight) { lines.push(emptyLine); } }
// Bottom line: filter input or mini help. if (this.filterMode) { lines.push(this.renderFilterInput(width)); } else { lines.push(this.renderMiniHelp(width)); }
return lines; }
handleInput(data: string): void { if (this.filterMode) { this.handleFilterInput(data); return; }
// Toggle help overlay. if (pmdMatches(data, "pmd.common.toggleHelp")) { this.showingHelp = !this.showingHelp; return; }
// Any key closes the help overlay. if (this.showingHelp) { this.showingHelp = false; return; }
// Quit if (pmdMatches(data, "pmd.common.quit")) { this.onQuit(); return; }
// Enter filter mode. if (pmdMatches(data, "pmd.browser.filter")) { this.filterMode = true; this.filterQuery = ""; this.applyFilter(); return; }
// Cycle sort key if (pmdMatches(data, "pmd.browser.cycleSort")) { if (!this.filterMode && !this.showingHelp) { if (this.sortKey === "path") { this.sortKey = "created"; } else if (this.sortKey === "created") { this.sortKey = "updated"; } else { this.sortKey = "path"; } this.applySort(); } return; }
// Toggle sort direction if (pmdMatches(data, "pmd.browser.reverseSort")) { if (!this.filterMode && !this.showingHelp) { this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; this.applySort(); } return; }
// Open selected entry. if (pmdMatches(data, "pmd.browser.open")) { if (this.filtered.length > 0) { const entry = this.filtered[this.cursor]; if (entry) this.onOpen(entry); } return; }
const pageSize = Math.max(1, this.getVisibleItemCount() - 1); const max = Math.max(0, this.filtered.length - 1);
if (pmdMatches(data, "pmd.common.up")) { this.cursor = Math.max(0, this.cursor - 1); return; }
if (pmdMatches(data, "pmd.common.down")) { this.cursor = Math.min(max, this.cursor + 1); return; }
if (pmdMatches(data, "pmd.common.top")) { this.cursor = 0; this.scrollOffset = 0; return; }
if (pmdMatches(data, "pmd.common.bottom")) { this.cursor = max; return; }
if (pmdMatches(data, "pmd.common.pageUp")) { this.cursor = Math.max(0, this.cursor - pageSize); return; }
if (pmdMatches(data, "pmd.common.pageDown")) { this.cursor = Math.min(max, this.cursor + pageSize); return; } }
private handleFilterInput(data: string): void { if (pmdMatches(data, "pmd.common.cancel")) { this.filterMode = false; this.filterQuery = ""; this.applyFilter(); return; }
if (pmdMatches(data, "pmd.common.confirm")) { this.filterMode = false; return; }
if (pmdMatches(data, "pmd.common.backspace")) { this.filterQuery = this.filterQuery.slice(0, -1); this.applyFilter(); return; }
if (data.length === 1 && data >= " ") { this.filterQuery += data; this.applyFilter(); return; } }}