view 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 source

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 };