# HG changeset patch # User MrJuneJune # Date 1785703871 25200 # Node ID 3007ef5fc0ed8cc28f53f9c06e333750237960b8 # Parent 0e7b9464248d31d7f0ae41f3cd5608c883fcfce5 [hg-web] Simplify visuals and preview static files diff -r 0e7b9464248d -r 3007ef5fc0ed hg-web/README.md --- 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/ - -> 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 diff -r 0e7b9464248d -r 3007ef5fc0ed hg-web/e2e/app_e2e_test.js --- 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 ', '-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, diff -r 0e7b9464248d -r 3007ef5fc0ed hg-web/main.c --- 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); } diff -r 0e7b9464248d -r 3007ef5fc0ed hg-web/src/components/directory-browser.tsx --- 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>(); @@ -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 ( +
+
event.stopPropagation()} + > +
+ + + {filename} + + + Download + + +
+
+ {failed &&
Unable to preview this file.
} + {!failed && previewKind === 'image' && ( + {filename} setFailed(true)} + /> + )} + {!failed && previewKind === 'video' && ( +