changeset 223:0e7b9464248d hg-web

[hg-web] Add browser route regression coverage
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 10:07:40 -0700
parents a8d6435dc021
children 3007ef5fc0ed
files MODULE.bazel MODULE.bazel.lock hg-web/BUILD hg-web/README.md hg-web/e2e/BUILD hg-web/e2e/app_e2e_test.js hg-web/e2e/package-lock.json hg-web/e2e/package.json hg-web/e2e/pnpm-lock.yaml hg-web/main.c hg-web/src/components/app.tsx hg-web/src/components/directory-browser.tsx hg-web/src/index.css seobeo/s_network.c seobeo/s_web.c seobeo/seobeo.h seobeo/tests/BUILD seobeo/tests/seobeo_response_test.c seobeo/tests/seobeo_sigpipe_test.c
diffstat 19 files changed, 792 insertions(+), 47 deletions(-) [+]
line wrap: on
line diff
--- a/MODULE.bazel	Sun Aug 02 09:07:36 2026 -0700
+++ b/MODULE.bazel	Sun Aug 02 10:07:40 2026 -0700
@@ -2,6 +2,8 @@
 bazel_dep(name = "platforms", version = "1.0.0")
 bazel_dep(name = "bazel_skylib", version = "1.8.2")
 bazel_dep(name = "rules_shell", version = "0.6.1")
+bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True)
+bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True)
 bazel_dep(name = "openssl", version = "3.3.1.bcr.7")
 bazel_dep(name = "rules_foreign_cc", version = "0.14.0")
 bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True)
@@ -13,6 +15,36 @@
 )
 
 http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
