diff hg-web/src/components/directory-browser.tsx @ 226:3fa4bf481f42

[merge] Merge hg-web into default
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 14:42:01 -0700
parents 70de0c80d093
children
line wrap: on
line diff
--- a/hg-web/src/components/directory-browser.tsx	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 14:42:01 2026 -0700
@@ -19,9 +19,22 @@
   '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'
+  'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample',
+  'bzl', 'bazel'
+]);
+
+const BAZEL_FILENAMES = new Set([
+  'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel'
 ]);
 
+type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf';
+
+const IMAGE_EXTENSIONS = new Set([
+  'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'
+]);
+const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']);
+const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']);
+
 // Prefetch cache
 const prefetchCache = new Map<string, Promise<any>>();
 
@@ -30,6 +43,7 @@
   const basename = filename.toLowerCase();
   return CODE_EXTENSIONS.has(ext) ||
          CODE_EXTENSIONS.has(basename) ||
+         BAZEL_FILENAMES.has(basename) ||
          basename === 'dockerfile' ||
          basename === 'makefile' ||
          basename.startsWith('.');
@@ -40,6 +54,15 @@
   return ext === 'md' || ext === 'markdown';
 }
 
+function getStaticPreviewKind(filename: string): StaticPreviewKind | null {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  if (IMAGE_EXTENSIONS.has(ext)) return 'image';
+  if (VIDEO_EXTENSIONS.has(ext)) return 'video';
+  if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
+  if (ext === 'pdf') return 'pdf';
+  return null;
+}
+
 function prefetchDirectory(path: string): void {
   const cacheKey = `dir:${path}`;
   if (prefetchCache.has(cacheKey)) return;
@@ -155,6 +178,10 @@
 
   const getLanguage = () => {
     const ext = filename.split('.').pop()?.toLowerCase() || '';
+    const basename = filename.toLowerCase();
+    if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') {
+      return 'python';
+    }
     const langMap: Record<string, string> = {
       js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
       py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
@@ -276,6 +303,81 @@
   );
 }
 
+function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const filename = filePath.split('/').pop() || filePath;
+  const previewKind = getStaticPreviewKind(filename);
+  const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`;
+  const [failed, setFailed] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.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 static-file-viewer"
+        role="dialog"
+        aria-modal="true"
+        aria-label={`Preview ${filename}`}
+        onClick={(event) => event.stopPropagation()}
+      >
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <span className="file-viewer-actions">
+            <a href={fileUrl} download={filename}>Download</a>
+            <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+              <img className="icon-invert" src={ICONS.close} alt="Close" />
+            </button>
+          </span>
+        </div>
+        <div className="file-viewer-content static-file-preview">
+          {failed && <div className="error-message">Unable to preview this file.</div>}
+          {!failed && previewKind === 'image' && (
+            <img
+              className="static-file-image"
+              src={fileUrl}
+              alt={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'video' && (
+            <video
+              className="static-file-video"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'audio' && (
+            <audio
+              className="static-file-audio"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'pdf' && (
+            <iframe
+              className="static-file-pdf"
+              src={fileUrl}
+              title={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 /**
  * Component: FileList
  */
@@ -338,7 +440,7 @@
     e.preventDefault();
     if (isDir) {
       onNavigate(item.abspath);
-    } else if (isCodeFile(item.basename)) {
+    } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) {
       onOpenFile(item.abspath);
     } else {
       window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
@@ -429,6 +531,7 @@
   const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(false);
   const [viewingFile, setViewingFile] = useState<string | null>(null);
+  const requestGeneration = useRef(0);
 
   // Sync with initialPath prop
   useEffect(() => {
@@ -436,8 +539,9 @@
   }, [initialPath]);
 
   useEffect(() => {
-    fetchDirectory(currentPath);
-    fetchReadme(currentPath);
+    const generation = ++requestGeneration.current;
+    fetchDirectory(currentPath, generation);
+    fetchReadme(currentPath, generation);
   }, [currentPath]);
 
   const navigate = useCallback((path: string) => {
@@ -445,7 +549,7 @@
     onPathChange?.(path);
   }, [onPathChange]);
 
-  const fetchDirectory = async (path: string) => {
+  const fetchDirectory = async (path: string, generation: number) => {
     setLoading(true);
     setError(null);
     try {
@@ -468,26 +572,30 @@
         throw new Error(data.error);
       }
 
-      setContent({
-        files: data?.files || [],
-        directories: data?.directories || []
-      });
+      if (generation === requestGeneration.current) {
+        setContent({
+          files: data?.files || [],
+          directories: data?.directories || []
+        });
+      }
     } catch (err: any) {
       console.error('Error loading directory:', err);
-      setError(err.message);
+      if (generation === requestGeneration.current) setError(err.message);
     } finally {
-      setLoading(false);
+      if (generation === requestGeneration.current) setLoading(false);
     }
   };
 
-  const fetchReadme = async (path: string) => {
-    setReadme(null);
-    const readmePath = path ? `${path}/README.md` : 'README.md';
+  const fetchReadme = async (path: string, generation: number) => {
+    if (generation === requestGeneration.current) setReadme(null);
     try {
-      const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
+      const url = path
+        ? `${API_BASE}/readme?path=${encodeURIComponent(path)}`
+        : `${API_BASE}/readme`;
+      const response = await fetch(url);
       if (response.ok) {
         const text = await response.text();
-        setReadme(text);
+        if (generation === requestGeneration.current) setReadme(text || null);
       }
     } catch (err) {
       // Readme is optional
@@ -528,6 +636,8 @@
       {viewingFile && (
         isMarkdownFile(viewingFile) ? (
           <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : getStaticPreviewKind(viewingFile) ? (
+          <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
         ) : (
           <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
         )