view hg-web/src/repo-browser.tsx @ 191:a06710325c30 hg-web

[HgWeb] Fully working copy.
author MrJuneJune <me@mrjunejune.com>
date Sat, 24 Jan 2026 21:51:51 -0800
parents a2725419f988
children
line wrap: on
line source

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 }) => (
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="5"/>
    <line x1="12" y1="1" x2="12" y2="3"/>
    <line x1="12" y1="21" x2="12" y2="23"/>
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
    <line x1="1" y1="12" x2="3" y2="12"/>
    <line x1="21" y1="12" x2="23" y2="12"/>
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
  </svg>
);

const MoonIcon = ({ color = "currentColor" }: { color?: string }) => (
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
  </svg>
);

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: Styles
 * Injected CSS for the polished look with dark/light mode support
 */
const GlobalStyles = ({ isDark }: { isDark: boolean }) => (
  <style>{`
    :root {
      --bg-color: ${isDark ? '#0d1117' : '#ffffff'};
      --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'};
      --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'};
      --border-color: ${isDark ? '#30363d' : '#d0d7de'};
      --accent-color: ${isDark ? '#58a6ff' : '#0969da'};
      --text-primary: ${isDark ? '#e6edf3' : '#1f2328'};
      --text-secondary: ${isDark ? '#8b949e' : '#656d76'};
      --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'};
      --radius: 6px;
      --code-bg: ${isDark ? '#161b22' : '#ffffff'};
    }

    body {
      background: var(--bg-color);
      transition: background 0.2s;
    }

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

    /* Theme Toggle */
    .theme-toggle {
      margin-left: auto;
      background: var(--bg-subtle);
      border: 1px solid var(--border-color);
      border-radius: var(--radius);
      padding: 8px 12px;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 6px;
      color: var(--text-secondary);
      font-size: 13px;
      transition: all 0.2s;
    }
    .theme-toggle:hover {
      background: var(--hover-color);
      color: var(--text-primary);
    }
    .theme-toggle svg {
      flex-shrink: 0;
    }

    /* 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: var(--bg-color);
      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;
      background: var(--bg-color);
    }
    .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 ${isDark ? '#f8514966' : '#ffdce0'};
      background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#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;
      filter: ${isDark ? 'invert(0.8)' : 'none'};
    }
    .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;
    }
    .readme-header img {
      filter: ${isDark ? 'invert(0.7)' : 'none'};
    }
    #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); }

    /* File Viewer */
    .file-viewer-overlay {
      position: fixed;
      inset: 0;
      background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'};
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 1000;
      padding: 20px;
    }
    .file-viewer {
      background: var(--bg-color);
      border: 1px solid var(--border-color);
      border-radius: var(--radius);
      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-color);
      border-radius: var(--radius) var(--radius) 0 0;
    }
    .file-viewer-title {
      font-weight: 600;
      font-size: 14px;
      color: var(--text-primary);
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .file-viewer-close {
      background: transparent;
      border: none;
      cursor: pointer;
      padding: 4px;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .file-viewer-close:hover {
      background: var(--hover-color);
    }
    .file-viewer-close img {
      width: 16px;
      height: 16px;
      filter: ${isDark ? 'invert(0.7)' : 'none'};
      opacity: 0.7;
    }
    .file-viewer-content {
      overflow: auto;
      flex: 1;
    }
    .file-viewer-content pre {
      margin: 0;
      padding: 16px;
      background: var(--code-bg);
      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;
    }
    .file-viewer-loading {
      padding: 40px;
      text-align: center;
      color: var(--text-secondary);
    }
    .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);
      color: var(--text-secondary);
      opacity: 0.5;
    }
  `}</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: 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]);

  // 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<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 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
 * Shows markdown content rendered in a modal
 */
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]);

  // 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 (
    <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 src={ICONS.close} alt="Close" />
          </button>
        </div>
        <div className="file-viewer-content">
          {loading || !wasmReady ? (
            <div className="file-viewer-loading">Loading...</div>
          ) : content ? (
            <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} />
          ) : (
            <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">
       {/* 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}
            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 {
      // 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 (
    <div className="file-row" onMouseEnter={handleMouseEnter}>
      <span className="icon">
        <img 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 id="readmeSection">
      <div className="readme-header">
        <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} />
        README.md
      </div>
      <div id="readmeContent" ref={contentRef}>
        {!wasmReady && 'Loading...'}
      </div>
    </div>
  );
}

/**
 * Main Application Component
 */
function RepoBrowser() {
  const [currentPath, setCurrentPath] = useState(getCurrentPath());
  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 [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<string | null>(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 (
    <>
      <GlobalStyles isDark={isDarkMode} />
      <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>
          <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}>
            {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />}
            {isDarkMode ? 'Light' : 'Dark'}
          </button>
        </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: 'var(--text-secondary)'}}>
             Loading files...
          </div>
        ) : (
          <>
            <FileList
              directories={content.directories}
              files={content.files}
              onNavigate={navigate}
              onOpenFile={handleOpenFile}
            />
            <ReadmeViewer content={readme} />
          </>
        )}
      </div>

      {/* File Viewer Modal */}
      {viewingFile && (
        isMarkdownFile(viewingFile) ? (
          <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
        ) : (
          <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
        )
      )}
    </>
  );
}

export { RepoBrowser };