changeset 224:3007ef5fc0ed hg-web

[hg-web] Simplify visuals and preview static files
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 13:51:11 -0700
parents 0e7b9464248d
children 70de0c80d093
files hg-web/README.md hg-web/e2e/app_e2e_test.js hg-web/main.c hg-web/src/components/directory-browser.tsx hg-web/src/components/graph.tsx hg-web/src/hg-web-background.jpg hg-web/src/index.css hg-web/src/pencil_texture.png
diffstat 8 files changed, 309 insertions(+), 10 deletions(-) [+]
line wrap: on
line diff
--- a/hg-web/README.md	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/README.md	Sun Aug 02 13:51:11 2026 -0700
@@ -38,7 +38,7 @@
 │       ├── index.html
 │       ├── index.css
 │       ├── base.css
-│       └── custom pencil, panda, icon, and background assets
+│       └── custom pencil, panda, and icon assets
 ├── Build
 │   ├── BUILD
 │   ├── ../gui_ze/gui_ze.bzl
@@ -100,9 +100,14 @@
   -> GET /api/repo/file?path=...
   -> ApiGetFile
   -> GET hg-serve/raw-file/tip/<path>
-  -> highlight.js or markdown_converter WASM
+  -> highlight.js, markdown_converter WASM, or inline static preview
 ```
 
+Images, SVG, video, audio, and PDF files open in the forge preview modal.
+Unknown binary files retain a download link. The raw file API supplies explicit
+MIME types, `nosniff`, inline disposition for supported previews, and sandboxed
+SVG responses.
+
 ### Commit graph
 
 ```text
--- a/hg-web/e2e/app_e2e_test.js	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/e2e/app_e2e_test.js	Sun Aug 02 13:51:11 2026 -0700
@@ -73,6 +73,17 @@
   );
   fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n');
   fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n');
+  fs.writeFileSync(
+    path.join(root, 'pixel.png'),
+    Buffer.from(
+      'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=',
+      'base64',
+    ),
+  );
+  fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex'));
+  fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3'));
+  fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n'));
+  fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3]));
   run('hg', ['--repository', root, 'add'], options);
   run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options);
   const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
@@ -178,6 +189,9 @@
       assert.equal(response.status, 200, `${pathname} static asset`);
       assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`);
     }
+    for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) {
+      assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404);
+    }
 
     const rootList = await assertJson('/api/repo/list');
     assert.ok(rootList.directories.some(entry => entry.basename === 'docs'));
@@ -190,6 +204,28 @@
     assert.equal(fileResponse.status, 200);
     assert.match(await fileResponse.text(), /int main/);
 
+    for (const [filename, contentType, disposition] of [
+      ['pixel.png', 'image/png', 'inline'],
+      ['movie.mp4', 'video/mp4', 'inline'],
+      ['sound.mp3', 'audio/mpeg', 'inline'],
+      ['document.pdf', 'application/pdf', 'inline'],
+      ['archive.bin', 'application/octet-stream', 'attachment'],
+    ]) {
+      const response = await fetch(
+        `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`,
+      );
+      assert.equal(response.status, 200, `${filename} response`);
+      assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`));
+      assert.equal(response.headers.get('content-disposition'), disposition);
+      assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
+      assert.ok((await response.arrayBuffer()).byteLength > 0);
+    }
+    const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`);
+    assert.deepEqual(
+      Buffer.from(await binaryResponse.arrayBuffer()),
+      Buffer.from([0, 1, 2, 3]),
+    );
+
     const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`);
     assert.equal(readmeResponse.status, 200);
     assert.match(await readmeResponse.text(), /Nested README/);
@@ -234,6 +270,12 @@
       await page.getByText('Recent Commits').waitFor();
       await page.getByText('Repository Files').waitFor();
       assert.equal(await page.evaluate(() => window.__hgWebXss), undefined);
+      assert.equal(
+        await page.locator('.graph-container').evaluate(
+          element => getComputedStyle(element).backgroundImage,
+        ),
+        'none',
+      );
       await page.locator('.theme-toggle').click();
     });
 
@@ -268,6 +310,21 @@
       assert.equal(await page.getByText('Documentation').count(), 0);
       assert.equal(delayedReadmeRequested, true);
 
+      await page.getByRole('link', { name: 'pixel.png' }).click();
+      const image = page.locator('.static-file-image');
+      await image.waitFor();
+      await image.evaluate(element => {
+        const imageElement = element;
+        if (imageElement.complete) return;
+        return new Promise((resolve, reject) => {
+          imageElement.addEventListener('load', resolve, { once: true });
+          imageElement.addEventListener('error', reject, { once: true });
+        });
+      });
+      assert.equal(await image.evaluate(element => element.naturalWidth), 1);
+      await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor();
+      await page.keyboard.press('Escape');
+
       await page.getByRole('link', { name: 'src' }).click();
       await page.getByRole('link', { name: 'main.c' }).click();
       await page.getByText('int main(void)').waitFor();
