view hg-web/src/components/repo-browser.tsx @ 204:e5aed6c36672

[Notes] Added icons and updated styling a bit. Probalby usable now.
author MrJuneJune <me@mrjunejune.com>
date Sun, 15 Feb 2026 11:02:13 -0800
parents 9f4429c49733
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';
import { Header } from "hg-web/src/components/header";
import { Footer } from "hg-web/src/components/footer";
import { ThemeProvider, useTheme } from "hg-web/src/components/theme";

// --- 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={`?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 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
 * 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 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
    ? `?path=${encodeURIComponent(item.abspath)}`
    : `/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>
  );
}

/**
 * Repository Browser Content (uses theme context)
 */
function RepoBrowserContent() {
  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 [viewingFile, setViewingFile] = useState<string | null>(null);

  const { isDark, toggleTheme } = useTheme();

  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: string) => {
    const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
    window.history.pushState({ path }, '', newUrl);
    setCurrentPath(path);
  };

  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, ignore errors
    }
  };

  const handleOpenFile = useCallback((path: string) => {
    setViewingFile(path);
  }, []);

  const handleCloseFile = useCallback(() => {
    setViewingFile(null);
  }, []);

  return (
    <>
      <div className="repo-container">
        <Header
          title="Zenbu Repository"
          subtitle="Browse and manage the mercurial codebase"
          showThemeToggle={true}
          isDark={isDark}
          onToggleTheme={toggleTheme}
        />

        {/* Clone Bar */}
        <div className="clone-box">
          <div className="clone-box-inner">
            <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">
            <div className="loading-state">Loading files...</div>
          </div>
        ) : (
          <>
            <FileList
              directories={content.directories}
              files={content.files}
              onNavigate={navigate}
              onOpenFile={handleOpenFile}
            />
            <ReadmeViewer content={readme} />
          </>
        )}

        <Footer />
      </div>

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

/**
 * Main Application Component with ThemeProvider
 */
function RepoBrowser() {
  return (
    <ThemeProvider>
      <RepoBrowserContent />
    </ThemeProvider>
  );
}

export { RepoBrowser };