+http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+  name = "playwright_chromium_linux",
+  urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"],
+  sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5",
+  strip_prefix = "chrome-linux",
+  build_file_content = """
+exports_files(["chrome"])
+
+filegroup(
+    name = "chromium",
+    srcs = glob(["**"], exclude = ["BUILD.bazel"]),
+    visibility = ["//visibility:public"],
+)
+""",
+)
+
+node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
+node.toolchain(node_version = "20.18.0")
+use_repo(node, "nodejs")
+
+npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
+npm.npm_translate_lock(
+  name = "hg_web_npm",
+  data = ["//hg-web/e2e:package.json"],
+  npm_package_lock = "//hg-web/e2e:package-lock.json",
+  pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml",
+)
+use_repo(npm, "hg_web_npm")
 
 # Bun
 http_file(
--- a/MODULE.bazel.lock	Sun Aug 02 09:07:36 2026 -0700
+++ b/MODULE.bazel.lock	Sun Aug 02 10:07:40 2026 -0700
@@ -1,5 +1,5 @@
 {
-  "lockFileVersion": 26,
+  "lockFileVersion": 28,
   "registryFileHashes": {
     "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
     "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
@@ -43,8 +43,9 @@
     "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
     "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
     "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d",
-    "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93",
     "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
+    "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
+    "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309",
     "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
     "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
     "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
@@ -65,8 +66,8 @@
     "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
     "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/MODULE.bazel": "d18b017dddf219626ea5b04028ff0db2397655fbdaa9d9258a6443ddafb303ab",
     "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/source.json": "5fb3a2433f6508f4b23934eaa2986d604ae65aff95d7476d1273bf5cd434eedc",
-    "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d",
-    "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd",
     "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
     "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
     "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
@@ -135,7 +136,8 @@
     "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37",
     "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
     "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4",
-    "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07",
     "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc",
     "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2",
@@ -153,8 +155,8 @@
     "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
     "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
     "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2",
-    "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea",
-    "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e",
+    "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d",
+    "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6",
     "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
@@ -228,9 +230,105 @@
   },
   "selectedYankedVersions": {},
   "moduleExtensions": {
+    "@@aspect_rules_js+//npm:extensions.bzl%pnpm": {
+      "general": {
+        "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=",
+        "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=",
+        "recordedInputs": [
+          "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools",
+          "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report",
+          "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools",
+          "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
+          "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version",
+          "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools",
+          "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+",
+          "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+"
+        ],
+        "generatedRepoSpecs": {
+          "pnpm": {
+            "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule",
+            "attributes": {
+              "package": "pnpm",
+              "version": "8.15.9",
+              "root_package": "",
+              "link_workspace": "",
+              "link_packages": {},
+              "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==",
+              "url": "",
+              "commit": "",
+              "patch_args": [
+                "-p0"
+              ],
+              "patches": [],
+              "custom_postinstall": "",
+              "npm_auth": "",
+              "npm_auth_basic": "",
+              "npm_auth_username": "",
+              "npm_auth_password": "",
+              "lifecycle_hooks": [],
+              "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
+              "extract_full_archive": true,
+              "exclude_package_contents": []
+            }
+          },
+          "pnpm__links": {
+            "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links",
+            "attributes": {
+              "package": "pnpm",
+              "version": "8.15.9",
+              "dev": false,
+              "root_package": "",
+              "link_packages": {},
+              "deps": {},
+              "transitive_closure": {},
+              "lifecycle_build_target": false,
+              "lifecycle_hooks_env": [],
+              "lifecycle_hooks_execution_requirements": [
+                "no-sandbox"
+              ],
+              "lifecycle_hooks_use_default_shell_env": false,
+              "bins": {},
+              "package_visibility": [
+                "//visibility:public"
+              ],
+              "exclude_package_contents": []
+            }
+          }
+        }
+      }
+    },
+    "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
+      "general": {
+        "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=",
+        "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=",
+        "recordedInputs": [
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+",
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+"
+        ],
+        "generatedRepoSpecs": {
+          "aspect_tools_telemetry_report": {
+            "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository",
+            "attributes": {
+              "deps": {
+                "aspect_rules_js": "2.9.2",
+                "aspect_tools_telemetry": "0.3.3"
+              }
+            }
+          }
+        }
+      }
+    },
     "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": {
       "general": {
-        "bzlTransitiveDigest": "27R0vlu7+T5uJVRkrJWwjP+5K4Pi5H1nJbI3JBLJyKI=",
+        "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=",
         "usagesDigest": "BWWW2nG29mUymMGpRgfP+nxndKWaf1fCZZ33EDPaVIc=",
         "recordedInputs": [
           "REPO_MAPPING:buildifier_prebuilt+,bazel_skylib bazel_skylib+",
@@ -374,7 +472,7 @@
     },
     "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": {
       "general": {
-        "bzlTransitiveDigest": "QDGe6PSXCStGFYJd3Yj2kvRMiXLid2u8mNHBRjJoOEw=",
+        "bzlTransitiveDigest": "+1O9FmrwgJwK/ywCKhwouNuu7NWC/hfkkzjuGp/1c9Q=",
         "usagesDigest": "f1E1WkCSGt50uwBJp/heJk2R/iAm39ixgGS93wpdywA=",
         "recordedInputs": [
           "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
@@ -444,9 +542,31 @@
         }
       }
     },
+    "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": {
+      "general": {
+        "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=",
+        "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=",
+        "recordedInputs": [
+          "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools",
+          "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
+        ],
+        "generatedRepoSpecs": {
+          "pybind11": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel",
+              "strip_prefix": "pybind11-2.12.0",
+              "urls": [
+                "https://github.com/pybind/pybind11/archive/v2.12.0.zip"
+              ]
+            }
+          }
+        }
+      }
+    },
     "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": {
       "general": {
-        "bzlTransitiveDigest": "dWWiLQWKST2+NU4SkbVS2OhS6KgYdZ4jBI5MBsDEBt0=",
+        "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=",
         "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=",
         "recordedInputs": [
           "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools",
@@ -819,7 +939,7 @@
     },
     "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
       "general": {
-        "bzlTransitiveDigest": "ABI1D/sbS1ovwaW/kHDoj8nnXjQ0oKU9fzmzEG4iT8o=",
+        "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=",
         "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=",
         "recordedInputs": [
           "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools"
@@ -877,7 +997,7 @@
     "@@rules_nodejs+//nodejs:extensions.bzl%node": {
       "general": {
         "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=",
-        "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=",
+        "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=",
         "recordedInputs": [],
         "generatedRepoSpecs": {
           "nodejs_linux_amd64": {
@@ -1007,7 +1127,7 @@
     },
     "@@rules_python+//python/extensions:config.bzl%config": {
       "general": {
-        "bzlTransitiveDigest": "2hLgIvNVTLgxus0ZuXtleBe70intCfo0cHs8qvt6cdM=",
+        "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=",
         "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=",
         "recordedInputs": [
           "REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
@@ -1275,5 +1395,6 @@
       }
     }
   },
-  "facts": {}
+  "facts": {},
+  "factsVersions": {}
 }
--- a/hg-web/BUILD	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/BUILD	Sun Aug 02 10:07:40 2026 -0700
@@ -59,6 +59,7 @@
   srcs = ["main.c"],
   deps = ["//seobeo:seobeo"],
   data = [":all_assets"],
+  visibility = ["//hg-web:__subpackages__"],
 )
 
 cc_binary(
@@ -77,6 +78,11 @@
   ],
 )
 
+test_suite(
+  name = "tests",
+  tests = ["//hg-web/e2e:app_e2e_test"],
+)
+
 bundle(
   name = "hg_web_server_bundle",
   binary = ":hg_web_server",
--- a/hg-web/README.md	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/README.md	Sun Aug 02 10:07:40 2026 -0700
@@ -169,10 +169,18 @@
 ```bash
 bazel build //hg-web:hg_web_server
 bazel build //hg-web:hg_web_server_bundle
+bazel test //hg-web:tests
 bazel test //markdown_converter/tests:markdown_to_html_test
 bazel test //seobeo/tests:all
 ```
 
+`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs
+the application in pinned Chromium through Playwright. It covers shell routes,
+files and README rendering, graph and changeset navigation, browser history,
+console and page errors, API validation, Mercurial wire protocol, Markdown
+script escaping, static assets, and backend outages. The test invokes the
+workspace Mercurial CLI with an isolated configuration and home directory.
+
 Build and run the complete local stack with one Bazel command:
 
 ```bash
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/BUILD	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,31 @@
+load("@aspect_rules_js//js:defs.bzl", "js_test")
+load("@hg_web_npm//:defs.bzl", "npm_link_all_packages")
+
+exports_files([
+    "package-lock.json",
+    "package.json",
+    "pnpm-lock.yaml",
+])
+
+npm_link_all_packages(name = "node_modules")
+
+js_test(
+    name = "app_e2e_test",
+    entry_point = "app_e2e_test.js",
+    data = [
+        ":node_modules/playwright-core",
+        "//hg-web:hg_web_server",
+        "@playwright_chromium_linux//:chromium",
+        "@playwright_chromium_linux//:chrome",
+    ],
+    env = {
+        "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)",
+    },
+    no_copy_to_bin = ["@playwright_chromium_linux//:chromium"],
+    size = "large",
+    target_compatible_with = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+    timeout = "long",
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/app_e2e_test.js	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,332 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { spawn, spawnSync } = require('node:child_process');
+const { chromium } = require('playwright-core');
+
+const BASE_URL = 'http://127.0.0.1:6970';
+const RUNFILES = process.env.JS_BINARY__RUNFILES;
+const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
+
+function run(command, args, options = {}) {
+  const result = spawnSync(command, args, {
+    encoding: 'utf8',
+    ...options,
+  });
+  if (result.status !== 0) {
+    throw new Error(
+      `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`,
+    );
+  }
+  return result.stdout.trim();
+}
+
+function mercurialEnvironment(home) {
+  return {
+    ...process.env,
+    HGPLAIN: '1',
+    HGRCPATH: '',
+    HOME: home,
+  };
+}
+
+function stopProcess(child) {
+  if (!child || child.exitCode !== null) return Promise.resolve();
+  child.kill('SIGTERM');
+  return new Promise(resolve => {
+    const timer = setTimeout(() => {
+      if (child.exitCode === null) child.kill('SIGKILL');
+    }, 3000);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve();
+    });
+  });
+}
+
+async function waitForHttp(url, child, logs) {
+  const deadline = Date.now() + 15000;
+  while (Date.now() < deadline) {
+    if (child.exitCode !== null) {
+      throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`);
+    }
+    try {
+      const response = await fetch(url);
+      if (response.ok) return;
+    } catch {
+      // Keep waiting for startup.
+    }
+    await new Promise(resolve => setTimeout(resolve, 100));
+  }
+  throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`);
+}
+
+function createFixtureRepository(root) {
+  const options = { env: mercurialEnvironment(root) };
+  run('hg', ['init', root], options);
+  fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
+  fs.mkdirSync(path.join(root, 'src'), { recursive: true });
+  fs.writeFileSync(
+    path.join(root, 'README.md'),
+    '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n',
+  );
+  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');
+  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);
+
+  fs.appendFileSync(path.join(root, 'README.md'), '\nSecond revision.\n');
+  fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n');
+  run('hg', ['add', 'new-file.txt'], { ...options, cwd: root });
+  run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options);
+  const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
+  return { firstNode, tipNode };
+}
+
+async function assertJson(pathname, status = 200) {
+  const response = await fetch(`${BASE_URL}${pathname}`);
+  const body = await response.text();
+  assert.equal(response.status, status, `${pathname} status: ${body}`);
+  return JSON.parse(body);
+}
+
+async function assertPageHasNoBrowserErrors(browser, pathname, assertion) {
+  const page = await browser.newPage();
+  const errors = [];
+  page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`));
+  page.on('console', message => {
+    if (message.type() === 'error') errors.push(`console: ${message.text()}`);
+  });
+  page.on('requestfailed', request => {
+    errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+  });
+  page.on('response', response => {
+    if (response.status() >= 400) {
+      errors.push(`response: ${response.status()} ${response.url()}`);
+    }
+  });
+
+  const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' });
+  assert.equal(response.status(), 200, `${pathname} document status`);
+  await assertion(page);
+  if (errors.length > 0) {
+    throw new Error(`${pathname} browser errors\n${errors.join('\n')}`);
+  }
+  await page.close();
+}
+
+async function main() {
+  assert.ok(RUNFILES, 'rules_js runfiles path is required');
+  assert.ok(WORKSPACE, 'rules_js workspace name is required');
+
+  const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-'));
+  const appLogs = [];
+  const hgLogs = [];
+  let hgServer;
+  let appServer;
+  let browser;
+
+  try {
+    const { firstNode, tipNode } = createFixtureRepository(fixtureRoot);
+    const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
+    const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server');
+    const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
+
+    assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`);
+    assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`);
+
+    hgServer = spawn(
+      'hg',
+      [
+        '--repository', fixtureRoot,
+        'serve',
+        '--address', '127.0.0.1',
+        '--port', '4444',
+        '--accesslog', '-',
+        '--errorlog', '-',
+      ],
+      {
+        env: mercurialEnvironment(fixtureRoot),
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    );
+    hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString()));
+    hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString()));
+
+    appServer = spawn(serverBinary, [], {
+      cwd: runfilesWorkspace,
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    appServer.stdout.on('data', chunk => appLogs.push(chunk.toString()));
+    appServer.stderr.on('data', chunk => appLogs.push(chunk.toString()));
+
+    await waitForHttp(`${BASE_URL}/`, appServer, appLogs);
+    if (hgServer.exitCode !== null) {
+      throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`);
+    }
+    await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs);
+
+    for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 200, `${pathname} shell route`);
+      assert.match(await response.text(), /<title>Zenbu Repository<\/title>/);
+    }
+    for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 200, `${pathname} static asset`);
+      assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`);
+    }
+
+    const rootList = await assertJson('/api/repo/list');
+    assert.ok(rootList.directories.some(entry => entry.basename === 'docs'));
+    assert.ok(rootList.files.some(entry => entry.basename === 'README.md'));
+
+    const nestedList = await assertJson('/api/repo/list?path=docs');
+    assert.ok(nestedList.files.some(entry => entry.basename === 'README.md'));
+
+    const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`);
+    assert.equal(fileResponse.status, 200);
+    assert.match(await fileResponse.text(), /int main/);
+
+    const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`);
+    assert.equal(readmeResponse.status, 200);
+    assert.match(await readmeResponse.text(), /Nested README/);
+    assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204);
+
+    const graph = await assertJson('/api/graph/tip?style=json');
+    assert.equal(graph.node, tipNode);
+    assert.ok(graph.changesets.length >= 2);
+
+    const changeset = await assertJson(`/api/changeset/${tipNode}`);
+    assert.equal(changeset.node, tipNode);
+    assert.equal(changeset.desc, 'Update fixture');
+    assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added'));
+    assert.ok(changeset.diff.length > 0);
+
+    for (const pathname of [
+      '/api/repo/list?path=..%2Fetc',
+      '/api/repo/file?path=..%2FREADME.md',
+      '/api/graph/not-a-node?style=json',
+      '/api/changeset/not-a-node',
+    ]) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 400, `${pathname} rejects invalid input`);
+    }
+    assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404);
+
+    const identify = run(
+      'hg',
+      ['identify', `${BASE_URL}/repo`],
+      { env: mercurialEnvironment(fixtureRoot) },
+    );
+    assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`));
+
+    browser = await chromium.launch({
+      executablePath: chromiumPath,
+      headless: true,
+      args: ['--no-sandbox'],
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/', async page => {
+      await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor();
+      await page.getByText('Recent Commits').waitFor();
+      await page.getByText('Repository Files').waitFor();
+      assert.equal(await page.evaluate(() => window.__hgWebXss), undefined);
+      await page.locator('.theme-toggle').click();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/directory', async page => {
+      await page.getByText('Repository Files').waitFor();
+      await page.getByRole('link', { name: 'README.md' }).first().click();
+      await page.getByText('Fixture Repository').waitFor();
+      assert.equal(await page.evaluate(() => window.__hgWebXss), undefined);
+      await page.keyboard.press('Escape');
+
+      let delayedReadmeRequested = false;
+      let markReadmeRequested;
+      const readmeRequested = new Promise(resolve => {
+        markReadmeRequested = resolve;
+      });
+      await page.route(/\/api\/repo\/readme\?path=/, async route => {
+        delayedReadmeRequested = true;
+        markReadmeRequested();
+        await new Promise(resolve => setTimeout(resolve, 500));
+        await route.continue();
+      });
+      await page.getByRole('link', { name: 'docs' }).click();
+      await Promise.race([
+        readmeRequested,
+        new Promise((_, reject) => {
+          setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000);
+        }),
+      ]);
+      await page.getByRole('link', { name: 'root' }).click();
+      await page.getByText('Fixture Repository').waitFor();
+      await page.waitForTimeout(600);
+      assert.equal(await page.getByText('Documentation').count(), 0);
+      assert.equal(delayedReadmeRequested, true);
+
+      await page.getByRole('link', { name: 'src' }).click();
+      await page.getByRole('link', { name: 'main.c' }).click();
+      await page.getByText('int main(void)').waitFor();
+      await page.keyboard.press('Escape');
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => {
+      await page.getByText('Documentation').waitFor();
+      await page.getByRole('link', { name: 'README.md' }).click();
+      await page.getByText('Nested README.').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/graph', async page => {
+      await page.getByText('Commit Graph').waitFor();
+      await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip'));
+      const graphUrl = page.url();
+      const rows = page.locator('.graph-row');
+      await rows.first().click();
+      await page.waitForURL(/\/changeset\/[0-9a-f]+$/);
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.waitForFunction(expected => window.location.href === expected, graphUrl);
+      await page.getByText('Commit Graph').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => {
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      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();
+      await page.getByRole('button', { name: firstNode.slice(0, 12) }).click();
+      await page.waitForFunction(
+        expectedPath => window.location.pathname === expectedPath,
+        `/changeset/${firstNode}`,
+      );
+      await page.getByRole('heading', { name: 'Initial fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.getByText('Commit Graph').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => {
+      await page.getByText('Recent Commits').waitFor();
+    });
+
+    await stopProcess(hgServer);
+    const unavailable = await fetch(`${BASE_URL}/api/repo/list`);
+    assert.equal(unavailable.status, 502);
+    assert.equal((await fetch(`${BASE_URL}/`)).status, 200);
+  } finally {
+    if (browser) await browser.close();
+    await stopProcess(appServer);
+    await stopProcess(hgServer);
+    fs.rmSync(fixtureRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  console.error(error.stack || error);
+  process.exitCode = 1;
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/package-lock.json	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,24 @@
+{
+  "name": "e2e",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "playwright-core": "1.48.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.48.2",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
+      "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/package.json	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,6 @@
+{
+  "private": true,
+  "dependencies": {
+    "playwright-core": "1.48.2"
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/pnpm-lock.yaml	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,18 @@
+lockfileVersion: '6.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+dependencies:
+  playwright-core:
+    specifier: 1.48.2
+    version: 1.48.2
+
+packages:
+
+  /[email protected]:
+    resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==}
+    engines: {node: '>=18'}
+    hasBin: true
+    dev: false
--- a/hg-web/main.c	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/main.c	Sun Aug 02 10:07:40 2026 -0700
@@ -344,10 +344,14 @@
   if (length < 0 || (size_t)length >= sizeof(hg_path))
     return text_response(arena, "400", "text/plain", "README path is too long");
 
-  return forward_hg_response(
-      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"),
-      "text/markdown",
-      arena);
+  Seobeo_Client_Response *hg_response =
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown");
+  if (hg_response && hg_response->status_code == HTTP_NOT_FOUND)
+  {
+    Seobeo_Client_Response_Destroy(hg_response);
+    return text_response(arena, "204", "text/markdown", "");
+  }
+  return forward_hg_response(hg_response, "text/markdown", arena);
 }
 
 Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
--- a/hg-web/src/components/app.tsx	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 10:07:40 2026 -0700
@@ -13,6 +13,7 @@
   graphTip?: string;
   dirPath?: string;
   changesetId?: string;
+  returnDepth?: number;
 }
 
 type ChangesetDetail = {
@@ -24,7 +25,10 @@
   tags: string[];
   user: string;
   parents: string[];
-  files: string[];
+  files: Array<{
+    file: string;
+    status: string;
+  }>;
   diff: Array<{
     blockno: number;
     lines: Array<{ t: string; n: number; l: string }>;
@@ -106,6 +110,13 @@
   }
 }
 
+function isRouteState(value: unknown): value is RouteState {
+  if (!value || typeof value !== 'object' || !('page' in value)) return false;
+  return ['landing', 'graph', 'directory', 'changeset'].includes(
+    String((value as { page: unknown }).page),
+  );
+}
+
 // Landing Page Component
 function LandingPage({
   onNavigateToGraph,
@@ -242,7 +253,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Commit Graph</span>
@@ -320,8 +331,8 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
-          &larr; Back to graph
+        <button className="back-button" onClick={onBack} aria-label="Back">
+          &larr; Back
         </button>
         <span className="page-title">Changeset</span>
       </div>
@@ -364,7 +375,14 @@
           {changeset.files.length > 0 && (
             <div className="changeset-files">
               <strong>Files</strong>
-              {changeset.files.map(file => <code key={file}>{file}</code>)}
+              {changeset.files.map(file => (
+                <code key={file.file}>
+                  <span className={`changeset-file-status status-${file.status}`}>
+                    {file.status}
+                  </span>
+                  {file.file}
+                </code>
+              ))}
             </div>
           )}
 
@@ -409,7 +427,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Repository Files</span>
@@ -430,8 +448,8 @@
 
   // Handle browser back/forward
   useEffect(() => {
-    const handlePopState = () => {
-      setRoute(parseRoute());
+    const handlePopState = (event: PopStateEvent) => {
+      setRoute(isRouteState(event.state) ? event.state : parseRoute());
     };
     window.addEventListener('popstate', handlePopState);
     return () => window.removeEventListener('popstate', handlePopState);
@@ -456,8 +474,19 @@
   }, [navigate]);
 
   const navigateToChangeset = useCallback((changesetId: string) => {
-    navigate({ page: 'changeset', changesetId });
-  }, [navigate]);
+    const returnDepth = route.page === 'changeset'
+      ? (route.returnDepth ?? 0) + 1
+      : 1;
+    navigate({ page: 'changeset', changesetId, returnDepth });
+  }, [navigate, route.page, route.returnDepth]);
+
+  const navigateBackFromChangeset = useCallback(() => {
+    if (route.returnDepth !== undefined && route.returnDepth > 0) {
+      window.history.go(-route.returnDepth);
+      return;
+    }
+    navigateToGraph();
+  }, [navigateToGraph, route.returnDepth]);
 
   const handleDirectoryPathChange = useCallback((path: string) => {
     // Update URL without full navigation
@@ -530,7 +559,7 @@
       {route.page === 'changeset' && route.changesetId && (
         <ChangesetPage
           changesetId={route.changesetId}
-          onBack={() => navigateToGraph()}
+          onBack={navigateBackFromChangeset}
           onOpenChangeset={navigateToChangeset}
         />
       )}
--- a/hg-web/src/components/directory-browser.tsx	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 10:07:40 2026 -0700
@@ -429,6 +429,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 +437,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 +447,7 @@
     onPathChange?.(path);
   }, [onPathChange]);
 
-  const fetchDirectory = async (path: string) => {
+  const fetchDirectory = async (path: string, generation: number) => {
     setLoading(true);
     setError(null);
     try {
@@ -468,26 +470,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
--- a/hg-web/src/index.css	Sun Aug 02 09:07:36 2026 -0700
+++ b/hg-web/src/index.css	Sun Aug 02 10:07:40 2026 -0700
@@ -451,9 +451,26 @@
 }
 
 .changeset-files code {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
   overflow-wrap: anywhere;
 }
 
+.changeset-file-status {
+  color: var(--text-secondary);
+  font-size: 10px;
+  text-transform: uppercase;
+}
+
+.changeset-file-status.status-added {
+  color: var(--success);
+}
+
+.changeset-file-status.status-removed {
+  color: var(--danger);
+}
+
 .changeset-diff {
   margin-top: 24px;
 }
--- a/seobeo/s_network.c	Sun Aug 02 09:07:36 2026 -0700
+++ b/seobeo/s_network.c	Sun Aug 02 10:07:40 2026 -0700
@@ -1,8 +1,37 @@
 #include "seobeo/seobeo.h"
 
+static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT;
+
+static void Seobeo_Process_Ignore_Sigpipe(void)
+{
+  signal(SIGPIPE, SIG_IGN);
+}
+
+static void Seobeo_Socket_Disable_Sigpipe(int socket_fd)
+{
+#ifdef SO_NOSIGPIPE
+  int enabled = 1;
+  setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
+#else
+  (void)socket_fd;
+#endif
+}
+
+static ssize_t Seobeo_Socket_Write(
+    int socket_fd,
+    const void *buffer,
+    size_t length)
+{
+#ifdef MSG_NOSIGNAL
+  return send(socket_fd, buffer, length, MSG_NOSIGNAL);
+#else
+  return send(socket_fd, buffer, length, 0);
+#endif
+}
 
 Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host,  const char* port)
 {
+  pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe);
   Seobeo_Handle *p_handle;
   struct addrinfo hints, *server_infos, *free_server_info;
   int32 socket_fd, yes = 1;  // Need this for setsockopt 
@@ -26,6 +55,7 @@
     if((socket_fd = socket(free_server_info->ai_family,
                         free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1)
     { perror("socket"); continue; }
+    Seobeo_Socket_Disable_Sigpipe(socket_fd);
 
      if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1)
      { perror("setsockopt SO_REUSEADDR"); continue; }
@@ -81,6 +111,7 @@
 
 Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host,  const char* port, boolean use_tls)
 {
+  pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe);
   Seobeo_Handle *p_handle;
   p_handle = malloc(sizeof(*p_handle));
 
@@ -98,6 +129,7 @@
   if((socket_fd = socket(server_infos->ai_family,
                       server_infos->ai_socktype, server_infos->ai_protocol)) == -1)
   { perror("socket"); return NULL; }
+  Seobeo_Socket_Disable_Sigpipe(socket_fd);
 
   if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0)
   { perror("connect"); return NULL; }
@@ -154,6 +186,7 @@
       Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr),
       client_inet_addr, sizeof client_inet_addr);
   if (client_fd == -1) return NULL;
+  Seobeo_Socket_Disable_Sigpipe(client_fd);
 
   // Set non blocking...
   int flags = fcntl(client_fd, F_GETFL, 0);
@@ -236,7 +269,7 @@
     }else
     {
       Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket);
-      ssize_t n = write(
+      ssize_t n = Seobeo_Socket_Write(
         p_handle->socket,
         p_handle->write_buffer + sent,
         total - sent
@@ -275,9 +308,10 @@
       }
       else
       {
-        n = write(p_handle->socket,
-                  data + offset,
-                  data_size - offset);
+        n = Seobeo_Socket_Write(
+            p_handle->socket,
+            data + offset,
+            data_size - offset);
       }
 
       if (n==0)
--- a/seobeo/s_web.c	Sun Aug 02 09:07:36 2026 -0700
+++ b/seobeo/s_web.c	Sun Aug 02 10:07:40 2026 -0700
@@ -51,6 +51,7 @@
   {
     case HTTP_OK: status_text = "OK"; break;
     case HTTP_CREATED: status_text = "Created"; break;
+    case HTTP_NO_CONTENT: status_text = "No Content"; break;
     case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break;
     case HTTP_FOUND: status_text = "Found"; break;
     case HTTP_BAD_REQUEST: status_text = "Bad Request"; break;
--- a/seobeo/seobeo.h	Sun Aug 02 09:07:36 2026 -0700
+++ b/seobeo/seobeo.h	Sun Aug 02 10:07:40 2026 -0700
@@ -30,6 +30,7 @@
 // HTTP STATUS CODE
 #define HTTP_OK 200
 #define HTTP_CREATED 201
+#define HTTP_NO_CONTENT 204
 #define HTTP_MOVED_PERMANENTLY 301
 #define HTTP_FOUND 302
 #define HTTP_BAD_REQUEST 400
--- a/seobeo/tests/BUILD	Sun Aug 02 09:07:36 2026 -0700
+++ b/seobeo/tests/BUILD	Sun Aug 02 10:07:40 2026 -0700
@@ -39,6 +39,15 @@
 )
 
 cc_test(
+  name = "seobeo_sigpipe_test",
+  srcs = ["seobeo_sigpipe_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
   name = "seobeo_http_timeout_test",
   srcs = ["seobeo_http_timeout_test.c"],
   deps = ["//seobeo:seobeo"],
--- a/seobeo/tests/seobeo_response_test.c	Sun Aug 02 09:07:36 2026 -0700
+++ b/seobeo/tests/seobeo_response_test.c	Sun Aug 02 10:07:40 2026 -0700
@@ -45,5 +45,35 @@
   close(sockets[1]);
   free(handle.write_buffer);
   Dowa_Arena_Free(arena);
+
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  memset(&handle, 0, sizeof(handle));
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 4096;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+  arena = Dowa_Arena_Create(4096);
+  response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", "204", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena);
+  Dowa_HashMap_Push_Arena(response, "body", "", arena);
+  Seobeo_Router_Send_Response(&handle, response, arena);
+
+  memset(received, 0, sizeof(received));
+  length = read(sockets[1], received, sizeof(received) - 1);
+  if (length <= 0 ||
+      !strstr(received, "HTTP/1.1 204 No Content\r\n") ||
+      !strstr(received, "Content-Length: 0\r\n") ||
+      !strstr(received, "\r\n\r\n"))
+    failed = 1;
+
+  if (failed)
+    fprintf(stderr, "Unexpected no-content response:\n%s\n", received);
+
+  close(sockets[0]);
+  close(sockets[1]);
+  free(handle.write_buffer);
+  Dowa_Arena_Free(arena);
   return failed;
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_sigpipe_test.c	Sun Aug 02 10:07:40 2026 -0700
@@ -0,0 +1,36 @@
+#include "seobeo/seobeo.h"
+
+#include <signal.h>
+#include <stdlib.h>
+#include <sys/socket.h>
+
+int main(void)
+{
+  Seobeo_Handle *server =
+      Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0");
+  if (!server)
+    return 1;
+
+  raise(SIGPIPE);
+  Seobeo_Handle_Destroy(server);
+
+  int sockets[2];
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  Seobeo_Handle handle = {0};
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 64;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+  if (!handle.write_buffer)
+    return 1;
+
+  close(sockets[1]);
+  if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0)
+    return 1;
+
+  int result = Seobeo_Handle_Flush(&handle);
+  close(sockets[0]);
+  free(handle.write_buffer);
+  return result < 0 ? 0 : 1;
+}