@@ -298,6 +355,12 @@
       await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor();
       await page.getByText('added', { exact: true }).waitFor();
       await page.getByRole('region', { name: 'Changeset diff' }).waitFor();
+      assert.equal(
+        await page.locator('.changeset-paper').evaluate(
+          element => getComputedStyle(element).backgroundImage,
+        ),
+        'none',
+      );
       await page.getByRole('button', { name: firstNode.slice(0, 12) }).click();
       await page.waitForFunction(
         expectedPath => window.location.pathname === expectedPath,
--- a/hg-web/main.c	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/main.c	Sun Aug 02 13:51:11 2026 -0700
@@ -198,6 +198,77 @@
          strchr(value, '\n') == NULL;
 }
 
+static boolean extension_is(const char *extension, const char *expected)
+{
+  return extension && strcasecmp(extension, expected) == 0;
+}
+
+static const char *repository_file_content_type(
+    const char *path,
+    boolean *inline_preview,
+    boolean *sandbox_content)
+{
+  const char *extension = strrchr(path, '.');
+  *inline_preview = TRUE;
+  *sandbox_content = FALSE;
+
+  if (extension_is(extension, ".png")) return "image/png";
+  if (extension_is(extension, ".jpg") ||
+      extension_is(extension, ".jpeg")) return "image/jpeg";
+  if (extension_is(extension, ".gif")) return "image/gif";
+  if (extension_is(extension, ".webp")) return "image/webp";
+  if (extension_is(extension, ".avif")) return "image/avif";
+  if (extension_is(extension, ".bmp")) return "image/bmp";
+  if (extension_is(extension, ".ico")) return "image/x-icon";
+  if (extension_is(extension, ".svg"))
+  {
+    *sandbox_content = TRUE;
+    return "image/svg+xml";
+  }
+  if (extension_is(extension, ".mp4") ||
+      extension_is(extension, ".m4v")) return "video/mp4";
+  if (extension_is(extension, ".webm")) return "video/webm";
+  if (extension_is(extension, ".mov")) return "video/quicktime";
+  if (extension_is(extension, ".ogv")) return "video/ogg";
+  if (extension_is(extension, ".mp3")) return "audio/mpeg";
+  if (extension_is(extension, ".wav")) return "audio/wav";
+  if (extension_is(extension, ".ogg") ||
+      extension_is(extension, ".oga")) return "audio/ogg";
+  if (extension_is(extension, ".flac")) return "audio/flac";
+  if (extension_is(extension, ".m4a")) return "audio/mp4";
+  if (extension_is(extension, ".aac")) return "audio/aac";
+  if (extension_is(extension, ".pdf")) return "application/pdf";
+  if (extension_is(extension, ".wasm")) return "application/wasm";
+
+  *inline_preview = FALSE;
+  if (extension_is(extension, ".md") ||
+      extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8";
+  if (extension_is(extension, ".txt") ||
+      extension_is(extension, ".log") ||
+      extension_is(extension, ".c") ||
+      extension_is(extension, ".h") ||
+      extension_is(extension, ".cc") ||
+      extension_is(extension, ".cpp") ||
+      extension_is(extension, ".js") ||
+      extension_is(extension, ".jsx") ||
+      extension_is(extension, ".ts") ||
+      extension_is(extension, ".tsx") ||
+      extension_is(extension, ".css") ||
+      extension_is(extension, ".html") ||
+      extension_is(extension, ".htm") ||
+      extension_is(extension, ".xml") ||
+      extension_is(extension, ".json") ||
+      extension_is(extension, ".yaml") ||
+      extension_is(extension, ".yml") ||
+      extension_is(extension, ".toml") ||
+      extension_is(extension, ".sh") ||
+      extension_is(extension, ".py") ||
+      extension_is(extension, ".rs") ||
+      extension_is(extension, ".go"))
+    return "text/plain; charset=utf-8";
+  return "application/octet-stream";
+}
+
 static Seobeo_Client_Response *hg_proxy_request(
     const char *method,
     const char *path,
@@ -243,6 +314,7 @@
 static Seobeo_Request_Entry *forward_hg_response(
     Seobeo_Client_Response *hg_response,
     const char *default_content_type,
+    const char *override_content_type,
     Dowa_Arena *arena)
 {
   if (!hg_response)
@@ -252,7 +324,9 @@
   const char *upstream_content_type =
       map_value_case_insensitive(hg_response->headers, "Content-Type");
   const char *upstream_or_default_content_type =
-      upstream_content_type ? upstream_content_type : default_content_type;
+      override_content_type
+          ? override_content_type
+          : upstream_content_type ? upstream_content_type : default_content_type;
   if (!upstream_or_default_content_type)
     upstream_or_default_content_type = "application/octet-stream";
   char *content_type = arena_string(arena, upstream_or_default_content_type);
@@ -297,6 +371,7 @@
   return forward_hg_response(
       hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
       "application/json",
+      NULL,
       arena);
 }
 
@@ -314,10 +389,28 @@
   if (length < 0 || (size_t)length >= sizeof(hg_path))
     return text_response(arena, "400", "text/plain", "File path is too long");
 
-  return forward_hg_response(
-      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"),
-      "application/octet-stream",
+  Seobeo_Client_Response *hg_response =
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream");
+  if (!hg_response)
+    return forward_hg_response(NULL, "application/json", NULL, arena);
+
+  boolean inline_preview = FALSE;
+  boolean sandbox_content = FALSE;
+  const char *content_type =
+      repository_file_content_type(path, &inline_preview, &sandbox_content);
+  Seobeo_Request_Entry *response = forward_hg_response(
+      hg_response, "application/octet-stream", content_type, arena);
+  Dowa_HashMap_Push_Arena(
+      response,
+      "Content-Disposition",
+      inline_preview ? "inline" : "attachment",
       arena);
+  Dowa_HashMap_Push_Arena(
+      response, "X-Content-Type-Options", "nosniff", arena);
+  if (sandbox_content)
+    Dowa_HashMap_Push_Arena(
+        response, "Content-Security-Policy", "sandbox", arena);
+  return response;
 }
 
 Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena)
@@ -351,7 +444,8 @@
     Seobeo_Client_Response_Destroy(hg_response);
     return text_response(arena, "204", "text/markdown", "");
   }
