# HG changeset patch # User MrJuneJune # Date 1769400295 28800 # Node ID 9f4429c497334ab7184b92bf3b1028d818f537e7 # Parent b818a4561a3ca236ba94f120070b4baed90387a8 [HgWeb] Making progress.... diff -r b818a4561a3c -r 9f4429c49733 gui_ze/gui_ze.bzl --- a/gui_ze/gui_ze.bzl Sat Jan 24 21:52:14 2026 -0800 +++ b/gui_ze/gui_ze.bzl Sun Jan 25 20:04:55 2026 -0800 @@ -132,7 +132,7 @@ {copy_commands} export NODE_PATH=./third_party/bun/node_modules cp ./third_party/bun/tsconfig.json . -{bun} build {entry} --outfile {output} --target browser +{bun} build {entry} --outfile {output} --target browser """.format( copy_commands = "\n".join(copy_commands), src_package = src_package, diff -r b818a4561a3c -r 9f4429c49733 hg-web/BUILD --- a/hg-web/BUILD Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/BUILD Sun Jan 25 20:04:55 2026 -0800 @@ -41,9 +41,15 @@ dest = "src/public", ) +move_files_into_dir( + name = "public_fonts_files", + srcs = ["//mrjunejune:public_fonts_files"], + dest = "src/public/fonts", +) + filegroup( name = "all_assets", - srcs = glob(["src/**"]) + [":compiled_js", ":public_files"], + srcs = glob(["src/**"]) + [":compiled_js", ":public_files", ":public_fonts_files"], ) # Server binaries diff -r b818a4561a3c -r 9f4429c49733 hg-web/main.c --- a/hg-web/main.c Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/main.c Sun Jan 25 20:04:55 2026 -0800 @@ -127,6 +127,71 @@ return resp; } +Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); + const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path); + void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); + char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id); + char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); + Seobeo_Url_Decode(decoded_path, rel_path); + char *safe_path = sanitize_path(decoded_path, arena); + + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path); + + if (strlen(safe_path) == 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); + return resp; + } + + char hg_path[MAX_PATH]; + // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); + // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; + snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path); + Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + + Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + + char status[4]; + snprintf(status, 4, "%i", hg_response->status_code); + + if (!hg_response->body) + { + Dowa_HashMap_Push_Arena(resp, "status", "502", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); + return resp; + } + + if (hg_response->status_code != 200) + { + Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body); + Dowa_HashMap_Push_Arena(resp, "status", status, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); + return resp; + } + + + char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); + char *temp2 = Dowa_Arena_Allocate(arena, 256); + snprintf(temp2, 256, "%zu", hg_response->body_length); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); + Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); + + return resp; +} + Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) { Seobeo_Request_Entry *resp = NULL; @@ -345,11 +410,30 @@ return resp; } +Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); + + printf("%s", html); + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(resp, "body", html, arena); + return resp; +} + int main(void) { Seobeo_Router_Init(); + + Seobeo_Router_Register("GET", "/", GetReactHome); + Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); + Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph); Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); // Use streaming handler for hg wire protocol... diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/base.css --- a/hg-web/src/base.css Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/src/base.css Sun Jan 25 20:04:55 2026 -0800 @@ -1,123 +1,169 @@ -/* --- Colors ---*/ +/* Reset CSS: https://meyerweb.com/eric/tools/css/reset/ */ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* =========================================== + Base CSS - Color Variables and Basic Setup + =========================================== */ + +/* Light mode (default) */ :root { --bg: #ffffff; - --fg: #1a1a1a; - --border: #e0e0e0; - --hover: #f5f5f5; - --accent: #0066cc; - --accent-hover: #0052a3; - --secondary: #6c757d; - --success: #28a745; - --warning: #ffc107; - --danger: #dc3545; - --code-bg: #f6f8fa; - --link: #0066cc; - --link-hover: #0052a3; + --bg-subtle: #f6f8fa; + --bg-code: #f6f8fa; + --border: #d0d7de; + --accent: #0969da; + --text-primary: #1f2328; + --text-secondary: #656d76; + --hover: #f3f4f6; + --success: #1a7f37; + --danger: #cf222e; + --danger-bg: #ffebe9; + --danger-border: #ffdce0; + --overlay: rgba(0, 0, 0, 0.5); + + /* Graph colors - light mode */ + --graph-1: #495057; + --graph-2: #1971c2; + --graph-3: #099268; + --graph-4: #e67700; + --graph-5: #7048e8; + --graph-6: #c92a2a; + --graph-7: #c2255c; + --graph-node-border: #ffffff; } -.dark { +/* Dark mode - applied when html has .dark class */ +:root.dark { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + /* Graph colors - dark mode */ + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } +/* System preference fallback (when no explicit class is set) */ @media (prefers-color-scheme: dark) { - :root:not(.light-mode) { + :root:not(.light) { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } } -/* --- Reset and Base Styles --- */ +/* Fonts */ +@font-face { + font-family: "Roboto"; + src: url("/public/fonts/Roboto-Regular.ttf"); +} +@font-face { + font-family: "Roboto Light"; + src: url("/public/fonts/Roboto-Thin.ttf"); +} +@font-face { + font-family: "More Thin"; + src: url("/public/fonts/more-sugar.thin.otf"); +} +@font-face { + font-family: "More"; + src: url("/public/fonts/more-sugar.regular.otf"); +} + +button { + font-family: "More Thin", sans-serif; +} + +/* Reset and Base */ * { margin: 0; padding: 0; box-sizing: border-box; } -html { - background: var(--bg); - color: var(--fg); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; - line-height: 1.6; +html, body { background: var(--bg); - color: var(--fg); - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -main { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; + color: var(--text-primary); + font-family: "More Thin", sans-serif; + line-height: 1.6; + transition: background 0.2s, color 0.2s; } a { - color: var(--link); + color: var(--accent); text-decoration: none; } a:hover { - color: var(--link-hover); text-decoration: underline; } -h1, h2, h3, h4, h5, h6 { - margin-bottom: 1rem; - font-weight: 600; - line-height: 1.25; -} - -h1 { font-size: 2rem; } -h2 { font-size: 1.75rem; } -h3 { font-size: 1.5rem; } -h4 { font-size: 1.25rem; } -h5 { font-size: 1.1rem; } -h6 { font-size: 1rem; } - -p { - margin-bottom: 1rem; -} - code { - background: var(--code-bg); + background: var(--bg-code); padding: 0.2em 0.4em; border-radius: 3px; - font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9em; } pre { - background: var(--code-bg); + background: var(--bg-code); padding: 1rem; border-radius: 6px; overflow-x: auto; - margin-bottom: 1rem; } pre code { @@ -125,17 +171,18 @@ padding: 0; } -/* Mobile responsive */ -@media (max-width: 768px) { - body { - font-size: 14px; - } +/* Icon invert for dark mode */ +:root.dark .icon-invert { + filter: invert(0.8); +} - main { - padding: 1rem; - } +:root.light .icon-invert, +:root:not(.dark):not(.light) .icon-invert { + filter: none; +} - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } +@media (prefers-color-scheme: dark) { + :root:not(.light) .icon-invert { + filter: invert(0.8); + } } diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/build.ts --- a/hg-web/src/build.ts Sat Jan 24 21:52:14 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,24 +0,0 @@ -import { readdir } from "node:fs/promises"; -const files = await readdir(import.meta.dir); -console.log(files); - - -const outputPath = Bun.argv[2]; - -if (!outputPath) { - console.error("Please provide an output path. Usage: bun build.ts "); - process.exit(1); -} - -const build = await Bun.build({ - entrypoints: ["./hg-web/src/main.tsx"], - outdir: outputPath, - metafile: true, -}); - -if (build.success) { - console.log(`Build successful! Files saved to: ${outputPath}`); - console.log(JSON.stringify(build.metafile, null, 2)); -} else { - console.error("Build failed:", build.logs); -} diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/app.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/app.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,389 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Graph, useGraphData } from "hg-web/src/components/graph"; +import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +type Page = 'landing' | 'graph' | 'directory'; + +type RouteState = { + page: Page; + graphCommit?: string; + graphTip?: string; + dirPath?: string; +} + +// Icons +const ICONS = { + folder: "/icons/folder.png", +}; + +const GraphIcon = () => ( + + + + + + + +); + +const FolderIcon = () => ( + + + +); + +const API_BASE = '/api/repo'; + +function parseRoute(): RouteState { + const params = new URLSearchParams(window.location.search); + const pathname = window.location.pathname; + + if (pathname.startsWith('/graph') || params.has('graph')) { + return { + page: 'graph', + graphCommit: params.get('commit') || undefined, + graphTip: params.get('tip') || undefined, + }; + } + + if (pathname.startsWith('/directory') || params.has('path')) { + return { + page: 'directory', + dirPath: params.get('path') || '', + }; + } + + return { page: 'landing' }; +} + +function buildUrl(state: RouteState): string { + const params = new URLSearchParams(); + + switch (state.page) { + case 'graph': + if (state.graphCommit) params.set('commit', state.graphCommit); + if (state.graphTip) params.set('tip', state.graphTip); + return `/graph${params.toString() ? '?' + params.toString() : ''}`; + case 'directory': + if (state.dirPath) params.set('path', state.dirPath); + return `/directory${params.toString() ? '?' + params.toString() : ''}`; + default: + return '/'; + } +} + +// Landing Page Component +function LandingPage({ + onNavigateToGraph, + onNavigateToDirectory, +}: { + onNavigateToGraph: () => void; + onNavigateToDirectory: (path?: string) => void; +}) { + const [directories, setDirectories] = useState([]); + const [files, setFiles] = useState([]); + const [dirLoading, setDirLoading] = useState(true); + + const { data: graphData, loading: graphLoading } = useGraphData(); + + useEffect(() => { + fetch(`${API_BASE}/list`) + .then(r => r.json()) + .then(data => { + setDirectories(data.directories || []); + setFiles(data.files || []); + setDirLoading(false); + }) + .catch(() => setDirLoading(false)); + }, []); + + const previewItems = [ + ...directories.slice(0, 6), + ...files.slice(0, Math.max(0, 6 - directories.length)) + ].slice(0, 6); + + return ( +
+ {/* Graph Preview */} +
+ +
+ {graphLoading ? ( +
Loading commits...
+ ) : graphData ? ( + { + console.log('Clicked commit:', node); + }} + /> + ) : ( +
Failed to load commits
+ )} +
+
+ + {/* Directory Preview */} +
+ +
+ {dirLoading ? ( +
Loading files...
+ ) : previewItems.length > 0 ? ( + previewItems.map((item) => ( +
onNavigateToDirectory(item.abspath)} + > + + + + {item.basename} +
+ )) + ) : ( +
No files found
+ )} +
+
+
+ ); +} + +// Graph Page Component +function GraphPage({ + onBack, + initialCommit, + initialTip, +}: { + onBack: () => void; + initialCommit?: string; + initialTip?: string; +}) { + const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ + initialCommit: initialCommit || null, + graphTop: initialTip || null, + }); + + useEffect(() => { + if (tip && currentCommit) { + const params = new URLSearchParams(); + params.set('commit', currentCommit); + params.set('tip', tip); + const newUrl = `/graph?${params.toString()}`; + window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); + } + }, [currentCommit, tip]); + + return ( +
+
+ + Commit Graph +
+ + {tip && ( +
+ + Tip: + {tip.substring(0, 12)} + + {currentCommit && currentCommit !== tip && ( + + Current: + {currentCommit.substring(0, 12)} + + )} +
+ )} + + {error && ( +
Error: {error}
+ )} + + { + console.log('Clicked commit:', node); + }} + /> +
+ ); +} + +// Directory Page Component +function DirectoryPage({ + onBack, + initialPath, + onPathChange, +}: { + onBack: () => void; + initialPath?: string; + onPathChange: (path: string) => void; +}) { + return ( +
+
+ + Repository Files +
+ + +
+ ); +} + +// Main App Content (uses theme context) +function AppContent() { + const [route, setRoute] = useState(parseRoute); + const { isDark, toggleTheme } = useTheme(); + + // Handle browser back/forward + useEffect(() => { + const handlePopState = () => { + setRoute(parseRoute()); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const navigate = useCallback((newRoute: RouteState) => { + const url = buildUrl(newRoute); + window.history.pushState(newRoute, '', url); + setRoute(newRoute); + }, []); + + const navigateToLanding = useCallback(() => { + navigate({ page: 'landing' }); + }, [navigate]); + + const navigateToGraph = useCallback((commit?: string, tip?: string) => { + navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); + }, [navigate]); + + const navigateToDirectory = useCallback((path?: string) => { + navigate({ page: 'directory', dirPath: path || '' }); + }, [navigate]); + + const handleDirectoryPathChange = useCallback((path: string) => { + // Update URL without full navigation + const params = new URLSearchParams(); + if (path) params.set('path', path); + const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; + window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); + setRoute(prev => ({ ...prev, dirPath: path })); + }, []); + + return ( +
+
+ + {/* Navigation Tabs */} +
+ + + +
+ + {/* Page Content */} + {route.page === 'landing' && ( + navigateToGraph()} + onNavigateToDirectory={navigateToDirectory} + /> + )} + + {route.page === 'graph' && ( + + )} + + {route.page === 'directory' && ( + + )} + +
+
+ ); +} + +// App wrapper with ThemeProvider +function App() { + return ( + + + + ); +} + +export { App }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/directory-browser.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/directory-browser.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,539 @@ +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 (served as static files) --- +const ICONS = { + folder: "/icons/folder.png", + file: "/icons/file.svg", + close: "/icons/close.png" +}; + +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: Breadcrumb + */ +function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { + if (!currentPath) { + return ( + + ); + } + + const parts = currentPath.split('/').filter(p => p); + const crumbs = parts.map((part, index) => ({ + name: part, + fullPath: parts.slice(0, index + 1).join('/') + })); + + return ( + + ); +} + +/** + * 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]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + 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 + */ +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]); + + 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, onOpenFile }: { + directories: any[]; + files: any[]; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const isEmpty = directories.length === 0 && files.length === 0; + + if (isEmpty) { + return ( +
+
This directory is empty.
+
+ ); + } + + return ( +
+
Files
+
+ {directories.map((dir) => ( + + ))} + {files.map((file) => ( + + ))} +
+
+ ); +} + +/** + * Component: FileRow + */ +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) { + onNavigate(item.abspath); + } else if (isCodeFile(item.basename)) { + onOpenFile(item.abspath); + } else { + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); + } + }; + + const handleMouseEnter = () => { + if (isDir) { + prefetchDirectory(item.abspath); + } else if (isCodeFile(item.basename)) { + prefetchFile(item.abspath); + } + }; + + const href = isDir + ? `#` + : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; + + return ( +
+ + {isDir + + + + {item.basename} + + +
+ ); +} + +/** + * Component: ReadmeViewer + */ +function ReadmeViewer({ content }: { content: string | null }) { + const contentRef = useRef(null); + const moduleRef = useRef(null); + const [wasmReady, setWasmReady] = useState(false); + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + 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]); + + if (!content) return null; + + return ( +
+
+ + README.md +
+
+ {!wasmReady && 'Loading...'} +
+
+ ); +} + +/** + * Directory Browser Component (no header/footer - for embedding in app) + */ +interface DirectoryBrowserProps { + initialPath?: string; + onPathChange?: (path: string) => void; +} + +function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + 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 [viewingFile, setViewingFile] = useState(null); + + // Sync with initialPath prop + useEffect(() => { + setCurrentPath(initialPath); + }, [initialPath]); + + useEffect(() => { + fetchDirectory(currentPath); + fetchReadme(currentPath); + }, [currentPath]); + + const navigate = useCallback((path: string) => { + setCurrentPath(path); + onPathChange?.(path); + }, [onPathChange]); + + const fetchDirectory = async (path: string) => { + setLoading(true); + setError(null); + try { + const cacheKey = `dir:${path}`; + let data; + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); + } 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) { + throw new Error(data.error); + } + + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } catch (err: any) { + console.error('Error loading directory:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const fetchReadme = async (path: string) => { + setReadme(null); + const readmePath = path ? `${path}/README.md` : 'README.md'; + 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 + } + }; + + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + return ( + <> + + + {error &&
Error: {error}
} + + {loading ? ( +
+
Loading files...
+
+ ) : ( + <> + + + + )} + + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + + ) : ( + + ) + )} + + ); +} + +export { DirectoryBrowser }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/footer.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/footer.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,31 @@ +import React from 'react'; + +interface FooterProps { + showCloneUrl?: boolean; + cloneUrl?: string; +} + +function Footer({ + showCloneUrl = false, + cloneUrl = "hg clone http://zenbu.babocoder.com/repo", +}: FooterProps) { + const currentYear = new Date().getFullYear(); + + return ( +
+ {showCloneUrl && ( +
+
+ Clone HTTPS + {cloneUrl} +
+
+ )} +
+ © 2026 June Park +
+
+ ); +} + +export { Footer }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/graph.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/graph.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,334 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; + +// Configuration constants for the layout +const rowHeight = 40; +const colWidth = 20; +const nodeRadius = 4.5; + +// --- Interfaces --- + +interface Changeset { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + phase: string; + col: number; + row: number; + color: number; + edges: Array<{ + bcolor: string; + col: number; + color: number; + nextcol: number; + width: number; + }>; + parents: string[]; +} + +interface GraphData { + node: string; + changeset_count: number; + changesets: Changeset[]; +} + +interface UseGraphDataOptions { + initialCommit?: string | null; + graphTop?: string | null; +} + +interface UseGraphDataResult { + data: GraphData | null; + loading: boolean; + error: string | null; + loadMore: () => void; + hasMore: boolean; + tip: string | null; + currentCommit: string | null; +} + +// --- Hook Logic --- + +function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tip, setTip] = useState(graphTop); + const [currentCommit, setCurrentCommit] = useState(initialCommit); + const [hasMore, setHasMore] = useState(true); + + const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { + if (loading) return; + setLoading(true); + setError(null); + + try { + const url = !commit + ? `/api/graph/tip?style=json` + : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; + + const response = await fetch(url); + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); + + const result: GraphData = await response.json(); + + setData(prev => { + if (append && prev) { + const existingNodes = new Set(prev.changesets.map(cs => cs.node)); + const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); + + // Re-index rows to ensure they increment correctly for the canvas height + const startRow = prev.changesets.length; + const reindexed = newChangesets.map((cs, idx) => ({ + ...cs, + row: startRow + idx + })); + + return { + ...result, + changesets: [...prev.changesets, ...reindexed] + }; + } + return result; + }); + + if (!tip && !append) setTip(result.node); + + if (result.changesets.length > 0) { + const lastNode = result.changesets[result.changesets.length - 1].node; + setCurrentCommit(lastNode); + setHasMore(result.changesets.length >= 30); + } else { + setHasMore(false); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [tip, loading]); + + useEffect(() => { + fetchData(initialCommit, graphTop, false); + }, [initialCommit, graphTop]); + + const loadMore = useCallback(() => { + if (!loading && hasMore && currentCommit && tip) { + fetchData(currentCommit, tip, true); + } + }, [loading, hasMore, currentCommit, tip, fetchData]); + + return { data, loading, error, loadMore, hasMore, tip, currentCommit }; +} + +// --- Pencil Rendering Logic --- + +const drawPencilLine = ( + ctx: CanvasRenderingContext2D, + x1: number, y1: number, + x2: number, y2: number, + texture: CanvasPattern | null, // Ensure type safety + isCurve: boolean = false +) => { + const strokes = 3; + ctx.save(); + + for (let s = 0; s < strokes; s++) { + ctx.beginPath(); + ctx.strokeStyle = texture; + ctx.globalAlpha = 0.2 + (s * 0.2); + ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner + + // 2. Realistic Jitter: Actually return a random small number + const jitter = () => (Math.random() - 0.5) * 1.5; + + ctx.moveTo(x1 + jitter(), y1 + jitter()); + + if (isCurve) { + const cpY = y1 + (y2 - y1) / 2; + ctx.bezierCurveTo( + x1 + jitter(), cpY + jitter(), + x2 + jitter(), cpY + jitter(), + x2 + jitter(), y2 + jitter() + ); + } else { + ctx.lineTo(x2 + jitter(), y2 + jitter()); + } + + ctx.stroke(); + } + ctx.restore(); +} + +// --- Main Component --- + +interface GraphProps { + data: GraphData | null; + loading?: boolean; + hasMore?: boolean; + onLoadMore?: () => void; + onCommitClick?: (node: string) => void; + maxRows?: number; +} + +const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const changesets = useMemo(() => + maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); + + let pencilPattern; + const img = new Image(); + img.src = "http://localhost:6970/pencil_texture.png"; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !changesets.length) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Grab colors from CSS variables or defaults + const getColors = () => { + const s = getComputedStyle(document.documentElement); + return [ + s.getPropertyValue('--graph-1').trim() || '#4dabf7', + s.getPropertyValue('--graph-2').trim() || '#63e6be', + s.getPropertyValue('--graph-3').trim() || '#ffbc42', + s.getPropertyValue('--graph-4').trim() || '#b197fc', + s.getPropertyValue('--graph-5').trim() || '#ff8787', + s.getPropertyValue('--graph-6').trim() || '#f06595', + ]; + }; + + const colors = getColors(); + const dpr = window.devicePixelRatio || 1; + const maxCol = Math.max(...changesets.map(cs => cs.col), 0); + const canvasWidth = (maxCol + 2) * colWidth; + + // Scale for high-DPI screens + canvas.width = canvasWidth * dpr; + canvas.height = changesets.length * rowHeight * dpr; + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${changesets.length * rowHeight}px`; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); + + const getX = (col: number) => (col + 1) * colWidth; + const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); + + const renderCanvas = () => { + if (!pencilPattern) return; // Don't draw if the pattern isn't ready + + // Pass 1: Draw Connecting Edges + changesets.forEach((cs, i) => { + if (!cs.edges) return; + cs.edges.forEach(edge => { + const sX = getX(edge.col), sY = getY(i); + const eX = getX(edge.nextcol), eY = getY(i + 1); + + drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); + }); + }); + + // Pass 2: Draw Commit Nodes + changesets.forEach((cs, i) => { + const x = getX(cs.col), y = getY(i); + const color = colors[cs.color % colors.length]; + + // Sketchy outer glow + ctx.beginPath(); + ctx.arc(x, y, nodeRadius + 2, 0, Math.PI * 2); + ctx.fillStyle = `${color}33`; + ctx.fill(); + + // Core Node + ctx.beginPath(); + ctx.arc(x, y, nodeRadius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + // Sketchy border rings + for (let s = 0; s < 2; s++) { + ctx.beginPath(); + ctx.arc(x + (Math.random() - 0.5), y + (Math.random() - 0.5), nodeRadius + 0.5, 0, Math.PI * 2); + ctx.strokeStyle = '#000000'; + ctx.globalAlpha = 0.3; + ctx.lineWidth = 0.8; + ctx.stroke(); + } + ctx.globalAlpha = 1; + }); + }; + + img.onload = () => { + console.log("WTF"); + pencilPattern = ctx.createPattern(img, "repeat")!; + renderCanvas(); + }; + }, [changesets]); + + // Handle Infinite Scroll via Intersection Observer + useEffect(() => { + if (!onLoadMore || !hasMore) return; + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !loading) { + onLoadMore(); + } + }, { threshold: 0.1 }); + + const sentinel = document.getElementById('infinite-scroll-sentinel'); + if (sentinel) observer.observe(sentinel); + return () => observer.disconnect(); + }, [onLoadMore, hasMore, loading]); + + return ( +
+
+ {/* Graph Column - Sticky to keep lines aligned with text during scroll */} +
+ +
+ + {/* Details Column */} +
+ {changesets.map((cs) => ( +
onCommitClick?.(cs.node)} + onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > + {cs.node.substring(0, 12)} + {cs.desc} + {cs.user.split(' <')[0]} +
+ ))} +
+
+
+ + {loading &&
Loading repository history...
} +
+ ); +}; + +export { Graph, useGraphData }; +export type { GraphData, Changeset, UseGraphDataResult }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/header.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/header.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,60 @@ +import React from 'react'; + +// Icons +const ICONS = { + repo: "/public/epi_all_colors.svg", +}; + +const SunIcon = () => ( + + + + + + + + + + + +); + +const MoonIcon = () => ( + + + +); + +interface HeaderProps { + title?: string; + subtitle?: string; + showThemeToggle?: boolean; + isDark?: boolean; + onToggleTheme?: () => void; +} + +function Header({ + title = "Zenbu Repository", + subtitle, + showThemeToggle = true, + isDark = false, + onToggleTheme, +}: HeaderProps) { + return ( +
+ Zenbu +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {showThemeToggle && onToggleTheme && ( + + )} +
+ ); +} + +export { Header }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/repo-browser.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/repo-browser.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,584 @@ +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'; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +// --- ICONS (served as static files) --- +const ICONS = { + folder: "/icons/folder.png", + file: "/icons/file.svg", + close: "/icons/close.png" +}; + +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: Breadcrumb + */ +function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { + if (!currentPath) { + return ( + + ); + } + + const parts = currentPath.split('/').filter(p => p); + const crumbs = parts.map((part, index) => ({ + name: part, + fullPath: parts.slice(0, index + 1).join('/') + })); + + return ( + + ); +} + +/** + * 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, onOpenFile }: { + directories: any[]; + files: any[]; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const isEmpty = directories.length === 0 && files.length === 0; + + if (isEmpty) { + return ( +
+
This directory is empty.
+
+ ); + } + + return ( +
+
Files
+ +
+ {directories.map((dir) => ( + + ))} + + {files.map((file) => ( + + ))} +
+
+ ); +} + +/** + * Component: FileRow + */ +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) { + onNavigate(item.abspath); + } else if (isCodeFile(item.basename)) { + onOpenFile(item.abspath); + } else { + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); + } + }; + + const handleMouseEnter = () => { + 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)}`; + + return ( +
+ + {isDir + + + + {item.basename} + + +
+ ); +} + +/** + * Component: ReadmeViewer + */ +function ReadmeViewer({ content }: { content: string | null }) { + const contentRef = useRef(null); + const moduleRef = useRef(null); + const [wasmReady, setWasmReady] = useState(false); + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + 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]); + + if (!content) return null; + + return ( +
+
+ + README.md +
+
+ {!wasmReady && 'Loading...'} +
+
+ ); +} + +/** + * Repository Browser Content (uses theme context) + */ +function RepoBrowserContent() { + const [currentPath, setCurrentPath] = useState(getCurrentPath()); + 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 [viewingFile, setViewingFile] = useState(null); + + const { isDark, toggleTheme } = useTheme(); + + function getCurrentPath() { + const params = new URLSearchParams(window.location.search); + return params.get('path') || ''; + } + + useEffect(() => { + const handlePopState = () => setCurrentPath(getCurrentPath()); + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + useEffect(() => { + fetchDirectory(currentPath); + fetchReadme(currentPath); + }, [currentPath]); + + const navigate = (path: string) => { + const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; + window.history.pushState({ path }, '', newUrl); + setCurrentPath(path); + }; + + const fetchDirectory = async (path: string) => { + setLoading(true); + setError(null); + try { + const cacheKey = `dir:${path}`; + let data; + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); + } 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) { + throw new Error(data.error); + } + + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } catch (err: any) { + console.error('Error loading directory:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const fetchReadme = async (path: string) => { + setReadme(null); + const readmePath = path ? `${path}/README.md` : 'README.md'; + 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); + }, []); + + return ( + <> +
+
+ + {/* Clone Bar */} +
+
+ Clone HTTPS + hg clone http://zenbu.babocoder.com/repo +
+
+ + {/* Navigation & Content */} + + + {error &&
Error: {error}
} + + {loading ? ( +
+
Loading files...
+
+ ) : ( + <> + + + + )} + +
+
+ + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + + ) : ( + + ) + )} + + ); +} + +/** + * Main Application Component with ThemeProvider + */ +function RepoBrowser() { + return ( + + + + ); +} + +export { RepoBrowser }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/components/theme.tsx --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/theme.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,72 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +interface ThemeContextType { + isDark: boolean; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +// Apply theme class to document root +function applyTheme(isDark: boolean) { + const root = document.documentElement; + if (isDark) { + root.classList.add('dark'); + root.classList.remove('light'); + } else { + root.classList.add('light'); + root.classList.remove('dark'); + } +} + +interface ThemeProviderProps { + children: ReactNode; +} + +function ThemeProvider({ children }: ThemeProviderProps) { + const [isDark, setIsDark] = useState(() => { + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + // Apply theme on mount and change + useEffect(() => { + applyTheme(isDark); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + }, [isDark]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + // Only apply if no explicit preference is saved + if (!localStorage.getItem('theme')) { + setIsDark(e.matches); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = useCallback(() => { + setIsDark(prev => !prev); + }, []); + + return ( + + {children} + + ); +} + +function useTheme(): ThemeContextType { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} + +export { ThemeProvider, useTheme }; diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/index.css --- a/hg-web/src/index.css Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/src/index.css Sun Jan 25 20:04:55 2026 -0800 @@ -1,179 +1,751 @@ +/* =========================================== + Component Styles + Import base.css before this file + =========================================== */ + +/* =========================================== + App Layout + =========================================== */ +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +/* =========================================== + Header + =========================================== */ .header { - border-bottom: 1px solid var(--border); - padding-bottom: 1rem; - margin-bottom: 2rem; + display: flex; + align-items: center; + margin-bottom: 24px; + gap: 15px; +} + +.header-icon { + width: 32px; + height: 32px; + opacity: 0.8; + cursor: pointer; } .header h1 { - margin-bottom: 0.5rem; + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header h1 a { + color: inherit; + text-decoration: none; +} + +.header-subtitle { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -.header .description { - color: var(--secondary); - font-size: 0.95rem; +/* =========================================== + Navigation Tabs + =========================================== */ +.nav-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; } -.clone-info { - background: var(--code-bg); - border: 1px solid var(--border); +.nav-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; border-radius: 6px; - padding: 1rem; - margin-bottom: 2rem; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.nav-tab:hover { + background: var(--bg-subtle); + color: var(--text-primary); +} + +.nav-tab.active { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-primary); + font-weight: 500; } -.clone-info code { - background: none; - color: var(--fg); - font-size: 0.95rem; +.nav-tab svg { + color: var(--text-secondary); +} + +/* =========================================== + Landing Page + =========================================== */ +.landing-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 768px) { + .landing-grid { + grid-template-columns: 1fr; + } +} + +.landing-section { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; } -.breadcrumb { - margin-bottom: 1.5rem; - font-size: 0.95rem; +.landing-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); } -.breadcrumb a { - color: var(--link); +.landing-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); } -.breadcrumb a:hover { +.landing-section-title svg { + color: var(--text-secondary); +} + +.landing-section-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.2s; +} + +.landing-section-link:hover { + background: var(--bg-subtle); text-decoration: underline; } -.breadcrumb span { - color: var(--secondary); - margin: 0 0.5rem; +.landing-section-content { + padding: 0; +} + +/* =========================================== + Directory Items (Landing Preview) + =========================================== */ +.dir-item { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; + cursor: pointer; + transition: background 0.1s; +} + +.dir-item:last-child { + border-bottom: none; +} + +.dir-item:hover { + background: var(--hover); +} + +.dir-item-icon { + margin-right: 12px; + opacity: 0.7; +} + +.dir-item-icon img { + width: 18px; + height: 18px; +} + +.dir-item-name { + color: var(--text-primary); +} + +.dir-item-name:hover { + color: var(--accent); +} + +/* =========================================== + Page Header (Graph Page) + =========================================== */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; } -.file-list { +.back-button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.back-button:hover { + background: var(--hover); + color: var(--text-primary); +} + +.page-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* =========================================== + Graph Params + =========================================== */ +.graph-params { + display: flex; + gap: 12px; + margin-bottom: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.graph-param { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--bg-subtle); + border-radius: 4px; +} + +.graph-param-label { + font-weight: 500; +} + +.graph-param-value { + font-family: monospace; + color: var(--accent); +} + +/* =========================================== + Graph Component + =========================================== */ +.graph-container { + background: var(--bg); + color: var(--text-primary); + font-family: "More Thin", sans-serif; + border-radius: 6px; + border: 1px solid var(--border); overflow: hidden; } -.file-item { +.graph-wrapper { display: flex; - align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border); - transition: background-color 0.2s; + align-items: flex-start; + max-height: 600px; + overflow-y: auto; +} + +.graph-canvas-column { + flex-shrink: 0; + background: var(--bg); + position: sticky; + left: 0; } -.file-item:last-child { - border-bottom: none; +.graph-details-column { + flex-grow: 1; + overflow-x: hidden; } -.file-item:hover { +.graph-row { + height: 40px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 12px; + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + transition: background-color 0.1s; +} + +.graph-row:hover { background: var(--hover); } -.file-item .icon { - margin-right: 0.75rem; - font-size: 1.2rem; - width: 20px; - text-align: center; +.graph-row-meta { + display: flex; + gap: 10px; + margin-bottom: 2px; + align-items: center; +} + +.graph-hash { + color: var(--accent); + font-family: monospace; +} + +.graph-user { + color: var(--text-secondary); + font-weight: 500; +} + +.graph-branch { + color: var(--text-secondary); + font-size: 10px; + background: var(--bg-subtle); + padding: 1px 6px; + border-radius: 3px; +} + +.graph-desc { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.graph-badge-tip { + background: var(--success); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; +} + +.graph-badge-tag { + background: var(--accent); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; } -.file-item .name { - flex: 1; - font-family: 'Monaco', 'Courier New', monospace; - font-size: 0.9rem; +.graph-loading-row { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 12px; +} + +/* =========================================== + Common States + =========================================== */ +.empty-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.loading-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); } -.file-item .name a { - color: var(--fg); +.error-message { + padding: 15px; + border: 1px solid var(--danger-border); + background: var(--danger-bg); + color: var(--danger); + border-radius: 6px; + margin-bottom: 20px; +} + +/* =========================================== + Repository Browser + =========================================== */ +.repo-container { + font-family: "More Thin", sans-serif; + max-width: 980px; + margin: 40px auto; + color: var(--text-primary); + padding: 0 20px; +} + +/* Clone Box */ +.clone-box { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; } -.file-item .name a:hover { - color: var(--link); +.clone-label { + font-weight: 600; + font-size: 13px; + margin-right: 10px; + color: var(--text-primary); +} + +.clone-url { + font-family: "More Thin", sans-serif; + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + flex-grow: 1; +} + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + font-size: 14px; + margin-bottom: 16px; + color: var(--text-secondary); + padding: 8px 0; } -.file-item.directory .icon { +.breadcrumb a { color: var(--accent); + text-decoration: none; + border-radius: 4px; + padding: 2px 6px; +} + +.breadcrumb a:hover { + background: var(--bg-subtle); + text-decoration: underline; +} + +.breadcrumb .separator { + margin: 0 4px; + color: var(--text-secondary); + opacity: 0.5; +} + +.breadcrumb .nav-item.active { + font-weight: 600; + color: var(--text-primary); + padding: 2px 6px; +} + +/* File List */ +.file-list-container { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--bg); } -.file-item.file .icon { - color: var(--secondary); +.file-header { + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + padding: 12px 16px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.file-row { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row:hover { + background: var(--hover); } -.readme-section { - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid var(--border); +.file-row .icon img { + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 12px; +} + +.file-row .name a { + color: var(--text-primary); + text-decoration: none; + font-size: 14px; } -.readme-section h2 { - margin-bottom: 1rem; - font-size: 1.5rem; +.file-row .name a:hover { + color: var(--accent); + text-decoration: underline; +} + +/* Readme */ +.readme-section { + margin-top: 32px; + border: 1px solid var(--border); + border-radius: 6px; +} + +.readme-header { + background: var(--bg-subtle); + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; } .readme-content { + padding: 32px; + background: var(--bg); + overflow-x: auto; + color: var(--text-primary); +} + +/* File Viewer Modal */ +.file-viewer-overlay { + position: fixed; + inset: 0; + background: var(--overlay); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +.file-viewer { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - padding: 1.5rem; - background: var(--code-bg); + width: 100%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.file-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + border-radius: 6px 6px 0 0; +} + +.file-viewer-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; } -.readme-content h1 { font-size: 1.75rem; margin-top: 1.5rem; } -.readme-content h2 { font-size: 1.5rem; margin-top: 1.25rem; } -.readme-content h3 { font-size: 1.25rem; margin-top: 1rem; } +.file-viewer-close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} -.readme-content h1:first-child, -.readme-content h2:first-child, -.readme-content h3:first-child { - margin-top: 0; +.file-viewer-close:hover { + background: var(--hover); +} + +.file-viewer-close img { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.file-viewer-content { + overflow: auto; + flex: 1; } -.readme-content ul, -.readme-content ol { - margin-left: 2rem; - margin-bottom: 1rem; +.file-viewer-content pre { + margin: 0; + padding: 16px; + background: var(--bg-code); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; +} + +.file-viewer-content code { + background: transparent; + padding: 0; } -.readme-content li { - margin-bottom: 0.5rem; +.file-viewer-loading { + padding: 40px; + text-align: center; + color: var(--text-secondary); } -.readme-content img { - max-width: 100%; - height: auto; - border-radius: 6px; +.file-viewer-line-numbers { + display: inline-block; + user-select: none; + text-align: right; + padding-right: 16px; + margin-right: 16px; + border-right: 1px solid var(--border); + color: var(--text-secondary); + opacity: 0.5; } -.empty-state { - text-align: center; - padding: 3rem 1rem; - color: var(--secondary); +/* Theme Toggle Button */ +.theme-toggle { + margin-left: auto; + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + transition: all 0.2s; } -.error-message { - background: var(--danger); - color: white; - padding: 1rem; - border-radius: 6px; - margin-bottom: 1rem; +.theme-toggle:hover { + background: var(--hover); + color: var(--text-primary); +} + +.theme-toggle svg { + flex-shrink: 0; +} + +/* Description */ +.description { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -/* Mobile responsive */ +/* =========================================== + Mobile Responsive + =========================================== */ @media (max-width: 768px) { - main { - padding: 1rem; + .app-container { + padding: 20px 15px; } - .file-item { - padding: 0.5rem 0.75rem; + .repo-container { + padding: 0 15px; + } + + .file-row { + padding: 8px 12px; } - .file-item .name { - font-size: 0.85rem; - } - - .clone-info { - padding: 0.75rem; - overflow-x: auto; - } - - .readme-content { - padding: 1rem; + .clone-box { + padding: 10px 12px; + flex-direction: column; + align-items: flex-start; + gap: 8px; } } + +/* =========================================== + Footer + =========================================== */ +.footer { + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.footer-content { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px 0; + color: var(--text-secondary); + font-size: 13px; +} + +.footer-separator { + opacity: 0.5; +} + +.footer-text { + color: var(--text-secondary); +} + +.clone-box-inner { + display: flex; + align-items: center; + width: 100%; +} + +/* Header content wrapper */ +.header-content { + flex: 1; +} + +.header-content h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header-content h1 a { + color: inherit; + text-decoration: none; +} + +.header-content h1 a:hover { + text-decoration: none; +} diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/index.html --- a/hg-web/src/index.html Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/src/index.html Sun Jan 25 20:04:55 2026 -0800 @@ -4,8 +4,13 @@ Zenbu Repository + + + + + diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/main.tsx --- a/hg-web/src/main.tsx Sat Jan 24 21:52:14 2026 -0800 +++ b/hg-web/src/main.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -1,8 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { RepoBrowser } from "hg-web/src/repo-browser"; +import { App } from "hg-web/src/components/app"; -const root = ReactDOM.createRoot(document.getElementById('root')); - -// Use JSX syntax () -root.render(); +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render(); diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/pencil_texture.png Binary file hg-web/src/pencil_texture.png has changed diff -r b818a4561a3c -r 9f4429c49733 hg-web/src/repo-browser.tsx --- a/hg-web/src/repo-browser.tsx Sat Jan 24 21:52:14 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,879 +0,0 @@ -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 (served as static files) --- -const ICONS = { - folder: "/icons/folder.png", - file: "/icons/file.svg", - home: "/icons/home.png", - repo: "/public/epi_all_colors.svg", - 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 with dark/light mode support - */ -const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( - -); - -/** - * Component: Breadcrumb - */ -function Breadcrumb({ currentPath, onNavigate }) { - if (!currentPath) { - return ( - - ); - } - - const parts = currentPath.split('/').filter(p => p); - const crumbs = parts.map((part, index) => ({ - name: part, - fullPath: parts.slice(0, index + 1).join('/') - })); - - return ( - - ); -} - -/** - * 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, onOpenFile }: { - directories: any[]; - files: any[]; - onNavigate: (path: string) => void; - onOpenFile: (path: string) => void; -}) { - const isEmpty = directories.length === 0 && files.length === 0; - - if (isEmpty) { - return ( -
-
This directory is empty.
-
- ); - } - - return ( -
- {/* Optional header row like GitHub */} -
- Files -
- -
- {directories.map((dir) => ( - - ))} - - {files.map((file) => ( - - ))} -
-
- ); -} - -/** - * Component: FileRow - */ -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) { - 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 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)}`; - - return ( -
- - {isDir - - - - {item.basename} - - -
- ); -} - -/** - * Component: ReadmeViewer - */ -function ReadmeViewer({ content }: { content: string | null }) { - const contentRef = useRef(null); - const moduleRef = useRef(null); - const [wasmReady, setWasmReady] = useState(false); - - useEffect(() => { - createMarkdownModule().then((Module: any) => { - moduleRef.current = Module; - setWasmReady(true); - }); - }, []); - - 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]); - - if (!content) return null; - - return ( -
-
- - README.md -
-
- {!wasmReady && 'Loading...'} -
-
- ); -} - -/** - * Main Application Component - */ -function RepoBrowser() { - const [currentPath, setCurrentPath] = useState(getCurrentPath()); - 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); - return () => window.removeEventListener('popstate', handlePopState); - }, []); - - useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); - }, [currentPath]); - - const navigate = (path: string) => { - const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; - window.history.pushState({ path }, '', newUrl); - setCurrentPath(path); - }; - - const fetchDirectory = async (path: string) => { - setLoading(true); - setError(null); - try { - // Check prefetch cache first - const cacheKey = `dir:${path}`; - let data; - 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) { - throw new Error(data.error); - } - - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); - } catch (err: any) { - console.error('Error loading directory:', err); - setError(err.message); - } finally { - setLoading(false); - } - }; - - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; - 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 -
-

Zenbu Repository

-

Browse and manage the mercurial codebase

-
- -
- - {/* Clone Bar */} -
-
- Clone HTTPS - hg clone http://zenbu.babocoder.com/repo -
-
- - {/* Navigation & Content */} - - - {error &&
Error: {error}
} - - {loading ? ( -
- Loading files... -
- ) : ( - <> - - - - )} -
- - {/* File Viewer Modal */} - {viewingFile && ( - isMarkdownFile(viewingFile) ? ( - - ) : ( - - ) - )} - - ); -} - -export { RepoBrowser }; diff -r b818a4561a3c -r 9f4429c49733 mrjunejune/BUILD --- a/mrjunejune/BUILD Sat Jan 24 21:52:14 2026 -0800 +++ b/mrjunejune/BUILD Sun Jan 25 20:04:55 2026 -0800 @@ -30,7 +30,13 @@ filegroup( name = "public_files", - srcs = glob(["src/public/**"]), + srcs = glob(["src/public/*"]), + visibility = ["//visibility:public"], +) + +filegroup( + name = "public_fonts_files", + srcs = glob(["src/public/fonts/*"]), visibility = ["//visibility:public"], ) diff -r b818a4561a3c -r 9f4429c49733 react_games/public/base.css --- a/react_games/public/base.css Sat Jan 24 21:52:14 2026 -0800 +++ b/react_games/public/base.css Sun Jan 25 20:04:55 2026 -0800 @@ -5,7 +5,6 @@ } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 10%, #764ba2 100%); background-attachment: fixed; background-repeat: no-repeat;