# HG changeset patch # User MrJuneJune # Date 1769320311 28800 # Node ID a06710325c30f21a1391ade9d3e6c36b9f32e362 # Parent a2725419f988b129c23fe8ac8eade8ee7811ef0f [HgWeb] Fully working copy. diff -r a2725419f988 -r a06710325c30 hg-web/BUILD --- a/hg-web/BUILD Sat Jan 24 21:06:42 2026 -0800 +++ b/hg-web/BUILD Sat Jan 24 21:51:51 2026 -0800 @@ -19,6 +19,7 @@ deps = [ ":src_ts_files", "//markdown_converter:markdown_to_html_wasm", + "//third_party/highlight:js", ], visibility = ["//visibility:public"], ) diff -r a2725419f988 -r a06710325c30 hg-web/src/index.html --- a/hg-web/src/index.html Sat Jan 24 21:06:42 2026 -0800 +++ b/hg-web/src/index.html Sat Jan 24 21:51:51 2026 -0800 @@ -6,7 +6,6 @@ Zenbu Repository - @@ -14,5 +13,6 @@
+ diff -r a2725419f988 -r a06710325c30 hg-web/src/repo-browser.tsx --- a/hg-web/src/repo-browser.tsx Sat Jan 24 21:06:42 2026 -0800 +++ b/hg-web/src/repo-browser.tsx Sat Jan 24 21:51:51 2026 -0800 @@ -1,32 +1,121 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; +import hljs from 'third_party/highlight/highlight.min.js'; -// --- ICONS (Using CDN Links) --- +// --- ICONS (served as static files) --- const ICONS = { - folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", - file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", - home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", + folder: "/icons/folder.png", + file: "/icons/file.svg", + home: "/icons/home.png", repo: "/public/epi_all_colors.svg", - clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" + close: "/icons/close.png" }; +// SVG Icons for theme toggle +const SunIcon = ({ color = "currentColor" }: { color?: string }) => ( + + + + + + + + + + + +); + +const MoonIcon = ({ color = "currentColor" }: { color?: string }) => ( + + + +); + const API_BASE = '/api/repo'; +// File extensions that should be displayed as code +const CODE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', + 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', + 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', + 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', + 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', + 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' +]); + +// Prefetch cache +const prefetchCache = new Map>(); + +function isCodeFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + return CODE_EXTENSIONS.has(ext) || + CODE_EXTENSIONS.has(basename) || + basename === 'dockerfile' || + basename === 'makefile' || + basename.startsWith('.'); +} + +function isMarkdownFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + return ext === 'md' || ext === 'markdown'; +} + +function prefetchDirectory(path: string): void { + const cacheKey = `dir:${path}`; + if (prefetchCache.has(cacheKey)) return; + + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + + prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); +} + +function prefetchFile(path: string): void { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) return; + + prefetchCache.set(cacheKey, + fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) + .then(r => r.ok ? r.text() : null) + .catch(() => null) + ); +} + +async function getCachedFile(path: string): Promise { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) { + return prefetchCache.get(cacheKey); + } + prefetchFile(path); + return prefetchCache.get(cacheKey)!; +} + /** * Component: Styles - * Injected CSS for the polished look + * Injected CSS for the polished look with dark/light mode support */ -const GlobalStyles = () => ( +const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( ); @@ -193,9 +406,174 @@ } /** + * Component: FileViewer + * Shows file content inline with syntax highlighting + */ +function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const codeRef = useRef(null); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (content && codeRef.current) { + hljs.highlightElement(codeRef.current); + } + }, [content]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Get language from file extension for highlight.js + const getLanguage = () => { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const langMap: Record = { + js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', + json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', + html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', + sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', + dockerfile: 'dockerfile', makefile: 'makefile' + }; + return langMap[ext] || 'plaintext'; + }; + + const addLineNumbers = (text: string) => { + const lines = text.split('\n'); + return lines.map((_, i) => i + 1).join('\n'); + }; + + return ( +
+
e.stopPropagation()}> +
+ + + {filename} + + +
+
+ {loading ? ( +
Loading...
+ ) : content ? ( +
+              {addLineNumbers(content)}
+              {content}
+            
+ ) : ( +
Unable to load file
+ )} +
+
+
+ ); +} + +/** + * Component: MarkdownViewerModal + * Shows markdown content rendered in a modal + */ +function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const contentRef = useRef(null); + const moduleRef = useRef(null); + const [wasmReady, setWasmReady] = useState(false); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; + + const Module = moduleRef.current; + const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); + const markdownFree = Module.cwrap('markdown_free', null, ['number']); + + const ptr = markdownToHtmlPtr(content); + const html = Module.UTF8ToString(ptr); + markdownFree(ptr); + contentRef.current.innerHTML = html; + }, [content, wasmReady]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()}> +
+ + + {filename} + + +
+
+ {loading || !wasmReady ? ( +
Loading...
+ ) : content ? ( +
+ ) : ( +
Unable to load file
+ )} +
+
+
+ ); +} + +/** * Component: FileList */ -function FileList({ directories, files, onNavigate }) { +function FileList({ directories, files, onNavigate, onOpenFile }: { + directories: any[]; + files: any[]; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { const isEmpty = directories.length === 0 && files.length === 0; if (isEmpty) { @@ -215,21 +593,24 @@
{directories.map((dir) => ( - ))} {files.map((file) => ( - ))}
@@ -240,27 +621,45 @@ /** * Component: FileRow */ -function FileRow({ item, iconUrl, isDir, onNavigate }) { - const handleClick = (e) => { +function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { + item: { abspath: string; basename: string }; + iconUrl: string; + isDir: boolean; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); if (isDir) { - e.preventDefault(); onNavigate(item.abspath); + } else if (isCodeFile(item.basename)) { + onOpenFile(item.abspath); + } else { + // For non-code files, open in new tab + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); } }; - const href = isDir + const handleMouseEnter = () => { + // Prefetch on hover + if (isDir) { + prefetchDirectory(item.abspath); + } else if (isCodeFile(item.basename)) { + prefetchFile(item.abspath); + } + }; + + const href = isDir ? `?path=${encodeURIComponent(item.abspath)}` : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; - - const target = isDir ? undefined : "_blank"; return ( -
+
{isDir - + {item.basename} @@ -277,7 +676,7 @@ const [wasmReady, setWasmReady] = useState(false); useEffect(() => { - createMarkdownModule().then((Module) => { + createMarkdownModule().then((Module: any) => { moduleRef.current = Module; setWasmReady(true); }); @@ -301,7 +700,7 @@ return (
- + README.md
@@ -316,16 +715,28 @@ */ function RepoBrowser() { const [currentPath, setCurrentPath] = useState(getCurrentPath()); - const [content, setContent] = useState({ files: [], directories: [] }); - const [readme, setReadme] = useState(null); - const [error, setError] = useState(null); + const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); + const [readme, setReadme] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [isDarkMode, setIsDarkMode] = useState(() => { + // Check localStorage or system preference + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + const [viewingFile, setViewingFile] = useState(null); function getCurrentPath() { const params = new URLSearchParams(window.location.search); return params.get('path') || ''; } + // Persist theme preference + useEffect(() => { + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + }, [isDarkMode]); + useEffect(() => { const handlePopState = () => setCurrentPath(getCurrentPath()); window.addEventListener('popstate', handlePopState); @@ -337,33 +748,41 @@ fetchReadme(currentPath); }, [currentPath]); - const navigate = (path) => { + const navigate = (path: string) => { const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; window.history.pushState({ path }, '', newUrl); setCurrentPath(path); }; - const fetchDirectory = async (path) => { + const fetchDirectory = async (path: string) => { setLoading(true); setError(null); try { - const url = path - ? `${API_BASE}/list?path=${encodeURIComponent(path)}` - : `${API_BASE}/list`; - - const response = await fetch(url); + // Check prefetch cache first + const cacheKey = `dir:${path}`; let data; - if (response.ok) - data = await response.json(); + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); // Clear after use for fresh data next time + } else { + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + const response = await fetch(url); + if (response.ok) { + data = await response.json(); + } + } - if (data.error) + if (data?.error) { throw new Error(data.error); - + } + setContent({ - files: data.files || [], - directories: data.directories || [] + files: data?.files || [], + directories: data?.directories || [] }); - } catch (err) { + } catch (err: any) { console.error('Error loading directory:', err); setError(err.message); } finally { @@ -371,22 +790,37 @@ } }; - const fetchReadme = async (path) => { + const fetchReadme = async (path: string) => { + setReadme(null); const readmePath = path ? `${path}/README.md` : 'README.md'; - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); - console.log(response); - if (response.ok) - { - const text = await response.text(); - setReadme(text); + try { + const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + if (response.ok) { + const text = await response.text(); + setReadme(text); + } + } catch (err) { + // Readme is optional, ignore errors } }; + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + const toggleTheme = () => { + setIsDarkMode(prev => !prev); + }; + return ( <> - +
- + {/* Header */}
Repo @@ -394,6 +828,10 @@

Zenbu Repository

Browse and manage the mercurial codebase

+
{/* Clone Bar */} @@ -406,24 +844,34 @@ {/* Navigation & Content */} - + {error &&
Error: {error}
} - + {loading ? ( -
+
Loading files...
) : ( <> - )}
+ + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + + ) : ( + + ) + )} ); } diff -r a2725419f988 -r a06710325c30 third_party/highlight/highlight.js --- a/third_party/highlight/highlight.js Sat Jan 24 21:06:42 2026 -0800 +++ b/third_party/highlight/highlight.js Sat Jan 24 21:51:51 2026 -0800 @@ -8163,4 +8163,429 @@ })(); hljs.registerLanguage('x86asm', hljsGrammar); - })(); \ No newline at end of file + })(); + (function(){ + var hljsGrammar = (function () { + 'use strict'; + + + /* + Language: Shell Session + Requires: bash.js + Author: TSUYUSATO Kitsune + Category: common + Audit: 2020 + */ +/* +Language: Bash +Author: vah +Contributrors: Benjamin Pannell +Website: https://www.gnu.org/software/bash/ +Category: common, scripting +*/ + +/** @type LanguageFn */ + function bash(hljs) { + const regex = hljs.regex; + const VAR = {}; + const BRACED_VAR = { + begin: /\$\{/, + end: /\}/, + contains: [ + "self", + { + begin: /:-/, + contains: [ VAR ] + } // default values + ] + }; + Object.assign(VAR, { + className: 'variable', + variants: [ + { begin: regex.concat(/\$[\w\d#@][\w\d_]*/, + // negative look-ahead tries to avoid matching patterns that are not + // Perl at all like $ident$, @ident@, etc. + `(?![\\w\\d])(?![$])`) }, + BRACED_VAR + ] + }); + + const SUBST = { + className: 'subst', + begin: /\$\(/, + end: /\)/, + contains: [ hljs.BACKSLASH_ESCAPE ] + }; + const COMMENT = hljs.inherit( + hljs.COMMENT(), + { + match: [ + /(^|\s)/, + /#.*$/ + ], + scope: { + 2: 'comment' + } + } + ); + const HERE_DOC = { + begin: /<<-?\s*(?=\w+)/, + starts: { contains: [ + hljs.END_SAME_AS_BEGIN({ + begin: /(\w+)/, + end: /(\w+)/, + className: 'string' + }) + ] } + }; + const QUOTE_STRING = { + className: 'string', + begin: /"/, + end: /"/, + contains: [ + hljs.BACKSLASH_ESCAPE, + VAR, + SUBST + ] + }; + SUBST.contains.push(QUOTE_STRING); + const ESCAPED_QUOTE = { + match: /\\"/ + }; + const APOS_STRING = { + className: 'string', + begin: /'/, + end: /'/ + }; + const ESCAPED_APOS = { + match: /\\'/ + }; + const ARITHMETIC = { + begin: /\$?\(\(/, + end: /\)\)/, + contains: [ + { + begin: /\d+#[0-9a-f]+/, + className: "number" + }, + hljs.NUMBER_MODE, + VAR + ] + }; + const SH_LIKE_SHELLS = [ + "fish", + "bash", + "zsh", + "sh", + "csh", + "ksh", + "tcsh", + "dash", + "scsh", + ]; + const KNOWN_SHEBANG = hljs.SHEBANG({ + binary: `(${SH_LIKE_SHELLS.join("|")})`, + relevance: 10 + }); + const FUNCTION = { + className: 'function', + begin: /\w[\w\d_]*\s*\(\s*\)\s*\{/, + returnBegin: true, + contains: [ hljs.inherit(hljs.TITLE_MODE, { begin: /\w[\w\d_]*/ }) ], + relevance: 0 + }; + + const KEYWORDS = [ + "if", + "then", + "else", + "elif", + "fi", + "time", + "for", + "while", + "until", + "in", + "do", + "done", + "case", + "esac", + "coproc", + "function", + "select" + ]; + + const LITERALS = [ + "true", + "false" + ]; + + // to consume paths to prevent keyword matches inside them + const PATH_MODE = { match: /(\/[a-z._-]+)+/ }; + + // http://www.gnu.org/software/bash/manual/html_node/Shell-Builtin-Commands.html + const SHELL_BUILT_INS = [ + "break", + "cd", + "continue", + "eval", + "exec", + "exit", + "export", + "getopts", + "hash", + "pwd", + "readonly", + "return", + "shift", + "test", + "times", + "trap", + "umask", + "unset" + ]; + + const BASH_BUILT_INS = [ + "alias", + "bind", + "builtin", + "caller", + "command", + "declare", + "echo", + "enable", + "help", + "let", + "local", + "logout", + "mapfile", + "printf", + "read", + "readarray", + "source", + "sudo", + "type", + "typeset", + "ulimit", + "unalias" + ]; + + const ZSH_BUILT_INS = [ + "autoload", + "bg", + "bindkey", + "bye", + "cap", + "chdir", + "clone", + "comparguments", + "compcall", + "compctl", + "compdescribe", + "compfiles", + "compgroups", + "compquote", + "comptags", + "comptry", + "compvalues", + "dirs", + "disable", + "disown", + "echotc", + "echoti", + "emulate", + "fc", + "fg", + "float", + "functions", + "getcap", + "getln", + "history", + "integer", + "jobs", + "kill", + "limit", + "log", + "noglob", + "popd", + "print", + "pushd", + "pushln", + "rehash", + "sched", + "setcap", + "setopt", + "stat", + "suspend", + "ttyctl", + "unfunction", + "unhash", + "unlimit", + "unsetopt", + "vared", + "wait", + "whence", + "where", + "which", + "zcompile", + "zformat", + "zftp", + "zle", + "zmodload", + "zparseopts", + "zprof", + "zpty", + "zregexparse", + "zsocket", + "zstyle", + "ztcp" + ]; + + const GNU_CORE_UTILS = [ + "chcon", + "chgrp", + "chown", + "chmod", + "cp", + "dd", + "df", + "dir", + "dircolors", + "ln", + "ls", + "mkdir", + "mkfifo", + "mknod", + "mktemp", + "mv", + "realpath", + "rm", + "rmdir", + "shred", + "sync", + "touch", + "truncate", + "vdir", + "b2sum", + "base32", + "base64", + "cat", + "cksum", + "comm", + "csplit", + "cut", + "expand", + "fmt", + "fold", + "head", + "join", + "md5sum", + "nl", + "numfmt", + "od", + "paste", + "ptx", + "pr", + "sha1sum", + "sha224sum", + "sha256sum", + "sha384sum", + "sha512sum", + "shuf", + "sort", + "split", + "sum", + "tac", + "tail", + "tr", + "tsort", + "unexpand", + "uniq", + "wc", + "arch", + "basename", + "chroot", + "date", + "dirname", + "du", + "echo", + "env", + "expr", + "factor", + // "false", // keyword literal already + "groups", + "hostid", + "id", + "link", + "logname", + "nice", + "nohup", + "nproc", + "pathchk", + "pinky", + "printenv", + "printf", + "pwd", + "readlink", + "runcon", + "seq", + "sleep", + "stat", + "stdbuf", + "stty", + "tee", + "test", + "timeout", + // "true", // keyword literal already + "tty", + "uname", + "unlink", + "uptime", + "users", + "who", + "whoami", + "yes" + ]; + + return { + name: 'Bash', + aliases: [ + 'sh', + 'zsh' + ], + keywords: { + $pattern: /\b[a-z][a-z0-9._-]+\b/, + keyword: KEYWORDS, + literal: LITERALS, + built_in: [ + ...SHELL_BUILT_INS, + ...BASH_BUILT_INS, + // Shell modifiers + "set", + "shopt", + ...ZSH_BUILT_INS, + ...GNU_CORE_UTILS + ] + }, + contains: [ + KNOWN_SHEBANG, // to catch known shells and boost relevancy + hljs.SHEBANG(), // to catch unknown shells but still highlight the shebang + FUNCTION, + ARITHMETIC, + COMMENT, + HERE_DOC, + PATH_MODE, + QUOTE_STRING, + ESCAPED_QUOTE, + APOS_STRING, + ESCAPED_APOS, + VAR + ] + }; + } +} + return bash; +})(); + + hljs.registerLanguage('bash', hljsGrammar); + })();