-  return forward_hg_response(hg_response, "text/markdown", arena);
+  return forward_hg_response(
+      hg_response, "text/markdown", "text/markdown; charset=utf-8", arena);
 }
 
 Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
@@ -385,6 +479,7 @@
   return forward_hg_response(
       hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
       "application/json",
+      NULL,
       arena);
 }
 
@@ -402,6 +497,7 @@
   return forward_hg_response(
       hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
       "application/json",
+      NULL,
       arena);
 }
 
--- a/hg-web/src/components/directory-browser.tsx	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 13:51:11 2026 -0700
@@ -22,6 +22,14 @@
   'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
 ]);
 
+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>>();
 
@@ -40,6 +48,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;
@@ -276,6 +293,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 +430,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');
@@ -534,6 +626,8 @@
       {viewingFile && (
         isMarkdownFile(viewingFile) ? (
           <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : getStaticPreviewKind(viewingFile) ? (
+          <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
         ) : (
           <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
         )
--- a/hg-web/src/components/graph.tsx	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/src/components/graph.tsx	Sun Aug 02 13:51:11 2026 -0700
@@ -273,7 +273,7 @@
   }, [onLoadMore, hasMore, loading]);
 
   return (
-    <div className="graph-container" style={{ backgroundImage: 'url("/hg-web-background.jpg")' }}>
+    <div className="graph-container">
       {assetError && <div className="error-message">{assetError}</div>}
       <div 
         ref={containerRef} 
Binary file hg-web/src/hg-web-background.jpg has changed
--- a/hg-web/src/index.css	Sun Aug 02 10:07:40 2026 -0700
+++ b/hg-web/src/index.css	Sun Aug 02 13:51:11 2026 -0700
@@ -382,7 +382,6 @@
    =========================================== */
 .changeset-paper {
   background: var(--bg);
-  background-image: url("/pencil_texture.png");
   border: 1px solid var(--border);
   border-radius: 6px;
   padding: 24px;
@@ -755,11 +754,53 @@
   opacity: 0.7;
 }
 
+.file-viewer-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.file-viewer-actions a {
+  font-size: 13px;
+}
+
 .file-viewer-content {
   overflow: auto;
   flex: 1;
 }
 
+.static-file-viewer {
+  max-width: 1100px;
+}
+
+.static-file-preview {
+  min-height: 240px;
+  padding: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-subtle);
+}
+
+.static-file-image,
+.static-file-video {
+  display: block;
+  max-width: 100%;
+  max-height: 75vh;
+  object-fit: contain;
+}
+
+.static-file-audio {
+  width: min(100%, 640px);
+}
+
+.static-file-pdf {
+  width: 100%;
+  height: 75vh;
+  border: 0;
+  background: #fff;
+}
+
 .file-viewer-content pre {
   margin: 0;
   padding: 16px;
Binary file hg-web/src/pencil_texture.png has changed