diff hg-web/src/repo-browser.tsx @ 175:71ad34a8bc9a hg-web

[HgWeb] Can stream hg response now. Added react page for hg web since we use json anyway.
author MrJuneJune <me@mrjunejune.com>
date Tue, 20 Jan 2026 06:06:47 -0800
parents
children fed99fc04e12
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/repo-browser.tsx	Tue Jan 20 06:06:47 2026 -0800
@@ -0,0 +1,257 @@
+import React, { useState, useEffect } from 'react';
+
+const API_BASE = '/api/repo';
+
+/**
+ * Component: Breadcrumb
+ * Renders the navigation path at the top
+ */
+function Breadcrumb({ currentPath, onNavigate }) {
+  if (!currentPath) {
+    return (
+      <nav id="breadcrumb">
+        <span className="nav-item active">Root</span>
+      </nav>
+    );
+  }
+
+  const parts = currentPath.split('/').filter(p => p);
+  
+  // Create cumulative paths for links
+  // e.g., src/components -> ['src', 'src/components']
+  const crumbs = parts.map((part, index) => ({
+    name: part,
+    fullPath: parts.slice(0, index + 1).join('/')
+  }));
+
+  return (
+    <nav id="breadcrumb">
+      <a 
+        href="/" 
+        onClick={(e) => { e.preventDefault(); onNavigate(''); }}
+      >
+        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={`?path=${encodeURIComponent(crumb.fullPath)}`}
+                onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
+              >
+                {crumb.name}
+              </a>
+            )}
+          </React.Fragment>
+        );
+      })}
+    </nav>
+  );
+}
+
+/**
+ * Component: FileList
+ * Renders the table of directories and files
+ */
+function FileList({ directories, files, onNavigate }) {
+  const isEmpty = directories.length === 0 && files.length === 0;
+
+  if (isEmpty) {
+    return <div className="empty-state">No files found.</div>;
+  }
+
+  return (
+    <div id="fileList">
+      {/* Render Directories */}
+      {directories.map((dir) => (
+        <FileRow 
+          key={dir.abspath}
+          item={dir}
+          icon="📁"
+          isDir={true}
+          onNavigate={onNavigate}
+        />
+      ))}
+
+      {/* Render Files */}
+      {files.map((file) => (
+        <FileRow 
+          key={file.abspath}
+          item={file}
+          icon="📄"
+          isDir={false}
+        />
+      ))}
+    </div>
+  );
+}
+
+/**
+ * Component: FileRow
+ * Individual item row
+ */
+function FileRow({ item, icon, isDir, onNavigate }) {
+  const handleClick = (e) => {
+    if (isDir) {
+      e.preventDefault();
+      onNavigate(item.abspath);
+    }
+    // Files let the default <a> behavior happen (download/open in new tab)
+  };
+
+  // Files link to the raw content API, Dirs link to the app view
+  const href = isDir 
+    ? `?path=${encodeURIComponent(item.abspath)}`
+    : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
+  
+  const target = isDir ? undefined : "_blank";
+
+  return (
+    <div className={`file-item ${item.type}`}>
+      <span className="icon">{icon}</span>
+      <span className="name">
+        <a href={href} onClick={handleClick} target={target} rel="noreferrer">
+          {item.basename}
+        </a>
+      </span>
+    </div>
+  );
+}
+
+/**
+ * Component: ReadmeViewer
+ * Renders the README content
+ */
+function ReadmeViewer({ content }) {
+  if (!content) return null;
+
+  return (
+    <div id="readmeSection" style={{ marginTop: '20px', borderTop: '1px solid #eee' }}>
+      <h3>README.md</h3>
+      <div id="readmeContent">
+        <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
+          {content}
+        </pre>
+      </div>
+    </div>
+  );
+}
+
+
+
+/**
+ * Main Application Component
+ */
+function RepoBrowser() {
+  // State management for path, data, and UI states
+  const [currentPath, setCurrentPath] = useState(getCurrentPath());
+  const [content, setContent] = useState({ files: [], directories: [] });
+  const [readme, setReadme] = useState(null);
+  const [error, setError] = useState(null);
+  const [loading, setLoading] = useState(false);
+
+  // Helper to get path from URL query params
+  function getCurrentPath() {
+    const params = new URLSearchParams(window.location.search);
+    return params.get('path') || '';
+  }
+
+  // Effect: Handle Browser Navigation (Back/Forward buttons)
+  useEffect(() => {
+    const handlePopState = () => setCurrentPath(getCurrentPath());
+    window.addEventListener('popstate', handlePopState);
+    return () => window.removeEventListener('popstate', handlePopState);
+  }, []);
+
+  // Effect: Fetch Data whenever currentPath changes
+  useEffect(() => {
+    fetchDirectory(currentPath);
+    fetchReadme(currentPath);
+  }, [currentPath]);
+
+  // Internal navigation handler (avoids full page reload)
+  const navigate = (path) => {
+    const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
+    window.history.pushState({ path }, '', newUrl);
+    setCurrentPath(path);
+  };
+
+  const fetchDirectory = async (path) => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = path 
+        ? `${API_BASE}/list?path=${encodeURIComponent(path)}` 
+        : `${API_BASE}/list`;
+      
+      const response = await fetch(url);
+      const data = await response.json();
+
+      if (data.error) throw new Error(data.error);
+      
+      // Ensure we always have arrays even if API returns null
+      setContent({
+        files: data.files || [],
+        directories: data.directories || []
+      });
+    } catch (err) {
+      console.error('Error loading directory:', err);
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchReadme = async (path) => {
+    setReadme(null); // Reset previous readme
+    try {
+      const readmePath = path ? `${path}/README.md` : 'README.md';
+      const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`);
+      
+      if (response.ok) {
+        const text = await response.text();
+        setReadme(text);
+      }
+    } catch (err) {
+      // Silently fail for Readme as it's optional
+    }
+  };
+
+  return (
+    <div className="repo-container">
+      <div class="header">
+        <h1>Zenbu Repository</h1>
+        <p class="description">Browse and clone this mercurial repository</p>
+      </div>
+
+      <div class="clone-info">
+        <strong>Clone this repository:</strong>
+        <p><code>hg clone http://zenbu.babocoder.com/repo</code></p>
+      </div>
+
+      <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
+      
+      {error && <div className="error-message">Error: {error}</div>}
+      
+      {loading ? (
+        <div className="loading">Loading...</div>
+      ) : (
+        <>
+          <FileList 
+            directories={content.directories} 
+            files={content.files} 
+            onNavigate={navigate} 
+          />
+          <ReadmeViewer content={readme} />
+        </>
+      )}
+    </div>
+  );
+}
+
+export { RepoBrowser };