view hg-web/src/repo-browser.tsx @ 188:32ce881452fa hg-web

Fixing few stuff.
author MrJuneJune <me@mrjunejune.com>
date Fri, 23 Jan 2026 22:50:28 -0800
parents fed99fc04e12
children a2725419f988
line wrap: on
line source

import React, { useState, useEffect } from 'react';
import renderMarkdown from './markdown_to_html_bin.js';

console.log(renderMarkdown);

// --- ICONS (Using CDN Links) ---
const ICONS = {
  folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png",
  file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg",
  home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png",
  repo: "/public/epi_all_colors.svg",
  clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png"
};

const API_BASE = '/api/repo';

/**
 * Component: Styles
 * Injected CSS for the polished look
 */
const GlobalStyles = () => (
  <style>{`
    :root {
      --bg-color: #ffffff;
      --bg-subtle: #f6f8fa;
      --border-color: #d0d7de;
      --accent-color: #0969da;
      --text-primary: #1f2328;
      --text-secondary: #656d76;
      --hover-color: #f3f4f6;
      --radius: 6px;
    }

    .repo-container {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      max-width: 980px;
      margin: 40px auto;
      color: var(--text-primary);
      padding: 0 20px;
    }

    /* Header */
    .header {
      display: flex;
      align-items: center;
      margin-bottom: 20px;
      gap: 15px;
    }
    .header-icon { width: 32px; height: 32px; opacity: 0.8; }
    .header h1 { margin: 0; font-size: 24px; font-weight: 600; }
    .description { color: var(--text-secondary); margin: 0; font-size: 14px; }

    /* Clone Box */
    .clone-box {
      background: var(--bg-subtle);
      border: 1px solid var(--border-color);
      border-radius: var(--radius);
      padding: 12px 16px;
      margin-bottom: 24px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); }
    .clone-url { 
      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
      background: white;
      border: 1px solid var(--border-color);
      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;
    }
    #breadcrumb a { 
      color: var(--accent-color); 
      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 Table Structure */
    .file-list-container {
      border: 1px solid var(--border-color);
      border-radius: var(--radius);
      overflow: hidden;
    }
    .file-header {
      background: var(--bg-subtle);
      border-bottom: 1px solid var(--border-color);
      padding: 12px 16px;
      font-size: 13px;
      font-weight: 600;
      color: var(--text-secondary);
    }
    .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); }
    .error-message { 
      padding: 15px; border: 1px solid #ffdce0; 
      background: #ffebe9; color: #cf222e; 
      border-radius: var(--radius); margin-bottom: 20px; 
    }

    /* File Row */
    .file-row {
      display: flex;
      align-items: center;
      padding: 10px 16px;
      border-bottom: 1px solid var(--border-color);
      transition: background 0.1s;
    }
    .file-row:last-child { border-bottom: none; }
    .file-row:hover { background: var(--hover-color); }
    
    .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; 
    }
    .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; }

    /* Readme */
    #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); }
    .readme-header { 
      background: var(--bg-subtle); 
      padding: 10px 16px; 
      font-size: 12px; font-weight: 600; 
      border-bottom: 1px solid var(--border-color);
      display: flex; align-items: center; gap: 8px;
    }
    #readmeContent { padding: 32px; background: white; overflow-x: auto; }
  `}</style>
);

/**
 * Component: Breadcrumb
 */
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);
  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(''); }}
        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={`?path=${encodeURIComponent(crumb.fullPath)}`}
                onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
              >
                {crumb.name}
              </a>
            )}
          </React.Fragment>
        );
      })}
    </nav>
  );
}

/**
 * Component: FileList
 */
function FileList({ directories, files, onNavigate }) {
  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">
       {/* Optional header row like GitHub */}
      <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}
          />
        ))}

        {files.map((file) => (
          <FileRow 
            key={file.abspath}
            item={file}
            iconUrl={ICONS.file}
            isDir={false}
          />
        ))}
      </div>
    </div>
  );
}

/**
 * Component: FileRow
 */
function FileRow({ item, iconUrl, isDir, onNavigate }) {
  const handleClick = (e) => {
    if (isDir) {
      e.preventDefault();
      onNavigate(item.abspath);
    }
  };

  const href = isDir 
    ? `?path=${encodeURIComponent(item.abspath)}`
    : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
  
  const target = isDir ? undefined : "_blank";

  return (
    <div className="file-row">
      <span className="icon">
        <img src={iconUrl} alt={isDir ? "Directory" : "File"} />
      </span>
      <span className="name">
        <a href={href} onClick={handleClick} target={target} rel="noreferrer">
          {item.basename}
        </a>
      </span>
    </div>
  );
}

/**
 * Component: ReadmeViewer
 */
function ReadmeViewer({ content }) {
  if (!content) return null;

  return (
    <div id="readmeSection">
      <div className="readme-header">
        <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} />
        README.md
      </div>
      <div id="readmeContent"></div>
    </div>
  );
}

/**
 * Main Application Component
 */
function RepoBrowser() {
  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);

  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) => {
    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);
      
      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);
    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 */ }
  };

  return (
    <>
      <GlobalStyles />
      <div className="repo-container">
        
        {/* Header */}
        <div className="header">
          <img src={ICONS.repo} alt="Repo" className="header-icon" />
          <div>
            <h1>Zenbu Repository</h1>
            <p className="description">Browse and manage the mercurial codebase</p>
          </div>
        </div>

        {/* Clone Bar */}
        <div className="clone-box">
          <div style={{display:'flex', alignItems:'center', width:'100%'}}>
             <span className="clone-label">Clone HTTPS</span>
             <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code>
          </div>
        </div>

        {/* Navigation & Content */}
        <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
        
        {error && <div className="error-message">Error: {error}</div>}
        
        {loading ? (
          <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}>
             Loading files...
          </div>
        ) : (
          <>
            <FileList 
              directories={content.directories} 
              files={content.files} 
              onNavigate={navigate} 
            />
            <ReadmeViewer content={readme} />
          </>
        )}
      </div>
    </>
  );
}

export { RepoBrowser };