diff hg-web/src/components/directory-browser.tsx @ 193:9f4429c49733 hg-web

[HgWeb] Making progress....
author MrJuneJune <me@mrjunejune.com>
date Sun, 25 Jan 2026 20:04:55 -0800
parents
children
line wrap: on
line diff
--- /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<string, Promise<any>>();
+
+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<string | null> {
+  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 (
+      <nav className="breadcrumb">
+        <span className="nav-item active">root</span>
+      </nav>
+    );
+  }
+
+  const parts = currentPath.split('/').filter(p => p);
+  const crumbs = parts.map((part, index) => ({
+    name: part,
+    fullPath: parts.slice(0, index + 1).join('/')
+  }));
+
+  return (
+    <nav className="breadcrumb">
+      <a
+        href="#"
+        onClick={(e) => { e.preventDefault(); onNavigate(''); }}
+        title="Go to Root"
+      >
+        root
+      </a>
+      {crumbs.map((crumb, index) => {
+        const isLast = index === crumbs.length - 1;
+        return (
+          <React.Fragment key={crumb.fullPath}>
+            <span className="separator">/</span>
+            {isLast ? (
+              <span className="nav-item active">{crumb.name}</span>
+            ) : (
+              <a
+                href="#"
+                onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
+              >
+                {crumb.name}
+              </a>
+            )}
+          </React.Fragment>
+        );
+      })}
+    </nav>
+  );
+}
+
+/**
+ * Component: FileViewer
+ * Shows file content inline with syntax highlighting
+ */
+function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const codeRef = useRef<HTMLElement>(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<string, string> = {
+      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 (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <pre style={{ display: 'flex' }}>
+              <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
+              <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
+            </pre>
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: MarkdownViewerModal
+ */
+function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(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 (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading || !wasmReady ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <div className="readme-content" ref={contentRef} />
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * 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 (
+      <div className="file-list-container">
+        <div className="empty-state">This directory is empty.</div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="file-list-container">
+      <div className="file-header">Files</div>
+      <div id="fileListBody">
+        {directories.map((dir) => (
+          <FileRow
+            key={dir.abspath}
+            item={dir}
+            iconUrl={ICONS.folder}
+            isDir={true}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+        {files.map((file) => (
+          <FileRow
+            key={file.abspath}
+            item={file}
+            iconUrl={ICONS.file}
+            isDir={false}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * 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 (
+    <div className="file-row" onMouseEnter={handleMouseEnter}>
+      <span className="icon">
+        <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
+      </span>
+      <span className="name">
+        <a href={href} onClick={handleClick}>
+          {item.basename}
+        </a>
+      </span>
+    </div>
+  );
+}
+
+/**
+ * Component: ReadmeViewer
+ */
+function ReadmeViewer({ content }: { content: string | null }) {
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(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 (
+    <div className="readme-section">
+      <div className="readme-header">
+        <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
+        README.md
+      </div>
+      <div className="readme-content" ref={contentRef}>
+        {!wasmReady && 'Loading...'}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * 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<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [viewingFile, setViewingFile] = useState<string | null>(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 (
+    <>
+      <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
+
+      {error && <div className="error-message">Error: {error}</div>}
+
+      {loading ? (
+        <div className="file-list-container">
+          <div className="loading-state">Loading files...</div>
+        </div>
+      ) : (
+        <>
+          <FileList
+            directories={content.directories}
+            files={content.files}
+            onNavigate={navigate}
+            onOpenFile={handleOpenFile}
+          />
+          <ReadmeViewer content={readme} />
+        </>
+      )}
+
+      {/* File Viewer Modal */}
+      {viewingFile && (
+        isMarkdownFile(viewingFile) ? (
+          <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : (
+          <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
+        )
+      )}
+    </>
+  );
+}
+
+export { DirectoryBrowser };