Mercurial
changeset 195:f8f5004a920a
Merging back hg-web-tip
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 27 Jan 2026 06:51:44 -0800 |
| parents | 14cc84ba35a0 (current diff) fb28063dc490 (diff) |
| children | 83f16548ba41 |
| files | |
| diffstat | 57 files changed, 4636 insertions(+), 1478 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/benchmark/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,13 @@ +# benchmark + +HTTP framework benchmarking tools and tests. + +## Contents + +- `bun-http-framework-benchmark/` - Forked benchmark suite for comparing HTTP frameworks + +## Usage + +```bash +bazel build //benchmark:... +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/config/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,11 @@ +# config + +Shared Bazel build configurations and platform definitions. + +## Usage + +Reference in BUILD files: + +```starlark +load("//config:defs.bzl", ...) +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/deita/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,33 @@ +# deita + +SQLite database wrapper library for C. + +## Features + +- Connection management +- Query building and execution +- Result handling + +## Files + +| File | Description | +|------|-------------| +| `deita.h` | Public API header | +| `deita_internal.h` | Internal declarations | +| `d_sqlite.c` | SQLite wrapper implementation | +| `d_connection.c` | Connection handling | +| `d_query.c` | Query execution | +| `deita_test.c` | Unit tests | + +## Usage + +```c +#include "deita/deita.h" +``` + +## Building + +```bash +bazel build //deita:deita +bazel test //deita:deita_test +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dowa/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,43 @@ +# dowa + +Core utility library for C. Provides memory management, string operations, and math utilities. + +## Features + +- Arena allocator and memory utilities +- String builder and manipulation +- Math helpers +- stb_ds.h for dynamic arrays and hash maps + +## Files + +| File | Description | +|------|-------------| +| `dowa.h` | Public API header | +| `d_memory.c` | Memory management (arena allocator) | +| `d_string.c` | String utilities | +| `d_math.c` | Math helpers | +| `stb_ds.h` | STB dynamic structures | +| `dowa_test.c` | Unit tests | + +## Usage + +```c +#include "dowa/dowa.h" + +// Arena allocator +Arena arena = {0}; +char* str = arena_alloc(&arena, 100); +arena_free(&arena); + +// String builder +StringBuilder sb = {0}; +sb_append(&sb, "hello"); +``` + +## Building + +```bash +bazel build //dowa:dowa +bazel test //dowa:dowa_test +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/graphics/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,8 @@ +# graphics + +Graphics experiments and WebGL demos. + +## Files + +- `index.html` - HTML entry point +- `index.js` - JavaScript graphics code
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gui_ze/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,29 @@ +# gui_ze + +Bazel rules for building GUI applications and web frontends. + +## Files + +| File | Description | +|------|-------------| +| `gui_ze.bzl` | Starlark rules for web/GUI builds | +| `time_to_first_byte.sh` | TTFB measurement script | + +## Rules + +Provides Bazel rules for: +- Bundling JavaScript/TypeScript with esbuild +- Building WASM modules with Emscripten +- Packaging web applications + +## Usage + +```starlark +load("//gui_ze:gui_ze.bzl", "web_bundle", "wasm_cc_binary") + +web_bundle( + name = "app", + entry_point = "src/main.tsx", + ... +) +```
--- a/gui_ze/gui_ze.bzl Sat Jan 24 06:37:43 2026 -0800 +++ b/gui_ze/gui_ze.bzl Tue Jan 27 06:51:44 2026 -0800 @@ -91,6 +91,87 @@ executable = True, ) +def _bun_bundle_impl(ctx): + """ + Bundle TypeScript/JavaScript with Bun, resolving imports from bazel root. + + Copies all dependencies (dereferencing symlinks) to create a flat structure + where imports can resolve correctly relative to the bazel workspace root. + """ + out = ctx.actions.declare_file(ctx.label.name + ".js") + + inputs = depset( + direct = [ctx.file.src], + transitive = [dep[DefaultInfo].files for dep in ctx.attr.deps] + ) + + # Get the source file's package directory (e.g., "hg-web" from "hg-web/src/main.tsx") + src_package = ctx.file.src.path.split("/")[0] + + # Collect unique root directories to copy (deduped), excluding src_package (handled separately) + dirs_to_copy = {} + for f in ctx.files.deps: + # Find the root directory path by locating where short_path starts in full path + short_path_suffix = "/".join(f.short_path.split("/")[1:]) + pos = f.path.find(short_path_suffix) + if pos > 0: + root_dir = f.path[:pos].rstrip("/") + # Skip src_package - it's a symlink that needs special handling + if not root_dir.endswith(src_package): + dirs_to_copy[root_dir] = True + + # Build copy commands for each unique directory + copy_commands = ["cp -rL {dir} .".format(dir = d) for d in dirs_to_copy.keys()] + + ctx.actions.run_shell( + inputs = inputs, + outputs = [out], + tools = [ctx.executable._bun] + [ctx.file.src] + ctx.files.deps + ctx.files.node_modules, + command = """ +cp -rL {src_package} {src_package}_tmp && rm -rf {src_package} && mv {src_package}_tmp {src_package} +{copy_commands} +export NODE_PATH=./third_party/bun/node_modules +cp ./third_party/bun/tsconfig.json . +{bun} build {entry} --outfile {output} --target browser +""".format( + copy_commands = "\n".join(copy_commands), + src_package = src_package, + bun = ctx.executable._bun.path, + entry = ctx.file.src.path, + output = out.path, + ), + progress_message = "Bundling %s with Bun" % ctx.file.src.short_path, + ) + + return [DefaultInfo(files = depset([out]))] + +bun_bundle = rule( + implementation = _bun_bundle_impl, + attrs = { + "src": attr.label( + allow_single_file = [".ts", ".tsx", ".js", ".jsx"], + doc = "Entry point file to bundle", + ), + "deps": attr.label_list( + allow_files = True, + doc = "Source files and other dependencies to include", + ), + "node_modules": attr.label_list( + allow_files = True, + default = [Label("//third_party/bun:bun_files")], + doc = "Node modules for bundling (defaults to //third_party/bun:bun_files)", + ), + "_bun": attr.label( + default = Label("//third_party/bun:bun"), + executable = True, + cfg = "exec", + ), + }, + doc = "Bundle TypeScript/JavaScript using Bun with bazel root imports", +) + + + def _bun_build_impl(ctx): """ Run bun build on the folder @@ -111,11 +192,15 @@ command = """ cp -r third_party/bun/** . \ && cp -r {src_folder}/** . \ - && export NODE_PATH=./node_modules && {bun_path} build {input_path} --outfile {output_path} + && cp -r ./bazel-out/k8-fastbuild/bin/hg-web/src/** src \ + && ls src \ + && pwd \ + && export NODE_PATH=./node_modules && {bun_path} build {input_path} --outfile {output_path} --target browser """.format( bun_path = ctx.executable._bun.path, src_folder = ctx.attr.src_folder, - input_path = ctx.file.src.path.split("/")[-1], + # Fix this lol + input_path = "/".join(ctx.file.src.path.split("/")[-2:]), output_path = out.path, ), progress_message = "Bundling {} with Bun!\n\n".format(ctx.file.src.path), @@ -208,11 +293,11 @@ ctx.actions.run_shell( inputs = [src], outputs = [out], - command = "cp \"$1\" \"$2\"", + command = "cp -r \"$1\" \"$2\"", arguments = [src.path, out.path], ) outs.append(out) - return [DefaultInfo(files = depset(outs))] + return [DefaultInfo(files = depset(outs), runfiles = ctx.runfiles(files = outs))] move_files_into_dir = rule( implementation = _move_files_into_dir_impl,
--- a/hg-web/BUILD Sat Jan 24 06:37:43 2026 -0800 +++ b/hg-web/BUILD Tue Jan 27 06:51:44 2026 -0800 @@ -1,38 +1,73 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") -load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle") +load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle") + +# Source files +filegroup( + name = "src_ts_files", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.js", + "src/**/*.jsx", + ], allow_empty = True), +) +# Bundle TypeScript with Bun +bun_bundle( + name = "page", + src = "src/main.tsx", + deps = [ + ":src_ts_files", + "//markdown_converter:markdown_to_html_wasm", + "//third_party/highlight:js", + ], + visibility = ["//visibility:public"], +) + +# Prepare compiled assets move_files_into_dir( - name = "compiled_ts", + name = "compiled_js", srcs = [ - "//markdown_converter:markdown_to_html", + ":page", + "//markdown_converter:markdown_to_html_wasm", + "//third_party/highlight:js", ], dest = "src", ) +move_files_into_dir( + name = "public_files", + srcs = ["//mrjunejune:public_files"], + dest = "src/public", +) + +move_files_into_dir( + name = "public_fonts_files", + srcs = ["//mrjunejune:public_fonts_files"], + dest = "src/public/fonts", +) + filegroup( - name = "src_files", - srcs = glob(["src/**"]) + [":compiled_ts"], + name = "all_assets", + srcs = glob(["src/**"]) + [":compiled_js", ":public_files", ":public_fonts_files"], +) + +# Server binaries +cc_binary( + name = "hg_web_server", + srcs = ["main.c"], + deps = ["//seobeo:seobeo"], + data = [":all_assets"], ) cc_binary( - name = "hg_web_server", + name = "hg_web_server_debug", srcs = ["main.c"], - deps = ["//seobeo:seobeo_server"], - data = [":src_files"], - defines = ["REPO_ROOT=\\\"\"/home/mrjunejune/zenbu\"\\\""], + deps = ["//seobeo:seobeo"], + data = [":all_assets"], ) bundle( name = "hg_web_server_bundle", binary = ":hg_web_server", ) - - -cc_binary( - name = "hg_web_server_debug", - srcs = ["main.c"], - deps = ["//seobeo:seobeo_tcp_server_ws_debug"], - data = [":src_files"], - defines = ["REPO_ROOT=\\\"\"/home/mrjunejune/zenbu\"\\\""], -) -
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,34 @@ +# hg-web + +A web-based Mercurial repository browser. Provides a GitHub-style interface for browsing files, viewing code with syntax highlighting, and reading markdown documentation. + +## Features + +- Browse repository files and directories +- View code files with syntax highlighting (highlight.js) +- Render markdown files with WASM-based converter +- Dark/light theme support with system preference detection +- Prefetch on hover for faster navigation + +## Structure + +``` +hg-web/ +├── BUILD # Bazel build configuration +├── deploy.sh # Deployment script +├── main.c # C server handling API routes +└── src/ # Frontend source files +``` + +## Building + +```bash +bazel build //hg-web:hg_web +``` + +## API Endpoints + +The C server (`main.c`) provides: + +- `GET /api/repo/list?path=` - List directory contents +- `GET /api/repo/file?path=` - Fetch file contents
--- a/hg-web/main.c Sat Jan 24 06:37:43 2026 -0800 +++ b/hg-web/main.c Tue Jan 27 06:51:44 2026 -0800 @@ -11,37 +11,10 @@ #include <netdb.h> #define HG_SERVE_HOST "127.0.0.1" -#define HG_SERVE_PORT 4444 +#define HG_SERVE_PORT "4444" #define MAX_PATH 4096 -// TODO: Move this to seobeo.... -// Asked AI to create this lol, probably should learn to decode it myself.. -static void url_decode(char *dst, const char *src) -{ - char a, b; - while (*src) { - if ((*src == '%') && - ((a = src[1]) && (b = src[2])) && - (isxdigit(a) && isxdigit(b))) { - if (a >= 'a') a -= 'a'-'A'; - if (a >= 'A') a -= ('A' - 10); - else a -= '0'; - if (b >= 'a') b -= 'a'-'A'; - if (b >= 'A') b -= ('A' - 10); - else b -= '0'; - *dst++ = 16*a+b; - src+=3; - } else if (*src == '+') { - *dst++ = ' '; - src++; - } else { - *dst++ = *src++; - } - } - *dst = '\0'; -} - static char* sanitize_path(const char *input_path, Dowa_Arena *arena) { if (!input_path || strlen(input_path) == 0) @@ -57,8 +30,10 @@ for (size_t i = 0; i < len; i++) { - if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) { - if (i + 1 < len && input_path[i+1] == '.') { + if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) + { + if (i + 1 < len && input_path[i+1] == '.') + { // Skip ".." i++; continue; @@ -79,146 +54,33 @@ return result; } -// Helper to connect to hg serve -static int hg_proxy_connect(void) +Seobeo_Client_Response *hg_proxy_request( + const char *method, + const char *path, + const char *req_body, + const char *hg_custom) { - int sock = socket(AF_INET, SOCK_STREAM, 0); - if (sock < 0) - { - Seobeo_Log(SEOBEO_DEBUG, "Failed to create socket\n"); - return -1; - } - - struct sockaddr_in server_addr; - memset(&server_addr, 0, sizeof(server_addr)); - server_addr.sin_family = AF_INET; - server_addr.sin_port = htons(HG_SERVE_PORT); - inet_pton(AF_INET, HG_SERVE_HOST, &server_addr.sin_addr); - - if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) - { - Seobeo_Log(SEOBEO_DEBUG, "Failed to connect to hg serve at %s:%d\n", HG_SERVE_HOST, HG_SERVE_PORT); - close(sock); - return -1; - } - - return sock; -} - -// Generic helper to proxy a request to hg serve and get the response body -// Returns allocated body on success, NULL on failure -// out_status and out_content_type are optional output parameters -// out_body_len returns the actual body length (for binary content) -static char* hg_proxy_request( - const char *method, - const char *path, - const char *req_body, - size_t body_len, - char *out_status, // should be at least 4 bytes - char *out_content_type, // should be at least 256 bytes - size_t *out_body_len, // optional: returns actual body length - Dowa_Arena *arena) -{ - int sock = hg_proxy_connect(); - if (sock < 0) return NULL; - - // Build HTTP request - char http_request[MAX_PATH * 2]; - snprintf(http_request, sizeof(http_request), - "%s %s HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Connection: close\r\n" - "Accept: application/json, text/plain, */*\r\n" - "Content-Length: %zu\r\n" - "\r\n", - method, path, HG_SERVE_HOST, HG_SERVE_PORT, body_len); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy request: %s %s\n", method, path); - - if (send(sock, http_request, strlen(http_request), 0) < 0) - { - close(sock); - return NULL; - } - - if (body_len > 0 && req_body) - { - send(sock, req_body, body_len, 0); - } + char full_path[MAX_PATH]; + snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); + Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path); + Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path); + Seobeo_Client_Request_Set_Method(p_req, method); + Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0"); + Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json"); - // Read response - int buffer_size = 1024 * 1024 * 5; // 5MB - char *response_buf = Dowa_Arena_Allocate(arena, buffer_size); - size_t total_read = 0; - ssize_t bytes_read; - - while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0) - { - total_read += bytes_read; - if (total_read >= (size_t)(buffer_size - 1)) break; - } - response_buf[total_read] = '\0'; - close(sock); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read); - - // Parse response headers - use memmem to handle binary content - char *headers_end = NULL; - for (size_t i = 0; i + 3 < total_read; i++) - { - if (response_buf[i] == '\r' && response_buf[i+1] == '\n' && - response_buf[i+2] == '\r' && response_buf[i+3] == '\n') - { - headers_end = response_buf + i; - break; - } - } - if (!headers_end) return NULL; + if (hg_custom && hg_custom[0] != '\0') + { + char buffer[1024]; + snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom); + Seobeo_Client_Request_Add_Header_Array(p_req, buffer); + Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer); + } - // Extract status - if (out_status && strncmp(response_buf, "HTTP/", 5) == 0) - { - char *status_start = strchr(response_buf, ' '); - if (status_start) - { - strncpy(out_status, status_start + 1, 3); - out_status[3] = '\0'; - } - } - - // Extract content-type - if (out_content_type) - { - out_content_type[0] = '\0'; - char *ct_header = strcasestr(response_buf, "Content-Type:"); - if (ct_header && ct_header < headers_end) - { - ct_header += 13; - while (*ct_header == ' ') ct_header++; - char *ct_end = strpbrk(ct_header, "\r\n"); - if (ct_end) - { - size_t ct_len = ct_end - ct_header; - if (ct_len < 256) - { - strncpy(out_content_type, ct_header, ct_len); - out_content_type[ct_len] = '\0'; - } - } - } - } - - // Return body (copy to fresh allocation for clean pointer) - char *body = headers_end + 4; - size_t body_size = total_read - (body - response_buf); - - if (out_body_len) *out_body_len = body_size; - - char *result = Dowa_Arena_Allocate(arena, body_size + 1); - memcpy(result, body, body_size); - result[body_size] = '\0'; - - return result; + if (req_body) + Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body)); + Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); + Seobeo_Client_Request_Destroy(p_req); + return p_resp; } Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena) @@ -229,7 +91,7 @@ const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - url_decode(decoded_path, rel_path); + Seobeo_Url_Decode(decoded_path, rel_path); char *safe_path = sanitize_path(decoded_path, arena); @@ -241,14 +103,11 @@ else snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json"); - char status[4] = "200"; - char content_type[256] = ""; - size_t body_len = 0; - char *hg_response = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena); + Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%s body_len=%zu\n", status, body_len); + Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - if (!hg_response || status[0] != '2') + if (hg_response->status_code != 200) { Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n"); Dowa_HashMap_Push_Arena(resp, "status", "502", arena); @@ -256,11 +115,79 @@ Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena); return resp; } - char *json = hg_response; + + char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); + char *temp2 = Dowa_Arena_Allocate(arena, 256); + snprintf(temp2, 256, "%zu", hg_response->body_length); Dowa_HashMap_Push_Arena(resp, "status", "200", arena); Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", json, arena); + Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); + Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); + return resp; +} + +Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); + const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path); + void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); + char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id); + char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); + Seobeo_Url_Decode(decoded_path, rel_path); + char *safe_path = sanitize_path(decoded_path, arena); + + Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path); + + if (strlen(safe_path) == 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); + return resp; + } + + char hg_path[MAX_PATH]; + // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id"); + // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value; + snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path); + Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + + Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + + char status[4]; + snprintf(status, 4, "%i", hg_response->status_code); + + if (!hg_response->body) + { + Dowa_HashMap_Push_Arena(resp, "status", "502", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); + return resp; + } + + if (hg_response->status_code != 200) + { + Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body); + Dowa_HashMap_Push_Arena(resp, "status", status, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); + return resp; + } + + + char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); + char *temp2 = Dowa_Arena_Allocate(arena, 256); + snprintf(temp2, 256, "%zu", hg_response->body_length); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); + Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); return resp; } @@ -272,7 +199,7 @@ void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - url_decode(decoded_path, rel_path); + Seobeo_Url_Decode(decoded_path, rel_path); char *safe_path = sanitize_path(decoded_path, arena); Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path); @@ -285,18 +212,16 @@ return resp; } - // Build hg serve URL: /raw-file/tip/<path> char hg_path[MAX_PATH]; snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path); + Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - char status[4] = "200"; - char content_type[256] = ""; - size_t body_len = 0; - char *body = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena); + Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%s body_len=%zu\n", status, body_len); + char status[4]; + snprintf(status, 4, "%i", hg_response->status_code); - if (!body) + if (!hg_response->body) { Dowa_HashMap_Push_Arena(resp, "status", "502", arena); Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); @@ -304,31 +229,24 @@ return resp; } - if (status[0] != '2') + if (hg_response->status_code != 200) { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error response: %s\n", body); + Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body); Dowa_HashMap_Push_Arena(resp, "status", status, arena); Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - // Return actual error from hg serve if available - Dowa_HashMap_Push_Arena(resp, "body", body_len > 0 ? body : "File not found", arena); + Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); return resp; } - // Use content-type from hg serve, or determine from extension - const char *final_content_type = content_type; - if (strlen(content_type) == 0 || strcmp(content_type, "application/octet-stream") == 0) - { - final_content_type = "text/plain"; - if (strstr(safe_path, ".md")) final_content_type = "text/markdown"; - else if (strstr(safe_path, ".html")) final_content_type = "text/html"; - else if (strstr(safe_path, ".css")) final_content_type = "text/css"; - else if (strstr(safe_path, ".js")) final_content_type = "application/javascript"; - else if (strstr(safe_path, ".json")) final_content_type = "application/json"; - } + + char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); + char *temp2 = Dowa_Arena_Allocate(arena, 256); + snprintf(temp2, 256, "%zu", hg_response->body_length); Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", final_content_type, arena); - Dowa_HashMap_Push_Arena(resp, "body", body, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); + Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); return resp; } @@ -337,155 +255,194 @@ return ApiGetFile(req, arena); } +// Streaming handler for hg wire protocol - pipes data directly without buffering +void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); + const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; + + void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); + const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; + + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; + + const char *hg_custom = req[7].value; + + Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string); + + // THINKING: Connect to hg serve + // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in + // buffer and what not. + Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); + if (!p_upstream || p_upstream->socket < 0) + { + const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream"; + Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); + Seobeo_Handle_Flush(p_client); + if (p_upstream) + Seobeo_Handle_Destroy(p_upstream); + return; + } + + // Create headers + // we only allow x-hgarg-1 and content-length + char request_buf[8192]; + int req_len = snprintf(request_buf, sizeof(request_buf), + "%s /?%s HTTP/1.1\r\n" + "Host: %s:%s\r\n" + "User-Agent: Seobeo/1.0\r\n" + "Connection: close\r\n", + method, query_string, HG_SERVE_HOST, HG_SERVE_PORT); + + if (hg_custom && hg_custom[0] != '\0') + req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom); + + if (req_body && req_body[0] != '\0') + req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body); + else + req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n"); + + Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len); + if (Seobeo_Handle_Flush(p_upstream) < 0) + { + const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed"; + Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); + Seobeo_Handle_Flush(p_client); + Seobeo_Handle_Destroy(p_upstream); + return; + } + + // Responses + while (1) + { + int r = Seobeo_Handle_Read(p_upstream); + if (r < 0) + { + Seobeo_Handle_Destroy(p_upstream); + return; + } + if (p_upstream->read_buffer_len >= 4 && + strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL) + break; + if (r == 0) + continue; + } + + // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. + char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); + if (!hdr_end) + { + Seobeo_Handle_Destroy(p_upstream); + return; + } + size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; + Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); + Seobeo_Handle_Flush(p_client); + + // All body + size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; + if (body_in_buffer > 0) + { + Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); + Seobeo_Handle_Flush(p_client); + } + Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + while (1) + { + int n = Seobeo_Handle_Read(p_upstream); + if (n > 0) + { + Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len); + Seobeo_Handle_Flush(p_client); + Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + } + else if (n == -2) + break; + else if (n < 0) + break; + } + + Seobeo_Handle_Destroy(p_upstream); +} + Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; + Seobeo_Request_Entry *resp = NULL; - // Get method - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - // Get query string - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; + void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); + const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - // Get request body for POST - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - size_t body_len = strlen(req_body); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); + void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); + const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - // Connect to hg serve - int sock = hg_proxy_connect(); - if (sock < 0) - { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; - } + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; + size_t body_len = strlen(req_body); - // Build the HTTP request to forward to hg serve - char http_request[MAX_PATH * 2]; - if (strlen(query_string) > 0) - { - snprintf(http_request, sizeof(http_request), - "%s /?%s HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Connection: close\r\n" - "Content-Length: %zu\r\n" - "\r\n", - method, query_string, HG_SERVE_HOST, HG_SERVE_PORT, body_len); - } - else - { - snprintf(http_request, sizeof(http_request), - "%s / HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Connection: close\r\n" - "Content-Length: %zu\r\n" - "\r\n", - method, HG_SERVE_HOST, HG_SERVE_PORT, body_len); - } + const char *hg_custom = req[7].value; + Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); + + Seobeo_Client_Response *hg_response; - // Send HTTP request headers - if (send(sock, http_request, strlen(http_request), 0) < 0) - { - Seobeo_Log(SEOBEO_DEBUG, "Failed to send request to hg serve\n"); - close(sock); - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to send to hg serve", arena); - return resp; - } + char hg_path[MAX_PATH]; + snprintf(hg_path, sizeof(hg_path), "/?%s", query_string); + + hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom); + + Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length); + + Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type"); - // Send body if present - if (body_len > 0) - { - send(sock, req_body, body_len, 0); - } - - // Read response from hg serve - int buffer_size = 1024 * 1024 * 5; // 5MB - char *response_buf = Dowa_Arena_Allocate(arena, buffer_size); - size_t total_read = 0; - ssize_t bytes_read; + char *status = Dowa_Arena_Allocate(arena, 5); + snprintf(status, 4, "%i", hg_response->status_code); - while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0) - { - total_read += bytes_read; - if (total_read >= (size_t)(buffer_size - 1)) break; - } - response_buf[total_read] = '\0'; - close(sock); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read); + // Use binary-safe copy to handle null bytes in mercurial bundle data + char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); + char *temp2 = Dowa_Arena_Allocate(arena, 256); + snprintf(temp2, 256, "%zu", hg_response->body_length); - // Parse HTTP response - find headers end - char *headers_end = strstr(response_buf, "\r\n\r\n"); - if (!headers_end) - { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Invalid response from hg serve", arena); - return resp; - } + Dowa_HashMap_Push_Arena(resp, "status", status, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena); + Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); + Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - // Extract status code from first line (e.g., "HTTP/1.1 200 OK") - char status_code[4] = "200"; - if (strncmp(response_buf, "HTTP/", 5) == 0) - { - char *status_start = strchr(response_buf, ' '); - if (status_start) - { - strncpy(status_code, status_start + 1, 3); - status_code[3] = '\0'; - } - } + return resp; +} - // Extract content-type from headers - const char *content_type = "application/mercurial-0.1"; - char *ct_header = strcasestr(response_buf, "Content-Type:"); - if (ct_header && ct_header < headers_end) - { - ct_header += 13; // Skip "Content-Type:" - while (*ct_header == ' ') ct_header++; - char *ct_end = strpbrk(ct_header, "\r\n"); - if (ct_end) - { - size_t ct_len = ct_end - ct_header; - char *ct_copy = Dowa_Arena_Allocate(arena, ct_len + 1); - strncpy(ct_copy, ct_header, ct_len); - ct_copy[ct_len] = '\0'; - content_type = ct_copy; - } - } +Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); - // Body starts after \r\n\r\n - char *body = headers_end + 4; - - Dowa_HashMap_Push_Arena(resp, "status", status_code, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", content_type, arena); - Dowa_HashMap_Push_Arena(resp, "body", body, arena); - - return resp; + printf("%s", html); + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(resp, "body", html, arena); + return resp; } int main(void) { Seobeo_Router_Init(); + + Seobeo_Router_Register("GET", "/", GetReactHome); + Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); + Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph); Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); - Seobeo_Router_Register("GET", "/repo", ApiHgWireProtocol); - Seobeo_Router_Register("POST", "/repo", ApiHgWireProtocol); + // Use streaming handler for hg wire protocol... + Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); + Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); printf("Starting on Port 6970...\n"); - printf("Repository: %s\n", REPO_ROOT); - int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 4); + int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); Seobeo_Router_Destroy();
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,30 @@ +# src + +Frontend source files for the Mercurial repository browser. + +## Files + +| File | Description | +|------|-------------| +| `repo-browser.tsx` | Main React component for the repository browser UI | +| `main.tsx` | React entry point, renders RepoBrowser | +| `index.html` | HTML template | +| `index.js` | JavaScript entry point | +| `index.css` | highlight.js theme styles | +| `base.css` | Base/reset styles | +| `build.ts` | esbuild configuration for bundling | + +## Components (repo-browser.tsx) + +- `RepoBrowser` - Main app component with routing and state +- `FileList` - Renders directory listing +- `FileRow` - Individual file/folder row with prefetch on hover +- `FileViewer` - Modal for viewing code files with syntax highlighting +- `MarkdownViewerModal` - Modal for rendered markdown files +- `ReadmeViewer` - Inline README.md display +- `Breadcrumb` - Navigation breadcrumb +- `GlobalStyles` - CSS-in-JS with dark/light theme support + +## Subdirectories + +- `icons/` - Static icon assets
--- a/hg-web/src/base.css Sat Jan 24 06:37:43 2026 -0800 +++ b/hg-web/src/base.css Tue Jan 27 06:51:44 2026 -0800 @@ -1,123 +1,169 @@ -/* --- Colors ---*/ +/* Reset CSS: https://meyerweb.com/eric/tools/css/reset/ */ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* =========================================== + Base CSS - Color Variables and Basic Setup + =========================================== */ + +/* Light mode (default) */ :root { --bg: #ffffff; - --fg: #1a1a1a; - --border: #e0e0e0; - --hover: #f5f5f5; - --accent: #0066cc; - --accent-hover: #0052a3; - --secondary: #6c757d; - --success: #28a745; - --warning: #ffc107; - --danger: #dc3545; - --code-bg: #f6f8fa; - --link: #0066cc; - --link-hover: #0052a3; + --bg-subtle: #f6f8fa; + --bg-code: #f6f8fa; + --border: #d0d7de; + --accent: #0969da; + --text-primary: #1f2328; + --text-secondary: #656d76; + --hover: #f3f4f6; + --success: #1a7f37; + --danger: #cf222e; + --danger-bg: #ffebe9; + --danger-border: #ffdce0; + --overlay: rgba(0, 0, 0, 0.5); + + /* Graph colors - light mode */ + --graph-1: #495057; + --graph-2: #1971c2; + --graph-3: #099268; + --graph-4: #e67700; + --graph-5: #7048e8; + --graph-6: #c92a2a; + --graph-7: #c2255c; + --graph-node-border: #ffffff; } -.dark { +/* Dark mode - applied when html has .dark class */ +:root.dark { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + /* Graph colors - dark mode */ + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } +/* System preference fallback (when no explicit class is set) */ @media (prefers-color-scheme: dark) { - :root:not(.light-mode) { + :root:not(.light) { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } } -/* --- Reset and Base Styles --- */ +/* Fonts */ +@font-face { + font-family: "Roboto"; + src: url("/public/fonts/Roboto-Regular.ttf"); +} +@font-face { + font-family: "Roboto Light"; + src: url("/public/fonts/Roboto-Thin.ttf"); +} +@font-face { + font-family: "More Thin"; + src: url("/public/fonts/more-sugar.thin.otf"); +} +@font-face { + font-family: "More"; + src: url("/public/fonts/more-sugar.regular.otf"); +} + +button { + font-family: "More Thin", sans-serif; +} + +/* Reset and Base */ * { margin: 0; padding: 0; box-sizing: border-box; } -html { - background: var(--bg); - color: var(--fg); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; - line-height: 1.6; +html, body { background: var(--bg); - color: var(--fg); - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -main { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; + color: var(--text-primary); + font-family: "More Thin", sans-serif; + line-height: 1.6; + transition: background 0.2s, color 0.2s; } a { - color: var(--link); + color: var(--accent); text-decoration: none; } a:hover { - color: var(--link-hover); text-decoration: underline; } -h1, h2, h3, h4, h5, h6 { - margin-bottom: 1rem; - font-weight: 600; - line-height: 1.25; -} - -h1 { font-size: 2rem; } -h2 { font-size: 1.75rem; } -h3 { font-size: 1.5rem; } -h4 { font-size: 1.25rem; } -h5 { font-size: 1.1rem; } -h6 { font-size: 1rem; } - -p { - margin-bottom: 1rem; -} - code { - background: var(--code-bg); + background: var(--bg-code); padding: 0.2em 0.4em; border-radius: 3px; - font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9em; } pre { - background: var(--code-bg); + background: var(--bg-code); padding: 1rem; border-radius: 6px; overflow-x: auto; - margin-bottom: 1rem; } pre code { @@ -125,17 +171,18 @@ padding: 0; } -/* Mobile responsive */ -@media (max-width: 768px) { - body { - font-size: 14px; - } +/* Icon invert for dark mode */ +:root.dark .icon-invert { + filter: invert(0.8); +} - main { - padding: 1rem; - } +:root.light .icon-invert, +:root:not(.dark):not(.light) .icon-invert { + filter: none; +} - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } +@media (prefers-color-scheme: dark) { + :root:not(.light) .icon-invert { + filter: invert(0.8); + } }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/app.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,389 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Graph, useGraphData } from "hg-web/src/components/graph"; +import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; +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"; + +type Page = 'landing' | 'graph' | 'directory'; + +type RouteState = { + page: Page; + graphCommit?: string; + graphTip?: string; + dirPath?: string; +} + +// Icons +const ICONS = { + folder: "/icons/folder.png", +}; + +const GraphIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> + <circle cx="18" cy="12" r="3"/> + <line x1="6" y1="9" x2="6" y2="15"/> + <path d="M8.5 7.5L15.5 11"/> + </svg> +); + +const FolderIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> + <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/> + </svg> +); + +const API_BASE = '/api/repo'; + +function parseRoute(): RouteState { + const params = new URLSearchParams(window.location.search); + const pathname = window.location.pathname; + + if (pathname.startsWith('/graph') || params.has('graph')) { + return { + page: 'graph', + graphCommit: params.get('commit') || undefined, + graphTip: params.get('tip') || undefined, + }; + } + + if (pathname.startsWith('/directory') || params.has('path')) { + return { + page: 'directory', + dirPath: params.get('path') || '', + }; + } + + return { page: 'landing' }; +} + +function buildUrl(state: RouteState): string { + const params = new URLSearchParams(); + + switch (state.page) { + case 'graph': + if (state.graphCommit) params.set('commit', state.graphCommit); + if (state.graphTip) params.set('tip', state.graphTip); + return `/graph${params.toString() ? '?' + params.toString() : ''}`; + case 'directory': + if (state.dirPath) params.set('path', state.dirPath); + return `/directory${params.toString() ? '?' + params.toString() : ''}`; + default: + return '/'; + } +} + +// Landing Page Component +function LandingPage({ + onNavigateToGraph, + onNavigateToDirectory, +}: { + onNavigateToGraph: () => void; + onNavigateToDirectory: (path?: string) => void; +}) { + const [directories, setDirectories] = useState<any[]>([]); + const [files, setFiles] = useState<any[]>([]); + const [dirLoading, setDirLoading] = useState(true); + + const { data: graphData, loading: graphLoading } = useGraphData(); + + useEffect(() => { + fetch(`${API_BASE}/list`) + .then(r => r.json()) + .then(data => { + setDirectories(data.directories || []); + setFiles(data.files || []); + setDirLoading(false); + }) + .catch(() => setDirLoading(false)); + }, []); + + const previewItems = [ + ...directories.slice(0, 6), + ...files.slice(0, Math.max(0, 6 - directories.length)) + ].slice(0, 6); + + return ( + <div className="landing-grid"> + {/* Graph Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <GraphIcon /> + Recent Commits + </span> + <a href="/graph" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToGraph(); + }}> + View all + </a> + </div> + <div className="landing-section-content"> + {graphLoading ? ( + <div className="loading-state">Loading commits...</div> + ) : graphData ? ( + <Graph + data={graphData} + maxRows={8} + onCommitClick={(node) => { + console.log('Clicked commit:', node); + }} + /> + ) : ( + <div className="empty-state">Failed to load commits</div> + )} + </div> + </div> + + {/* Directory Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <FolderIcon /> + Repository Files + </span> + <a href="/directory" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToDirectory(); + }}> + Browse all + </a> + </div> + <div className="landing-section-content"> + {dirLoading ? ( + <div className="loading-state">Loading files...</div> + ) : previewItems.length > 0 ? ( + previewItems.map((item) => ( + <div + key={item.abspath} + className="dir-item" + onClick={() => onNavigateToDirectory(item.abspath)} + > + <span className="dir-item-icon"> + <img + className="icon-invert" + src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"} + alt="" + /> + </span> + <span className="dir-item-name">{item.basename}</span> + </div> + )) + ) : ( + <div className="empty-state">No files found</div> + )} + </div> + </div> + </div> + ); +} + +// Graph Page Component +function GraphPage({ + onBack, + initialCommit, + initialTip, +}: { + onBack: () => void; + initialCommit?: string; + initialTip?: string; +}) { + const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ + initialCommit: initialCommit || null, + graphTop: initialTip || null, + }); + + useEffect(() => { + if (tip && currentCommit) { + const params = new URLSearchParams(); + params.set('commit', currentCommit); + params.set('tip', tip); + const newUrl = `/graph?${params.toString()}`; + window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); + } + }, [currentCommit, tip]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back + </button> + <span className="page-title">Commit Graph</span> + </div> + + {tip && ( + <div className="graph-params"> + <span className="graph-param"> + <span className="graph-param-label">Tip:</span> + <span className="graph-param-value">{tip.substring(0, 12)}</span> + </span> + {currentCommit && currentCommit !== tip && ( + <span className="graph-param"> + <span className="graph-param-label">Current:</span> + <span className="graph-param-value">{currentCommit.substring(0, 12)}</span> + </span> + )} + </div> + )} + + {error && ( + <div className="error-message">Error: {error}</div> + )} + + <Graph + data={data} + loading={loading} + hasMore={hasMore} + onLoadMore={loadMore} + onCommitClick={(node) => { + console.log('Clicked commit:', node); + }} + /> + </div> + ); +} + +// Directory Page Component +function DirectoryPage({ + onBack, + initialPath, + onPathChange, +}: { + onBack: () => void; + initialPath?: string; + onPathChange: (path: string) => void; +}) { + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back + </button> + <span className="page-title">Repository Files</span> + </div> + + <DirectoryBrowser + initialPath={initialPath} + onPathChange={onPathChange} + /> + </div> + ); +} + +// Main App Content (uses theme context) +function AppContent() { + const [route, setRoute] = useState<RouteState>(parseRoute); + const { isDark, toggleTheme } = useTheme(); + + // Handle browser back/forward + useEffect(() => { + const handlePopState = () => { + setRoute(parseRoute()); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const navigate = useCallback((newRoute: RouteState) => { + const url = buildUrl(newRoute); + window.history.pushState(newRoute, '', url); + setRoute(newRoute); + }, []); + + const navigateToLanding = useCallback(() => { + navigate({ page: 'landing' }); + }, [navigate]); + + const navigateToGraph = useCallback((commit?: string, tip?: string) => { + navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); + }, [navigate]); + + const navigateToDirectory = useCallback((path?: string) => { + navigate({ page: 'directory', dirPath: path || '' }); + }, [navigate]); + + const handleDirectoryPathChange = useCallback((path: string) => { + // Update URL without full navigation + const params = new URLSearchParams(); + if (path) params.set('path', path); + const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; + window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); + setRoute(prev => ({ ...prev, dirPath: path })); + }, []); + + return ( + <div className="app-container"> + <Header + title="Zenbu Repository" + showThemeToggle={true} + isDark={isDark} + onToggleTheme={toggleTheme} + /> + + {/* Navigation Tabs */} + <div className="nav-tabs"> + <button + className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`} + onClick={navigateToLanding} + > + Home + </button> + <button + className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} + onClick={() => navigateToGraph()} + > + <GraphIcon /> + Graph + </button> + <button + className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`} + onClick={() => navigateToDirectory()} + > + <FolderIcon /> + Files + </button> + </div> + + {/* Page Content */} + {route.page === 'landing' && ( + <LandingPage + onNavigateToGraph={() => navigateToGraph()} + onNavigateToDirectory={navigateToDirectory} + /> + )} + + {route.page === 'graph' && ( + <GraphPage + onBack={navigateToLanding} + initialCommit={route.graphCommit} + initialTip={route.graphTip} + /> + )} + + {route.page === 'directory' && ( + <DirectoryPage + onBack={navigateToLanding} + initialPath={route.dirPath} + onPathChange={handleDirectoryPathChange} + /> + )} + + <Footer /> + </div> + ); +} + +// App wrapper with ThemeProvider +function App() { + return ( + <ThemeProvider> + <AppContent /> + </ThemeProvider> + ); +} + +export { App };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/directory-browser.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,539 @@ +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'; + +// --- 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="#" + 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]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + 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 + */ +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]); + + 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 + ? `#` + : `/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> + ); +} + +/** + * Directory Browser Component (no header/footer - for embedding in app) + */ +interface DirectoryBrowserProps { + initialPath?: string; + onPathChange?: (path: string) => void; +} + +function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + 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); + + // Sync with initialPath prop + useEffect(() => { + setCurrentPath(initialPath); + }, [initialPath]); + + useEffect(() => { + fetchDirectory(currentPath); + fetchReadme(currentPath); + }, [currentPath]); + + const navigate = useCallback((path: string) => { + setCurrentPath(path); + onPathChange?.(path); + }, [onPathChange]); + + 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 + } + }; + + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + return ( + <> + <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} /> + </> + )} + + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : ( + <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> + ) + )} + </> + ); +} + +export { DirectoryBrowser };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/footer.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,31 @@ +import React from 'react'; + +interface FooterProps { + showCloneUrl?: boolean; + cloneUrl?: string; +} + +function Footer({ + showCloneUrl = false, + cloneUrl = "hg clone http://zenbu.babocoder.com/repo", +}: FooterProps) { + const currentYear = new Date().getFullYear(); + + return ( + <footer className="footer"> + {showCloneUrl && ( + <div className="clone-box"> + <div className="clone-box-inner"> + <span className="clone-label">Clone HTTPS</span> + <code className="clone-url">{cloneUrl}</code> + </div> + </div> + )} + <div className="footer-content"> + <span className="footer-text">© 2026 June Park</span> + </div> + </footer> + ); +} + +export { Footer };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/graph.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,313 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; + +// Configuration constants for the layout +const rowHeight = 40; +const colWidth = 20; +const nodeRadius = 4.5; + +// --- Interfaces --- + +interface Changeset { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + phase: string; + col: number; + row: number; + color: number; + edges: Array<{ + bcolor: string; + col: number; + color: number; + nextcol: number; + width: number; + }>; + parents: string[]; +} + +interface GraphData { + node: string; + changeset_count: number; + changesets: Changeset[]; +} + +interface UseGraphDataOptions { + initialCommit?: string | null; + graphTop?: string | null; +} + +interface UseGraphDataResult { + data: GraphData | null; + loading: boolean; + error: string | null; + loadMore: () => void; + hasMore: boolean; + tip: string | null; + currentCommit: string | null; +} + +// --- Hook Logic --- + +function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { + const [data, setData] = useState<GraphData | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [tip, setTip] = useState<string | null>(graphTop); + const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit); + const [hasMore, setHasMore] = useState(true); + + const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { + if (loading) return; + setLoading(true); + setError(null); + + try { + const url = !commit + ? `/api/graph/tip?style=json` + : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; + + const response = await fetch(url); + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); + + const result: GraphData = await response.json(); + + setData(prev => { + if (append && prev) { + const existingNodes = new Set(prev.changesets.map(cs => cs.node)); + const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); + + // Re-index rows to ensure they increment correctly for the canvas height + const startRow = prev.changesets.length; + const reindexed = newChangesets.map((cs, idx) => ({ + ...cs, + row: startRow + idx + })); + + return { + ...result, + changesets: [...prev.changesets, ...reindexed] + }; + } + return result; + }); + + if (!tip && !append) setTip(result.node); + + if (result.changesets.length > 0) { + const lastNode = result.changesets[result.changesets.length - 1].node; + setCurrentCommit(lastNode); + setHasMore(result.changesets.length >= 30); + } else { + setHasMore(false); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [tip, loading]); + + useEffect(() => { + fetchData(initialCommit, graphTop, false); + }, [initialCommit, graphTop]); + + const loadMore = useCallback(() => { + if (!loading && hasMore && currentCommit && tip) { + fetchData(currentCommit, tip, true); + } + }, [loading, hasMore, currentCommit, tip, fetchData]); + + return { data, loading, error, loadMore, hasMore, tip, currentCommit }; +} + +// --- Pencil Rendering Logic --- + +const drawPencilLine = ( + ctx: CanvasRenderingContext2D, + x1: number, y1: number, + x2: number, y2: number, + texture: CanvasPattern | null, // Ensure type safety + isCurve: boolean = false +) => { + const strokes = 3; + ctx.save(); + + for (let s = 0; s < strokes; s++) { + ctx.beginPath(); + ctx.strokeStyle = texture; + ctx.globalAlpha = 0.2 + (s * 0.2); + ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner + + // 2. Realistic Jitter: Actually return a random small number + const jitter = () => (Math.random() - 0.5) * 1.5; + + ctx.moveTo(x1 + jitter(), y1 + jitter()); + + if (isCurve) { + const cpY = y1 + (y2 - y1) / 2; + ctx.bezierCurveTo( + x1 + jitter(), cpY + jitter(), + x2 + jitter(), cpY + jitter(), + x2 + jitter(), y2 + jitter() + ); + } else { + ctx.lineTo(x2 + jitter(), y2 + jitter()); + } + + ctx.stroke(); + } + ctx.restore(); +} + +// --- Main Component --- + +interface GraphProps { + data: GraphData | null; + loading?: boolean; + hasMore?: boolean; + onLoadMore?: () => void; + onCommitClick?: (node: string) => void; + maxRows?: number; +} + +const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + + const changesets = useMemo(() => + maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); + + let pencilPattern; + const img = new Image(); + img.src = "http://localhost:6970/pencil_lines.png"; + + const pandaImg = new Image(); + pandaImg.src = "http://localhost:6970/panda.png"; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !changesets.length) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Grab colors from CSS variables or defaults + const getColors = () => { + const s = getComputedStyle(document.documentElement); + return [ + s.getPropertyValue('--graph-1').trim() || '#4dabf7', + s.getPropertyValue('--graph-2').trim() || '#63e6be', + s.getPropertyValue('--graph-3').trim() || '#ffbc42', + s.getPropertyValue('--graph-4').trim() || '#b197fc', + s.getPropertyValue('--graph-5').trim() || '#ff8787', + s.getPropertyValue('--graph-6').trim() || '#f06595', + ]; + }; + + const colors = getColors(); + const dpr = window.devicePixelRatio || 1; + const maxCol = Math.max(...changesets.map(cs => cs.col), 0); + const canvasWidth = (maxCol + 2) * colWidth; + + // Scale for high-DPI screens + canvas.width = canvasWidth * dpr; + canvas.height = changesets.length * rowHeight * dpr; + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${changesets.length * rowHeight}px`; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); + + const getX = (col: number) => (col + 1) * colWidth; + const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); + + const renderCanvas = () => { + if (!pencilPattern) return; // Don't draw if the pattern isn't ready + + // Pass 1: Draw Connecting Edges + changesets.forEach((cs, i) => { + if (!cs.edges) return; + cs.edges.forEach(edge => { + const sX = getX(edge.col), sY = getY(i); + const eX = getX(edge.nextcol), eY = getY(i + 1); + + drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); + }); + }); + + // Pass 2: Draw Commit Nodes + changesets.forEach((cs, i) => { + const x = getX(cs.col), y = getY(i); + ctx.drawImage(pandaImg, x-10, y-10, 20, 20); + }); + }; + + img.onload = () => { + pencilPattern = ctx.createPattern(img, "repeat")!; + renderCanvas(); + }; + }, [changesets]); + + // Handle Infinite Scroll via Intersection Observer + useEffect(() => { + if (!onLoadMore || !hasMore) return; + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !loading) { + onLoadMore(); + } + }, { threshold: 0.1 }); + + const sentinel = document.getElementById('infinite-scroll-sentinel'); + if (sentinel) observer.observe(sentinel); + return () => observer.disconnect(); + }, [onLoadMore, hasMore, loading]); + + return ( + <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}> + <div + ref={containerRef} + style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} + > + {/* Graph Column - Sticky to keep lines aligned with text during scroll */} + <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> + <canvas ref={canvasRef} style={{ display: 'block' }} /> + </div> + + {/* Details Column */} + <div style={{ flex: 1 }}> + {changesets.map((cs) => ( + <div + key={cs.node} + style={{ + height: rowHeight, + display: 'flex', + alignItems: 'center', + padding: '0 15px', + borderBottom: '1px solid #252525', + cursor: 'pointer', + fontSize: '13px', + whiteSpace: 'nowrap' + }} + onClick={() => onCommitClick?.(cs.node)} + onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > + <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> + <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> + <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> + </div> + ))} + <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> + </div> + </div> + + {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} + </div> + ); +}; + +export { Graph, useGraphData }; +export type { GraphData, Changeset, UseGraphDataResult };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/header.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,60 @@ +import React from 'react'; + +// Icons +const ICONS = { + repo: "/public/epi_all_colors.svg", +}; + +const SunIcon = () => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> +); + +const MoonIcon = () => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> +); + +interface HeaderProps { + title?: string; + subtitle?: string; + showThemeToggle?: boolean; + isDark?: boolean; + onToggleTheme?: () => void; +} + +function Header({ + title = "Zenbu Repository", + subtitle, + showThemeToggle = true, + isDark = false, + onToggleTheme, +}: HeaderProps) { + return ( + <header className="header"> + <img src={ICONS.repo} alt="Zenbu" className="header-icon" /> + <div className="header-content"> + <h1><a href="/">{title}</a></h1> + {subtitle && <p className="header-subtitle">{subtitle}</p>} + </div> + {showThemeToggle && onToggleTheme && ( + <button className="theme-toggle" onClick={onToggleTheme} title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}> + {isDark ? <SunIcon /> : <MoonIcon />} + <span>{isDark ? 'Light' : 'Dark'}</span> + </button> + )} + </header> + ); +} + +export { Header };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/repo-browser.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,584 @@ +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 };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/theme.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,72 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +interface ThemeContextType { + isDark: boolean; + toggleTheme: () => void; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +// Apply theme class to document root +function applyTheme(isDark: boolean) { + const root = document.documentElement; + if (isDark) { + root.classList.add('dark'); + root.classList.remove('light'); + } else { + root.classList.add('light'); + root.classList.remove('dark'); + } +} + +interface ThemeProviderProps { + children: ReactNode; +} + +function ThemeProvider({ children }: ThemeProviderProps) { + const [isDark, setIsDark] = useState(() => { + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + // Apply theme on mount and change + useEffect(() => { + applyTheme(isDark); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + }, [isDark]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + // Only apply if no explicit preference is saved + if (!localStorage.getItem('theme')) { + setIsDark(e.matches); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = useCallback(() => { + setIsDark(prev => !prev); + }, []); + + return ( + <ThemeContext.Provider value={{ isDark, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +} + +function useTheme(): ThemeContextType { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} + +export { ThemeProvider, useTheme };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/icons/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,29 @@ +# icons + +Static icon assets for the repository browser UI. + +## Files + +| File | Description | Source | +|------|-------------|--------| +| `folder.png` | Directory/folder icon | Flaticon | +| `file.svg` | Generic file icon | VSCode Material Icon Theme | +| `home.png` | Home navigation icon | Flaticon | +| `repo.svg` | Repository icon (git branch style) | Custom | +| `close.png` | Modal close button icon | Flaticon | + +## Usage + +Icons are served as static files at `/icons/` and referenced in `repo-browser.tsx`: + +```typescript +const ICONS = { + folder: "/icons/folder.png", + file: "/icons/file.svg", + home: "/icons/home.png", + repo: "/icons/repo.svg", + close: "/icons/close.png" +}; +``` + +Add to Bazel filegroup for serving.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/icons/file.svg Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg> \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/icons/repo.svg Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/> + <path d="M9 18c-4.51 2-5-2-7-2"/> +</svg>
--- a/hg-web/src/index.css Sat Jan 24 06:37:43 2026 -0800 +++ b/hg-web/src/index.css Tue Jan 27 06:51:44 2026 -0800 @@ -1,179 +1,751 @@ +/* =========================================== + Component Styles + Import base.css before this file + =========================================== */ + +/* =========================================== + App Layout + =========================================== */ +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +/* =========================================== + Header + =========================================== */ .header { - border-bottom: 1px solid var(--border); - padding-bottom: 1rem; - margin-bottom: 2rem; + display: flex; + align-items: center; + margin-bottom: 24px; + gap: 15px; +} + +.header-icon { + width: 32px; + height: 32px; + opacity: 0.8; + cursor: pointer; } .header h1 { - margin-bottom: 0.5rem; + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header h1 a { + color: inherit; + text-decoration: none; +} + +.header-subtitle { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -.header .description { - color: var(--secondary); - font-size: 0.95rem; +/* =========================================== + Navigation Tabs + =========================================== */ +.nav-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; } -.clone-info { - background: var(--code-bg); - border: 1px solid var(--border); +.nav-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; border-radius: 6px; - padding: 1rem; - margin-bottom: 2rem; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.nav-tab:hover { + background: var(--bg-subtle); + color: var(--text-primary); +} + +.nav-tab.active { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-primary); + font-weight: 500; } -.clone-info code { - background: none; - color: var(--fg); - font-size: 0.95rem; +.nav-tab svg { + color: var(--text-secondary); +} + +/* =========================================== + Landing Page + =========================================== */ +.landing-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 768px) { + .landing-grid { + grid-template-columns: 1fr; + } +} + +.landing-section { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; } -.breadcrumb { - margin-bottom: 1.5rem; - font-size: 0.95rem; +.landing-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); } -.breadcrumb a { - color: var(--link); +.landing-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); } -.breadcrumb a:hover { +.landing-section-title svg { + color: var(--text-secondary); +} + +.landing-section-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.2s; +} + +.landing-section-link:hover { + background: var(--bg-subtle); text-decoration: underline; } -.breadcrumb span { - color: var(--secondary); - margin: 0 0.5rem; +.landing-section-content { + padding: 0; +} + +/* =========================================== + Directory Items (Landing Preview) + =========================================== */ +.dir-item { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; + cursor: pointer; + transition: background 0.1s; +} + +.dir-item:last-child { + border-bottom: none; +} + +.dir-item:hover { + background: var(--hover); +} + +.dir-item-icon { + margin-right: 12px; + opacity: 0.7; +} + +.dir-item-icon img { + width: 18px; + height: 18px; +} + +.dir-item-name { + color: var(--text-primary); +} + +.dir-item-name:hover { + color: var(--accent); +} + +/* =========================================== + Page Header (Graph Page) + =========================================== */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; } -.file-list { +.back-button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.back-button:hover { + background: var(--hover); + color: var(--text-primary); +} + +.page-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* =========================================== + Graph Params + =========================================== */ +.graph-params { + display: flex; + gap: 12px; + margin-bottom: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.graph-param { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--bg-subtle); + border-radius: 4px; +} + +.graph-param-label { + font-weight: 500; +} + +.graph-param-value { + font-family: monospace; + color: var(--accent); +} + +/* =========================================== + Graph Component + =========================================== */ +.graph-container { + background: var(--bg); + color: var(--text-primary); + font-family: "More Thin", sans-serif; + border-radius: 6px; + border: 1px solid var(--border); overflow: hidden; } -.file-item { +.graph-wrapper { display: flex; - align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border); - transition: background-color 0.2s; + align-items: flex-start; + max-height: 600px; + overflow-y: auto; +} + +.graph-canvas-column { + flex-shrink: 0; + background: var(--bg); + position: sticky; + left: 0; } -.file-item:last-child { - border-bottom: none; +.graph-details-column { + flex-grow: 1; + overflow-x: hidden; } -.file-item:hover { +.graph-row { + height: 40px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 12px; + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + transition: background-color 0.1s; +} + +.graph-row:hover { background: var(--hover); } -.file-item .icon { - margin-right: 0.75rem; - font-size: 1.2rem; - width: 20px; - text-align: center; +.graph-row-meta { + display: flex; + gap: 10px; + margin-bottom: 2px; + align-items: center; +} + +.graph-hash { + color: var(--accent); + font-family: monospace; +} + +.graph-user { + color: var(--text-secondary); + font-weight: 500; +} + +.graph-branch { + color: var(--text-secondary); + font-size: 10px; + background: var(--bg-subtle); + padding: 1px 6px; + border-radius: 3px; +} + +.graph-desc { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.graph-badge-tip { + background: var(--success); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; +} + +.graph-badge-tag { + background: var(--accent); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; } -.file-item .name { - flex: 1; - font-family: 'Monaco', 'Courier New', monospace; - font-size: 0.9rem; +.graph-loading-row { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 12px; +} + +/* =========================================== + Common States + =========================================== */ +.empty-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.loading-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); } -.file-item .name a { - color: var(--fg); +.error-message { + padding: 15px; + border: 1px solid var(--danger-border); + background: var(--danger-bg); + color: var(--danger); + border-radius: 6px; + margin-bottom: 20px; +} + +/* =========================================== + Repository Browser + =========================================== */ +.repo-container { + font-family: "More Thin", sans-serif; + max-width: 980px; + margin: 40px auto; + color: var(--text-primary); + padding: 0 20px; +} + +/* Clone Box */ +.clone-box { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; } -.file-item .name a:hover { - color: var(--link); +.clone-label { + font-weight: 600; + font-size: 13px; + margin-right: 10px; + color: var(--text-primary); +} + +.clone-url { + font-family: "More Thin", sans-serif; + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + flex-grow: 1; +} + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + font-size: 14px; + margin-bottom: 16px; + color: var(--text-secondary); + padding: 8px 0; } -.file-item.directory .icon { +.breadcrumb a { color: var(--accent); + text-decoration: none; + border-radius: 4px; + padding: 2px 6px; +} + +.breadcrumb a:hover { + background: var(--bg-subtle); + text-decoration: underline; +} + +.breadcrumb .separator { + margin: 0 4px; + color: var(--text-secondary); + opacity: 0.5; +} + +.breadcrumb .nav-item.active { + font-weight: 600; + color: var(--text-primary); + padding: 2px 6px; +} + +/* File List */ +.file-list-container { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--bg); } -.file-item.file .icon { - color: var(--secondary); +.file-header { + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + padding: 12px 16px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.file-row { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row:hover { + background: var(--hover); } -.readme-section { - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid var(--border); +.file-row .icon img { + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 12px; +} + +.file-row .name a { + color: var(--text-primary); + text-decoration: none; + font-size: 14px; } -.readme-section h2 { - margin-bottom: 1rem; - font-size: 1.5rem; +.file-row .name a:hover { + color: var(--accent); + text-decoration: underline; +} + +/* Readme */ +.readme-section { + margin-top: 32px; + border: 1px solid var(--border); + border-radius: 6px; +} + +.readme-header { + background: var(--bg-subtle); + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; } .readme-content { + padding: 32px; + background: var(--bg); + overflow-x: auto; + color: var(--text-primary); +} + +/* File Viewer Modal */ +.file-viewer-overlay { + position: fixed; + inset: 0; + background: var(--overlay); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +.file-viewer { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - padding: 1.5rem; - background: var(--code-bg); + width: 100%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.file-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + border-radius: 6px 6px 0 0; +} + +.file-viewer-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; } -.readme-content h1 { font-size: 1.75rem; margin-top: 1.5rem; } -.readme-content h2 { font-size: 1.5rem; margin-top: 1.25rem; } -.readme-content h3 { font-size: 1.25rem; margin-top: 1rem; } +.file-viewer-close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} -.readme-content h1:first-child, -.readme-content h2:first-child, -.readme-content h3:first-child { - margin-top: 0; +.file-viewer-close:hover { + background: var(--hover); +} + +.file-viewer-close img { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.file-viewer-content { + overflow: auto; + flex: 1; } -.readme-content ul, -.readme-content ol { - margin-left: 2rem; - margin-bottom: 1rem; +.file-viewer-content pre { + margin: 0; + padding: 16px; + background: var(--bg-code); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; +} + +.file-viewer-content code { + background: transparent; + padding: 0; } -.readme-content li { - margin-bottom: 0.5rem; +.file-viewer-loading { + padding: 40px; + text-align: center; + color: var(--text-secondary); } -.readme-content img { - max-width: 100%; - height: auto; - border-radius: 6px; +.file-viewer-line-numbers { + display: inline-block; + user-select: none; + text-align: right; + padding-right: 16px; + margin-right: 16px; + border-right: 1px solid var(--border); + color: var(--text-secondary); + opacity: 0.5; } -.empty-state { - text-align: center; - padding: 3rem 1rem; - color: var(--secondary); +/* Theme Toggle Button */ +.theme-toggle { + margin-left: auto; + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + transition: all 0.2s; } -.error-message { - background: var(--danger); - color: white; - padding: 1rem; - border-radius: 6px; - margin-bottom: 1rem; +.theme-toggle:hover { + background: var(--hover); + color: var(--text-primary); +} + +.theme-toggle svg { + flex-shrink: 0; +} + +/* Description */ +.description { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -/* Mobile responsive */ +/* =========================================== + Mobile Responsive + =========================================== */ @media (max-width: 768px) { - main { - padding: 1rem; + .app-container { + padding: 20px 15px; } - .file-item { - padding: 0.5rem 0.75rem; + .repo-container { + padding: 0 15px; + } + + .file-row { + padding: 8px 12px; } - .file-item .name { - font-size: 0.85rem; - } - - .clone-info { - padding: 0.75rem; - overflow-x: auto; - } - - .readme-content { - padding: 1rem; + .clone-box { + padding: 10px 12px; + flex-direction: column; + align-items: flex-start; + gap: 8px; } } + +/* =========================================== + Footer + =========================================== */ +.footer { + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.footer-content { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px 0; + color: var(--text-secondary); + font-size: 13px; +} + +.footer-separator { + opacity: 0.5; +} + +.footer-text { + color: var(--text-secondary); +} + +.clone-box-inner { + display: flex; + align-items: center; + width: 100%; +} + +/* Header content wrapper */ +.header-content { + flex: 1; +} + +.header-content h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header-content h1 a { + color: inherit; + text-decoration: none; +} + +.header-content h1 a:hover { + text-decoration: none; +}
--- a/hg-web/src/index.html Sat Jan 24 06:37:43 2026 -0800 +++ b/hg-web/src/index.html Tue Jan 27 06:51:44 2026 -0800 @@ -1,39 +1,23 @@ <!DOCTYPE html> <html lang="en"> -<head> + <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Zenbu Repository</title> + <link rel="stylesheet" href="/index.css"> <link rel="stylesheet" href="/base.css"> - <link rel="stylesheet" href="/index.css"> -</head> -<body> - <main> - <div class="header"> - <h1>Zenbu Repository</h1> - <p class="description">Browse and clone this mercurial repository</p> - </div> - <div class="clone-info"> - <strong>Clone this repository:</strong><br> - <code>hg clone http://zenbu.babocoder.com</code> - </div> - - <div class="breadcrumb" id="breadcrumb"></div> + <link rel="stylesheet" href="/a11y-dark.min.css" media="(prefers-color-scheme: dark)"> + <link rel="stylesheet" href="/a11y-light.min.css" media="(prefers-color-scheme: light)"> - <div class="file-list" id="fileList"></div> - - <div class="readme-section" id="readmeSection" style="display: none;"> - <h2>README</h2> - <div class="readme-content" id="readmeContent"></div> - </div> - - <div class="empty-state" id="emptyState" style="display: none;"> - <p>No files found in this directory</p> - </div> + <link rel="preload" href="/public/fonts/more-sugar.regular.otf" as="font" type="font/otf" crossorigin> + <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg"> + </head> + <body> + <main> + <div id="root"></div> </main> - - <script src="/markdown_to_html.js"></script> - <script src="/index.js"></script> -</body> + <script type="module" src="/page.js"></script> + <script src="/highlight.min.js"></script> + </body> </html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/main.tsx Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,6 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from "hg-web/src/components/app"; + +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render(<App />);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/load_test/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,7 @@ +# load_test + +Load testing and performance measurement scripts. + +## Files + +- `main.py` - Python load testing script
--- a/markdown_converter/BUILD Sat Jan 24 06:37:43 2026 -0800 +++ b/markdown_converter/BUILD Tue Jan 27 06:51:44 2026 -0800 @@ -38,6 +38,9 @@ "-sALLOW_MEMORY_GROWTH", # Allow memory to grow dynamically "-sEXPORTED_FUNCTIONS=['_markdown_to_html','_markdown_free','_markdown_get_length','_wasm_alloc','_wasm_free']", "-sEXPORTED_RUNTIME_METHODS=['cwrap','ccall','UTF8ToString','stringToUTF8','lengthBytesUTF8']", + "-sMODULARIZE", # Output as factory function + "-sEXPORT_ES6", # Use ES6 module exports + "-sENVIRONMENT=web", # Browser-only code (no Node.js builtins) ], tags=["manual"], )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,52 @@ +# markdown_converter + +Markdown to HTML converter written in C, with WASM support for browser use. + +## Features + +- Headings, paragraphs, lists +- Code blocks with syntax highlighting +- Links and images +- Bold, italic, inline code +- Blockquotes + +## Files + +| File | Description | +|------|-------------| +| `markdown_to_html.h` | Public API header | +| `markdown_to_html.c` | C implementation | +| `markdown_to_html.js` | JavaScript wrapper for WASM | +| `markdown_to_html.css` | Default styles | +| `tests/` | Test cases | + +## Usage (C) + +```c +#include "markdown_converter/markdown_to_html.h" + +char* html = markdown_to_html(markdown_string); +// use html... +markdown_free(html); +``` + +## Usage (WASM/JavaScript) + +```javascript +import createModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; + +const Module = await createModule(); +const toHtml = Module.cwrap('markdown_to_html', 'number', ['string']); +const free = Module.cwrap('markdown_free', null, ['number']); + +const ptr = toHtml(markdownString); +const html = Module.UTF8ToString(ptr); +free(ptr); +``` + +## Building + +```bash +bazel build //markdown_converter:markdown_to_html +bazel build //markdown_converter:markdown_to_html_wasm # WASM version +```
--- a/markdown_converter/markdown_to_html.c Sat Jan 24 06:37:43 2026 -0800 +++ b/markdown_converter/markdown_to_html.c Tue Jan 27 06:51:44 2026 -0800 @@ -84,6 +84,9 @@ } } +// Forward declaration +static void process_inline(StringBuffer *buf, const char *text, size_t len); + // Check if line starts with pattern (after trimming whitespace) static int starts_with(const char *line, const char *pattern) { @@ -187,6 +190,157 @@ return *line == '.' && line[1] == ' '; } +// Check if line could be a table row (contains |) +static int is_table_row(const char *line) +{ + line = skip_whitespace(line); + // Must contain at least one | + return strchr(line, '|') != NULL; +} + +// Check if line is a table separator (|---|---|) +static int is_table_separator(const char *line) +{ + line = skip_whitespace(line); + int has_dash = 0; + int has_pipe = 0; + + while (*line) { + char c = *line; + if (c == '|') has_pipe = 1; + else if (c == '-') has_dash = 1; + else if (c == ':') ; // alignment marker, allowed + else if (isspace((unsigned char)c)) ; // whitespace allowed + else return 0; // invalid character for separator + line++; + } + + return has_dash && has_pipe; +} + +// Parse alignment from separator cell (e.g., ":---:", "---:", ":---") +// Returns: 0 = left (default), 1 = center, 2 = right +static int parse_alignment(const char *cell, size_t len) +{ + // Trim whitespace + while (len > 0 && isspace((unsigned char)*cell)) { cell++; len--; } + while (len > 0 && isspace((unsigned char)cell[len-1])) { len--; } + + if (len == 0) return 0; + + int left_colon = (cell[0] == ':'); + int right_colon = (len > 0 && cell[len-1] == ':'); + + if (left_colon && right_colon) return 1; // center + if (right_colon) return 2; // right + return 0; // left (default) +} + +// Count columns in a table row +static int count_table_columns(const char *line) +{ + int count = 0; + int in_cell = 0; + line = skip_whitespace(line); + + // Skip leading | + if (*line == '|') line++; + + while (*line) { + if (*line == '|') { + count++; + in_cell = 0; + } else if (!isspace((unsigned char)*line)) { + in_cell = 1; + } + line++; + } + + // Count last cell if there was content after last | + if (in_cell) count++; + + return count > 0 ? count : 1; +} + +// Parse table cells and call callback for each +typedef void (*cell_callback)(StringBuffer *buf, const char *cell, size_t len, int align, int is_header); + +static void parse_table_row(StringBuffer *buf, const char *line, int *alignments, int num_cols, int is_header, cell_callback cb) +{ + line = skip_whitespace(line); + + // Skip leading | + if (*line == '|') line++; + + int col = 0; + const char *cell_start = line; + + while (*line && col < num_cols) { + if (*line == '|' || *(line + 1) == '\0') { + // End of cell + size_t cell_len = line - cell_start; + if (*line != '|') cell_len++; // include last char if no trailing | + + // Trim whitespace from cell + while (cell_len > 0 && isspace((unsigned char)*cell_start)) { cell_start++; cell_len--; } + while (cell_len > 0 && isspace((unsigned char)cell_start[cell_len-1])) { cell_len--; } + + int align = (alignments && col < num_cols) ? alignments[col] : 0; + cb(buf, cell_start, cell_len, align, is_header); + + col++; + cell_start = line + 1; + } + line++; + } + + // Fill remaining columns with empty cells + while (col < num_cols) { + cb(buf, "", 0, alignments ? alignments[col] : 0, is_header); + col++; + } +} + +static void emit_table_cell(StringBuffer *buf, const char *cell, size_t len, int align, int is_header) +{ + const char *tag = is_header ? "th" : "td"; + const char *align_attr = ""; + + if (align == 1) align_attr = " style=\"text-align:center\""; + else if (align == 2) align_attr = " style=\"text-align:right\""; + + buffer_append(buf, "<"); + buffer_append(buf, tag); + buffer_append(buf, align_attr); + buffer_append(buf, ">"); + process_inline(buf, cell, len); + buffer_append(buf, "</"); + buffer_append(buf, tag); + buffer_append(buf, ">"); +} + +// Parse alignments from separator row +static void parse_alignments(const char *line, int *alignments, int num_cols) +{ + line = skip_whitespace(line); + if (*line == '|') line++; + + int col = 0; + const char *cell_start = line; + + while (*line && col < num_cols) { + if (*line == '|' || *(line + 1) == '\0') { + size_t cell_len = line - cell_start; + if (*line != '|') cell_len++; + + alignments[col] = parse_alignment(cell_start, cell_len); + col++; + cell_start = line + 1; + } + line++; + } +} + // Process inline markdown (bold, italic, code, links, strikethrough) static void process_inline(StringBuffer *buf, const char *text, size_t len) { @@ -532,6 +686,79 @@ continue; } + // Table: | col1 | col2 | followed by |---|---| + if (is_table_row(line)) { + // Peek at next line to see if it's a separator + const char *peek_ptr = ptr; + if (*peek_ptr == '\n') peek_ptr++; + + const char *next_line_start = peek_ptr; + while (*peek_ptr && *peek_ptr != '\n') peek_ptr++; + size_t next_line_len = peek_ptr - next_line_start; + + char *next_line = (char *)malloc(next_line_len + 1); + if (next_line) { + memcpy(next_line, next_line_start, next_line_len); + next_line[next_line_len] = '\0'; + + if (is_table_separator(next_line)) { + // It's a table! + int num_cols = count_table_columns(line); + int *alignments = (int *)calloc(num_cols, sizeof(int)); + + buffer_append(buf, "<table>"); + + // Header row + buffer_append(buf, "<thead><tr>"); + parse_table_row(buf, line, NULL, num_cols, 1, emit_table_cell); + buffer_append(buf, "</tr></thead>"); + + free(line); + if (*ptr == '\n') ptr++; + + // Parse alignments from separator + parse_alignments(next_line, alignments, num_cols); + free(next_line); + + // Skip separator line + ptr = peek_ptr; + if (*ptr == '\n') ptr++; + + // Body rows + buffer_append(buf, "<tbody>"); + + while (*ptr) { + line_start = ptr; + while (*ptr && *ptr != '\n') ptr++; + line_len = ptr - line_start; + + line = (char *)malloc(line_len + 1); + if (!line) break; + memcpy(line, line_start, line_len); + line[line_len] = '\0'; + + if (!is_table_row(line) || is_empty_line(line)) { + ptr = line_start; + free(line); + break; + } + + buffer_append(buf, "<tr>"); + parse_table_row(buf, line, alignments, num_cols, 0, emit_table_cell); + buffer_append(buf, "</tr>"); + + free(line); + if (*ptr == '\n') ptr++; + } + + buffer_append(buf, "</tbody></table>"); + free(alignments); + continue; + } + free(next_line); + } + } + // HTML block - pass through unchanged if (is_html_block_start(line)) { // Check if it's a script or style tag that needs special handling @@ -607,6 +834,7 @@ is_horizontal_rule(line) || is_unordered_list(line) || is_ordered_list(line) || + is_table_row(line) || is_html_block_start(line)) { ptr = line_start; free(line);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/markdown_to_html.css Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,133 @@ +/* markdown panel */ +#markdown { + flex: 1; + padding: 15px; + border: 1px solid rgba(var(--gray), 0.3); + border-radius: 4px; + background: var(--white); + color: rgb(var(--gray-dark)); + overflow-y: auto; + font-size: 15px; +} + +/* Markdown markdown styles */ +#markdown h1, #markdown h2, #markdown h3, #markdown h4, #markdown h5, #markdown h6 { + margin: 1em 0 0.5em 0; + color: rgb(var(--gray-dark)); + line-height: 1.3; +} + +#markdown h1:first-child, #markdown h2:first-child, #markdown h3:first-child { + margin-top: 0; +} + +#markdown h1 { font-size: 1.8em; } +#markdown h2 { font-size: 1.5em; } +#markdown h3 { font-size: 1.25em; } +#markdown h4 { font-size: 1.1em; } + +#markdown p { + margin: 0.8em 0; +} + +#markdown ul, #markdown ol { + margin: 0.8em 0; + padding-left: 25px; +} + +#markdown li { + margin: 0.3em 0; +} + +#markdown code { + background: rgb(var(--gray-light)); + padding: 2px 6px; + border-radius: 3px; + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + font-size: 0.9em; + color: var(--accent); +} + +#markdown pre { + background: var(--black); + color: var(--white); + padding: 15px; + border-radius: 6px; + overflow-x: auto; + margin: 1em 0; + line-height: 1.4; +} + +#markdown pre code { + background: none; + color: inherit; + padding: 0; +} + +#markdown blockquote { + border-left: 4px solid var(--accent); + padding: 10px 15px; + margin: 1em 0; + background: rgb(var(--gray-light)); + color: rgb(var(--gray)); + font-style: italic; +} + +#markdown a { + color: var(--accent); + text-decoration: none; +} + +#markdown a:hover { + text-decoration: underline; +} + +#markdown hr { + border: none; + border-top: 1px solid rgb(var(--gray-light)); + margin: 1.5em 0; +} + +#markdown img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +#markdown strong { + font-weight: 600; +} + +#markdown em { + font-style: italic; +} + +#markdown del { + text-decoration: line-through; + color: rgb(var(--gray)); +} + +/* Mobile */ +@media (max-width: 900px) { + body { + padding: 15px; + } + + .container { + grid-template-columns: 1fr; + height: auto; + gap: 15px; + } + + .panel { + min-height: 350px; + } + + textarea { + min-height: 300px; + } + + #markdown { + min-height: 300px; + } +}
--- a/markdown_converter/wasm/BUILD Sat Jan 24 06:37:43 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,22 +0,0 @@ -# load("@rules_cc//cc:defs.bzl", "cc_library") -# load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary") -# -# package(default_visibility = ["//visibility:public"]) -# -# cc_library( -# name = "hello-world", -# srcs = ["markdown_to_html_wasm.c"], -# ) -# -# wasm_cc_binary( -# name = "markdown_to_html_wasm", -# cc_target = ":hello-world", -# ) -# -# # JS to link wasm with -# filegroup( -# name = "markdown_to_html_wasm_js", -# srcs = glob([ -# "**/*.js", -# ], allow_empty=True), -# )
--- a/markdown_converter/wasm/markdown_to_html_wasm.c Sat Jan 24 06:37:43 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,698 +0,0 @@ -/** - * Markdown to HTML Converter - Standalone WASM Implementation - * No libc dependencies - can be compiled with: clang --target=wasm32 - */ - -#define WASM_EXPORT __attribute__((visibility("default"))) - -typedef unsigned long size_t; -typedef int int32_t; - -// Simple bump allocator for WASM -#define HEAP_SIZE (1024 * 1024) // 1MB heap -static char heap[HEAP_SIZE]; -static size_t heap_offset = 0; - -WASM_EXPORT void *malloc(size_t size) -{ - // Align to 8 bytes - size_t aligned_offset = (heap_offset + 7) & ~7; - if (aligned_offset + size > HEAP_SIZE) return 0; - - void *ptr = &heap[aligned_offset]; - heap_offset = aligned_offset + size; - return ptr; -} - -WASM_EXPORT void free(void *ptr) -{ - // Simple bump allocator - no actual free - (void)ptr; -} - -WASM_EXPORT void heap_reset(void) -{ - heap_offset = 0; -} - -// String functions -static size_t strlen(const char *s) -{ - size_t len = 0; - while (s[len]) len++; - return len; -} - -static void *memcpy(void *dest, const void *src, size_t n) -{ - char *d = (char *)dest; - const char *s = (const char *)src; - while (n--) *d++ = *s++; - return dest; -} - -static int isspace_c(int c) -{ - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; -} - -static int isdigit_c(int c) -{ - return c >= '0' && c <= '9'; -} - -// String buffer for building HTML output -typedef struct { - char *data; - size_t length; - size_t capacity; -} StringBuffer; - -static StringBuffer *buffer_create(size_t initial_capacity) -{ - StringBuffer *buf = (StringBuffer *)malloc(sizeof(StringBuffer)); - if (!buf) return 0; - - buf->data = (char *)malloc(initial_capacity); - if (!buf->data) return 0; - - buf->data[0] = '\0'; - buf->length = 0; - buf->capacity = initial_capacity; - return buf; -} - -static void buffer_grow(StringBuffer *buf, size_t needed) -{ - if (buf->length + needed + 1 > buf->capacity) { - size_t new_capacity = buf->capacity * 2; - while (new_capacity < buf->length + needed + 1) - new_capacity *= 2; - - char *new_data = (char *)malloc(new_capacity); - if (new_data) { - memcpy(new_data, buf->data, buf->length + 1); - buf->data = new_data; - buf->capacity = new_capacity; - } - } -} - -static void buffer_append(StringBuffer *buf, const char *str) -{ - size_t len = strlen(str); - buffer_grow(buf, len); - memcpy(buf->data + buf->length, str, len + 1); - buf->length += len; -} - -static void buffer_append_n(StringBuffer *buf, const char *str, size_t n) -{ - buffer_grow(buf, n); - memcpy(buf->data + buf->length, str, n); - buf->length += n; - buf->data[buf->length] = '\0'; -} - -static void buffer_append_char(StringBuffer *buf, char c) -{ - buffer_grow(buf, 1); - buf->data[buf->length++] = c; - buf->data[buf->length] = '\0'; -} - -// Check if line starts with pattern (after trimming whitespace) -static int starts_with(const char *line, const char *pattern) -{ - while (*line && isspace_c(*line)) line++; - size_t plen = strlen(pattern); - for (size_t i = 0; i < plen; i++) { - if (line[i] != pattern[i]) return 0; - } - return 1; -} - -// Count leading # characters -static int count_heading_level(const char *line) -{ - int count = 0; - while (*line && isspace_c(*line)) line++; - while (line[count] == '#' && count < 6) count++; - if (count > 0 && line[count] == ' ') return count; - return 0; -} - -// Skip whitespace -static const char *skip_whitespace(const char *str) -{ - while (*str && isspace_c(*str)) str++; - return str; -} - -// Check if line is empty -static int is_empty_line(const char *line) -{ - while (*line) { - if (!isspace_c(*line)) return 0; - line++; - } - return 1; -} - -// Check if line is horizontal rule -static int is_horizontal_rule(const char *line) -{ - line = skip_whitespace(line); - char first = *line; - if (first != '-' && first != '*' && first != '_') return 0; - - int count = 0; - while (*line) { - if (*line == first) count++; - else if (!isspace_c(*line)) return 0; - line++; - } - return count >= 3; -} - -// Check if line is unordered list item -static int is_unordered_list(const char *line) -{ - line = skip_whitespace(line); - return (*line == '-' || *line == '*' || *line == '+') && line[1] == ' '; -} - -// Check if line is ordered list item -static int is_ordered_list(const char *line) -{ - line = skip_whitespace(line); - while (*line && isdigit_c(*line)) line++; - return *line == '.' && line[1] == ' '; -} - -// Check if line is a table row (starts with |) -static int is_table_row(const char *line) -{ - line = skip_whitespace(line); - return *line == '|'; -} - -// Check if line is a table separator row (| --- | --- |) -static int is_table_separator(const char *line) -{ - line = skip_whitespace(line); - if (*line != '|') return 0; - line++; - - int has_dash = 0; - while (*line) { - if (*line == '-') has_dash = 1; - else if (*line == '|' || *line == ':' || isspace_c(*line)) { /* ok */ } - else return 0; - line++; - } - return has_dash; -} - -// Forward declaration for process_inline -static void process_inline(StringBuffer *buf, const char *text, size_t len); - -// Parse table cells from a row and append to buffer -static void parse_table_row(StringBuffer *buf, const char *line, int is_header) -{ - const char *cell_tag = is_header ? "th" : "td"; - - buffer_append(buf, "<tr>"); - - line = skip_whitespace(line); - if (*line == '|') line++; // Skip leading | - - while (*line) { - // Skip whitespace before cell content - while (*line && isspace_c(*line)) line++; - - // Find cell end (next | or end of line) - const char *cell_start = line; - while (*line && *line != '|') line++; - - // Trim trailing whitespace from cell - const char *cell_end = line; - while (cell_end > cell_start && isspace_c(*(cell_end - 1))) cell_end--; - - size_t cell_len = cell_end - cell_start; - - // Only output cell if we have content or more cells coming - if (cell_len > 0 || *line == '|') { - buffer_append(buf, "<"); - buffer_append(buf, cell_tag); - buffer_append(buf, ">"); - if (cell_len > 0) { - process_inline(buf, cell_start, cell_len); - } - buffer_append(buf, "</"); - buffer_append(buf, cell_tag); - buffer_append(buf, ">"); - } - - if (*line == '|') line++; // Skip | - - // Check if this was the trailing | - const char *rest = line; - while (*rest && isspace_c(*rest)) rest++; - if (!*rest) break; // End of line after trailing | - } - - buffer_append(buf, "</tr>"); -} - -// Process inline markdown -static void process_inline(StringBuffer *buf, const char *text, size_t len) -{ - size_t i = 0; - - while (i < len) { - // Links: [text](url) - if (text[i] == '[') { - size_t link_start = i + 1; - size_t link_end = link_start; - while (link_end < len && text[link_end] != ']') link_end++; - - if (link_end < len && link_end + 1 < len && text[link_end + 1] == '(') { - size_t url_start = link_end + 2; - size_t url_end = url_start; - while (url_end < len && text[url_end] != ')') url_end++; - - if (url_end < len) { - buffer_append(buf, "<a href=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\">"); - buffer_append_n(buf, text + link_start, link_end - link_start); - buffer_append(buf, "</a>"); - i = url_end + 1; - continue; - } - } - } - - // Images:  - if (text[i] == '!' && i + 1 < len && text[i + 1] == '[') { - size_t alt_start = i + 2; - size_t alt_end = alt_start; - while (alt_end < len && text[alt_end] != ']') alt_end++; - - if (alt_end < len && alt_end + 1 < len && text[alt_end + 1] == '(') { - size_t url_start = alt_end + 2; - size_t url_end = url_start; - while (url_end < len && text[url_end] != ')') url_end++; - - if (url_end < len) { - buffer_append(buf, "<img src=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\" alt=\""); - buffer_append_n(buf, text + alt_start, alt_end - alt_start); - buffer_append(buf, "\">"); - i = url_end + 1; - continue; - } - } - } - - // Bold: **text** or __text__ - if ((text[i] == '*' && i + 1 < len && text[i + 1] == '*') || - (text[i] == '_' && i + 1 < len && text[i + 1] == '_')) { - char marker = text[i]; - size_t start = i + 2; - size_t end = start; - while (end + 1 < len && !(text[end] == marker && text[end + 1] == marker)) end++; - - if (end + 1 < len) { - buffer_append(buf, "<strong>"); - process_inline(buf, text + start, end - start); - buffer_append(buf, "</strong>"); - i = end + 2; - continue; - } - } - - // Strikethrough: ~~text~~ - if (text[i] == '~' && i + 1 < len && text[i + 1] == '~') { - size_t start = i + 2; - size_t end = start; - while (end + 1 < len && !(text[end] == '~' && text[end + 1] == '~')) end++; - - if (end + 1 < len) { - buffer_append(buf, "<del>"); - process_inline(buf, text + start, end - start); - buffer_append(buf, "</del>"); - i = end + 2; - continue; - } - } - - // Italic: *text* or _text_ - if ((text[i] == '*' || text[i] == '_') && i + 1 < len && !isspace_c(text[i + 1])) { - char marker = text[i]; - size_t start = i + 1; - size_t end = start; - while (end < len && text[end] != marker) end++; - - if (end < len && end > start) { - buffer_append(buf, "<em>"); - process_inline(buf, text + start, end - start); - buffer_append(buf, "</em>"); - i = end + 1; - continue; - } - } - - // Inline code: `code` - if (text[i] == '`') { - size_t start = i + 1; - size_t end = start; - while (end < len && text[end] != '`') end++; - - if (end < len) { - buffer_append(buf, "<code>"); - buffer_append_n(buf, text + start, end - start); - buffer_append(buf, "</code>"); - i = end + 1; - continue; - } - } - - // HTML escape - if (text[i] == '<') { - buffer_append(buf, "<"); - } else if (text[i] == '>') { - buffer_append(buf, ">"); - } else if (text[i] == '&') { - buffer_append(buf, "&"); - } else { - buffer_append_char(buf, text[i]); - } - i++; - } -} - -// Append heading tag -static void append_heading_tag(StringBuffer *buf, int level, int closing) -{ - buffer_append_char(buf, '<'); - if (closing) buffer_append_char(buf, '/'); - buffer_append_char(buf, 'h'); - buffer_append_char(buf, '0' + level); - buffer_append_char(buf, '>'); -} - -// Convert markdown to HTML -WASM_EXPORT char *markdown_to_html(const char *markdown) -{ - if (!markdown) return 0; - - StringBuffer *buf = buffer_create(4096); - if (!buf) return 0; - - const char *ptr = markdown; - const char *line_start; - - while (*ptr) { - line_start = ptr; - - // Find end of line - while (*ptr && *ptr != '\n') ptr++; - size_t line_len = ptr - line_start; - - // Create line copy - char *line = (char *)malloc(line_len + 1); - if (!line) return buf->data; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - // Skip empty lines - if (is_empty_line(line)) { - if (*ptr == '\n') ptr++; - continue; - } - - // Headings - int heading_level = count_heading_level(line); - if (heading_level > 0) { - const char *content = skip_whitespace(line); - while (*content == '#') content++; - content = skip_whitespace(content); - - append_heading_tag(buf, heading_level, 0); - process_inline(buf, content, strlen(content)); - append_heading_tag(buf, heading_level, 1); - - if (*ptr == '\n') ptr++; - continue; - } - - // Code block - if (starts_with(line, "```")) { - buffer_append(buf, "<pre><code>"); - if (*ptr == '\n') ptr++; - - while (*ptr) { - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - char *code_line = (char *)malloc(line_len + 1); - if (!code_line) break; - memcpy(code_line, line_start, line_len); - code_line[line_len] = '\0'; - - if (starts_with(code_line, "```")) { - if (*ptr == '\n') ptr++; - break; - } - - for (size_t i = 0; i < line_len; i++) { - if (code_line[i] == '<') buffer_append(buf, "<"); - else if (code_line[i] == '>') buffer_append(buf, ">"); - else if (code_line[i] == '&') buffer_append(buf, "&"); - else buffer_append_char(buf, code_line[i]); - } - buffer_append_char(buf, '\n'); - - if (*ptr == '\n') ptr++; - } - - buffer_append(buf, "</code></pre>"); - continue; - } - - // Blockquote - if (starts_with(line, ">")) { - buffer_append(buf, "<blockquote>"); - - while (1) { - const char *content = skip_whitespace(line); - if (*content == '>') content++; - content = skip_whitespace(content); - process_inline(buf, content, strlen(content)); - buffer_append_char(buf, ' '); - - if (*ptr == '\n') ptr++; - if (!*ptr) break; - - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - if (!starts_with(line, ">")) { - ptr = line_start; - break; - } - } - - buffer_append(buf, "</blockquote>"); - continue; - } - - // Horizontal rule - if (is_horizontal_rule(line)) { - buffer_append(buf, "<hr>"); - if (*ptr == '\n') ptr++; - continue; - } - - // Unordered list - if (is_unordered_list(line)) { - buffer_append(buf, "<ul>"); - - while (1) { - const char *content = skip_whitespace(line); - content += 2; - - buffer_append(buf, "<li>"); - process_inline(buf, content, strlen(content)); - buffer_append(buf, "</li>"); - - if (*ptr == '\n') ptr++; - if (!*ptr) break; - - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - if (!is_unordered_list(line)) { - ptr = line_start; - break; - } - } - - buffer_append(buf, "</ul>"); - continue; - } - - // Ordered list - if (is_ordered_list(line)) { - buffer_append(buf, "<ol>"); - - while (1) { - const char *content = skip_whitespace(line); - while (*content && isdigit_c(*content)) content++; - if (*content == '.') content++; - content = skip_whitespace(content); - - buffer_append(buf, "<li>"); - process_inline(buf, content, strlen(content)); - buffer_append(buf, "</li>"); - - if (*ptr == '\n') ptr++; - if (!*ptr) break; - - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - if (!is_ordered_list(line)) { - ptr = line_start; - break; - } - } - - buffer_append(buf, "</ol>"); - continue; - } - - // Table - if (is_table_row(line)) { - // Check if next line is a separator (to confirm this is a table) - const char *peek_ptr = ptr; - if (*peek_ptr == '\n') peek_ptr++; - - const char *next_line_start = peek_ptr; - while (*peek_ptr && *peek_ptr != '\n') peek_ptr++; - size_t next_line_len = peek_ptr - next_line_start; - - char *next_line = (char *)malloc(next_line_len + 1); - if (next_line) { - memcpy(next_line, next_line_start, next_line_len); - next_line[next_line_len] = '\0'; - - if (is_table_separator(next_line)) { - // It's a valid table - buffer_append(buf, "<table>"); - - // Header row - buffer_append(buf, "<thead>"); - parse_table_row(buf, line, 1); - buffer_append(buf, "</thead>"); - - // Skip to after separator - if (*ptr == '\n') ptr++; - ptr = peek_ptr; - if (*ptr == '\n') ptr++; - - // Body rows - buffer_append(buf, "<tbody>"); - while (*ptr) { - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - if (!is_table_row(line) || is_empty_line(line)) { - ptr = line_start; - break; - } - - parse_table_row(buf, line, 0); - if (*ptr == '\n') ptr++; - } - buffer_append(buf, "</tbody>"); - - buffer_append(buf, "</table>"); - continue; - } - } - } - - // Paragraph - buffer_append(buf, "<p>"); - - while (1) { - const char *content = skip_whitespace(line); - process_inline(buf, content, strlen(content)); - - if (*ptr == '\n') ptr++; - if (!*ptr) break; - - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - if (is_empty_line(line) || - count_heading_level(line) > 0 || - starts_with(line, "```") || - starts_with(line, ">") || - is_horizontal_rule(line) || - is_unordered_list(line) || - is_ordered_list(line) || - is_table_row(line)) { - ptr = line_start; - break; - } - - buffer_append_char(buf, ' '); - } - - buffer_append(buf, "</p>"); - } - - return buf->data; -} - -// Get string length (for JS interop) -WASM_EXPORT size_t markdown_strlen(const char *str) -{ - return str ? strlen(str) : 0; -}
--- a/markdown_converter/wasm/markdown_to_html_wasm.js Sat Jan 24 06:37:43 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,143 +0,0 @@ -/** - * Markdown to HTML Converter - WASM FFI Wrapper - * Loads the C implementation via WebAssembly and provides JavaScript API - * - * Build the WASM with: bazel build //markdown_converter:markdown_to_html_wasm - */ - -class MarkdownConverterWasm { - constructor() { - this.wasm = undefined; - this.ready = false; - } - - /** - * Initialize the WASM module - * @param {string} wasmPath - Path to the .wasm file - * @returns {Promise<void>} - */ - async init(wasmPath = '/markdown_to_html_wasm.wasm') { - if (this.ready) return; - - try { - this.wasm = await WebAssembly.instantiateStreaming(fetch(wasmPath), { - env: {} - }); - this.ready = true; - } catch (err) { - console.error('Failed to load markdown WASM module:', err); - throw err; - } - } - - /** - * Write string to WASM memory - * @private - */ - _writeString(str) { - const encoder = new TextEncoder(); - const bytes = encoder.encode(str + '\0'); - const ptr = this.wasm.instance.exports.malloc(bytes.length); - const memory = new Uint8Array(this.wasm.instance.exports.memory.buffer); - memory.set(bytes, ptr); - return ptr; - } - - /** - * Read string from WASM memory - * @private - */ - _readString(ptr) { - const memory = new Uint8Array(this.wasm.instance.exports.memory.buffer); - let end = ptr; - while (memory[end] !== 0) end++; - const bytes = memory.slice(ptr, end); - const decoder = new TextDecoder(); - return decoder.decode(bytes); - } - - /** - * Reset the WASM heap (call between conversions to reclaim memory) - */ - resetHeap() { - if (this.ready) { - this.wasm.instance.exports.heap_reset(); - } - } - - /** - * Convert markdown string to HTML string using WASM - * @param {string} markdown - The markdown text to convert - * @returns {string} The converted HTML - */ - convert(markdown) { - if (!this.ready) { - throw new Error('WASM module not initialized. Call init() first.'); - } - - // Reset heap before each conversion to reclaim memory - this.resetHeap(); - - // Write markdown to WASM memory - const inputPtr = this._writeString(markdown); - - // Call the C function - const outputPtr = this.wasm.instance.exports.markdown_to_html(inputPtr); - - // Read the result - const html = this._readString(outputPtr); - - return html; - } - - /** - * Convert markdown to DOM elements (compatible with original API) - * @param {string} markdown - The markdown text to convert - * @returns {Array<HTMLElement>} Array of DOM elements - */ - convertToElements(markdown) { - const html = this.convert(markdown); - const template = document.createElement('template'); - template.innerHTML = html; - return Array.from(template.content.children); - } -} - -// Singleton instance -const markdownWasm = new MarkdownConverterWasm(); - -/** - * Convert markdown to DOM elements (WASM version) - * Compatible with original markdownConverter() API - * @param {string} value - Markdown string - * @returns {Promise<Array<HTMLElement>>} Array of DOM elements - */ -async function markdownConverterWasm(value) { - if (!markdownWasm.ready) { - await markdownWasm.init(); - } - return markdownWasm.convertToElements(value); -} - -/** - * Render markdown to a container element (WASM version) - * Compatible with original renderMarkdown() API - * @param {HTMLElement} container - Container element - * @param {string} markdown - Markdown string - */ -async function renderMarkdownWasm(container, markdown) { - if (!markdownWasm.ready) { - await markdownWasm.init(); - } - container.innerHTML = markdownWasm.convert(markdown); -} - -// Export for use in other files -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - MarkdownConverterWasm, - markdownWasm, - markdownConverterWasm, - renderMarkdownWasm - }; -}
--- a/mrjunejune/BUILD Sat Jan 24 06:37:43 2026 -0800 +++ b/mrjunejune/BUILD Tue Jan 27 06:51:44 2026 -0800 @@ -27,6 +27,18 @@ ], dest = "src/public/highlight", ) + +filegroup( + name = "public_files", + srcs = glob(["src/public/*"]), + visibility = ["//visibility:public"], +) + +filegroup( + name = "public_fonts_files", + srcs = glob(["src/public/fonts/*"]), + visibility = ["//visibility:public"], +) filegroup( name = "src_files",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/npc/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,13 @@ +# npc + +NPC/character system application. + +## Files + +- `main.c` - Main application code + +## Building + +```bash +bazel build //npc:npc +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/postdog/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,30 @@ +# postdog + +GUI application built with raylib. HTTP client with a graphical interface (like Postman). + +## Features + +- HTTP request builder +- Response viewer +- Request history + +## Files + +| File | Description | +|------|-------------| +| `main.c` | Main application | +| `gui_window_file_dialog.h` | File dialog implementation | +| `Roboto-Regular.ttf` | UI font | +| `epi_all_colors.png` | Logo | +| `history/` | Saved request history | + +## Building + +```bash +bazel build //postdog:postdog +bazel run //postdog:postdog +``` + +## Dependencies + +- raylib (via //third_party/raylib)
--- a/react_games/public/base.css Sat Jan 24 06:37:43 2026 -0800 +++ b/react_games/public/base.css Tue Jan 27 06:51:44 2026 -0800 @@ -5,7 +5,6 @@ } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 10%, #764ba2 100%); background-attachment: fixed; background-repeat: no-repeat;
--- a/seobeo/BUILD Sat Jan 24 06:37:43 2026 -0800 +++ b/seobeo/BUILD Tue Jan 27 06:51:44 2026 -0800 @@ -308,7 +308,7 @@ "//dowa:dowa", "@openssl//:ssl", ], - defines = ["SEOBEO_WEBSOCKET_SERVER"], + defines = ["SEOBEO_WEBSOCKET_SERVER", "SEOBEO_ENABLE_DEBUG"], target_compatible_with = [ "@platforms//os:osx", ], @@ -334,7 +334,7 @@ "//dowa:dowa", "@openssl//:ssl", ], - defines = ["SEOBEO_WEBSOCKET_SERVER"], + defines = ["SEOBEO_WEBSOCKET_SERVER", "SEOBEO_ENABLE_DEBUG"], target_compatible_with = [ "@platforms//os:linux", ],
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,49 @@ +# seobeo + +HTTP client and networking library for C. + +## Features + +- HTTP/HTTPS client +- SSL/TLS support +- Async networking with libuv +- Snapshot testing utilities + +## Files + +| File | Description | +|------|-------------| +| `seobeo.h` | Public API header | +| `seobeo_internal.h` | Internal declarations | +| `s_http_client.c` | HTTP client implementation | +| `s_network.c` | Network utilities | +| `s_ssl.c` | SSL/TLS handling | +| `s_logging.c` | Logging utilities | +| `snapshot_creator.c/h` | Snapshot testing | +| `docs/` | Documentation | +| `examples/` | Usage examples | +| `tests/` | Unit tests | +| `os/` | OS-specific code | + +## Usage + +```c +#include "seobeo/seobeo.h" + +// Make HTTP request +HttpResponse* resp = http_get("https://example.com"); +// handle response... +http_response_free(resp); +``` + +## Building + +```bash +bazel build //seobeo:seobeo +bazel test //seobeo:seobeo_test +``` + +## Dependencies + +- libuv (via //third_party/libuv) +- OpenSSL
--- a/seobeo/s_http_client.c Sat Jan 24 06:37:43 2026 -0800 +++ b/seobeo/s_http_client.c Tue Jan 27 06:51:44 2026 -0800 @@ -69,7 +69,7 @@ memset(p_req, 0, sizeof(Seobeo_Client_Request)); - p_req->p_arena = Dowa_Arena_Create(1024 * 1024); + p_req->p_arena = Dowa_Arena_Create(1024 * 1024 * 5); if (!p_req->p_arena) { free(p_req); @@ -231,7 +231,7 @@ memset(p_resp, 0, sizeof(Seobeo_Client_Response)); - p_resp->p_arena = Dowa_Arena_Create(1024 * 1024 * 5); // 5 MB + p_resp->p_arena = Dowa_Arena_Create(1024 * 1024 * 10); // 10 MB if (!p_resp->p_arena) { free(p_resp); @@ -391,7 +391,7 @@ } else { - size_t cap = 1024 * 1024 * 3; + size_t cap = 1024 * 1024 * 5; size_t used = 0; char *body = download_path ? NULL : Dowa_Arena_Allocate(p_resp->p_arena, cap); @@ -414,7 +414,6 @@ } memcpy(body + used, p_handle->read_buffer, p_handle->read_buffer_len); used += p_handle->read_buffer_len; - Seobeo_Log(SEOBEO_DEBUG, "Copied %zu bytes, total %zu/%zu\n", used, used + p_handle->read_buffer_len, body_len); } Seobeo_Handle_Consume(p_handle, (uint32)p_handle->read_buffer_len); }
--- a/seobeo/s_web.c Sat Jan 24 06:37:43 2026 -0800 +++ b/seobeo/s_web.c Tue Jan 27 06:51:44 2026 -0800 @@ -151,6 +151,11 @@ void *p_conn_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Connection"); const char *conn_header = p_conn_kv ? ((Seobeo_Request_Entry*)p_conn_kv)->value : NULL; + void *p_real_ip_kv = Dowa_HashMap_Get_Ptr(p_req_map, "X-Real-IP"); + const char *real_ip = p_real_ip_kv ? ((Seobeo_Request_Entry*)p_real_ip_kv)->value : NULL; + if (!real_ip) + real_ip = p_cli_handle->host; + if (conn_header) { if (connection_header_contains(conn_header, "close")) @@ -185,6 +190,7 @@ // --- Check for WebSocket upgrade request --- #ifdef SEOBEO_WEBSOCKET_SERVER + Seobeo_Log(SEOBEO_DEBUG, "Web socket path \n"); if (Seobeo_WebSocket_Server_Handle_Upgrade(p_cli_handle, p_req_map, path)) { Seobeo_Log(SEOBEO_INFO, "WebSocket connection established\n"); @@ -194,7 +200,15 @@ } #endif - // --- Try to match API route first --- + // --- Try to match streaming route first --- + Seobeo_Stream_Handler stream_handler = Seobeo_Router_Find_Stream_Handler(method, path, &p_req_map, p_request_arena); + if (stream_handler != NULL) + { + stream_handler(p_cli_handle, p_req_map, p_response_arena); + goto clean_up_arenas; + } + + // --- Try to match API route --- Seobeo_Route_Handler handler = Seobeo_Router_Find_Handler(method, path, &p_req_map, p_request_arena); if (handler != NULL) { @@ -333,7 +347,10 @@ break; if (r == 0) - return 1; // EAGAIN, try again later TODO: Add this as part of Handle struct. + { + Seobeo_Log(SEOBEO_INFO, "Waiting?\n"); + continue; // EAGAIN, try again later TODO: Add this as part of Handle struct. + } } // "METHOD SP PATH SP VERSION CRLF" @@ -448,7 +465,6 @@ char *next = strstr(line, "\r\n"); if (!next) break; - // split at colon char *colon = memchr(line, ':', next - line); if (colon) { @@ -472,7 +488,6 @@ memcpy(val, val_start, value_len); val[value_len] = '\0'; - // Both key and value are arena-allocated, hashmap will use them Dowa_HashMap_Push_Arena(*pp_map, key, val, p_arena); } @@ -490,7 +505,6 @@ Seobeo_Log(SEOBEO_DEBUG, "Content-Length=%zu, reading body in chunks...\n", body_len); - // Allocate buffer for entire body char *body = Dowa_Arena_Allocate(p_arena, body_len + 1); if (!body) { @@ -606,6 +620,7 @@ char *method; // "GET", "POST", "PUT", "DELETE" char *path_pattern; // "/v1/users/:id/posts/:post_id" Seobeo_Route_Handler handler; + Seobeo_Stream_Handler stream_handler; // For streaming responses // Pre-parsed path segments for efficient matching char **path_segments; // ["v1", "users", ":id", "posts", ":post_id"] @@ -627,6 +642,25 @@ route.method = strdup(method); route.path_pattern = strdup(path_pattern); route.handler = handler; + route.stream_handler = NULL; + route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL); + route.segment_count = Dowa_Array_Length(route.path_segments); + route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count); + + for (size_t i = 0; i < route.segment_count; i++) + route.is_param[i] = (route.path_segments[i][0] == ':'); + + Dowa_Array_Push(g_routes, route); +} + +void Seobeo_Router_Register_Stream(const char *method, const char *path_pattern, Seobeo_Stream_Handler handler) +{ + Seobeo_Route route = {0}; + + route.method = strdup(method); + route.path_pattern = strdup(path_pattern); + route.handler = NULL; + route.stream_handler = handler; route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL); route.segment_count = Dowa_Array_Length(route.path_segments); route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count); @@ -709,6 +743,29 @@ return NULL; } +Seobeo_Stream_Handler Seobeo_Router_Find_Stream_Handler( + const char *method, + const char *path, + Seobeo_Request_Entry **pp_request_map, + Dowa_Arena *p_arena) +{ + if (g_routes == NULL || method == NULL || path == NULL) + return NULL; + + size_t route_count = Dowa_Array_Length(g_routes); + for (size_t i = 0; i < route_count; i++) + { + Seobeo_Route *route = &g_routes[i]; + if (strcmp(route->method, method) != 0) + continue; + + if (route->stream_handler && match_route_and_extract(route, path, pp_request_map, p_arena)) + return route->stream_handler; + } + + return NULL; +} + void Seobeo_Router_Send_Response( Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, @@ -744,24 +801,23 @@ const char *body = ""; void *p_body_kv = Dowa_HashMap_Get_Ptr(p_response_map, "body"); if (p_body_kv) - { body = ((Seobeo_Request_Entry*)p_body_kv)->value; - } const char *content_type = "text/html"; void *p_content_type_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-type"); if (p_content_type_kv) - { content_type = ((Seobeo_Request_Entry*)p_content_type_kv)->value; - } - size_t body_length = strlen(body); + // TODO: Update this to be integer + size_t body_length; void *p_content_length_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-length"); if (p_content_length_kv) { const char *content_length_str = ((Seobeo_Request_Entry*)p_content_length_kv)->value; body_length = atoi(content_length_str); } + else + body_length = strlen(body); char *header = Dowa_Arena_Allocate(p_arena, 4096); Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); @@ -782,6 +838,8 @@ free(temp); } + printf("hEADER %s\n", header); + Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header)); Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length); Seobeo_Handle_Flush(p_handle); @@ -804,3 +862,44 @@ Dowa_Array_Free(g_routes); g_routes = NULL; } + +// Written by AI. I don't know what it does. +void Seobeo_Url_Decode(char *dst, const char *src) +{ + char a, b; + while (*src) { + /* Check if we have a % followed by two valid hex characters */ + if (*src == '%' && src[1] && src[2]) { + a = src[1]; + b = src[2]; + + /* Manual isxdigit check and conversion for 'a' */ + int a_val = -1; + if (a >= '0' && a <= '9') a_val = a - '0'; + else if (a >= 'a' && a <= 'f') a_val = a - 'a' + 10; + else if (a >= 'A' && a <= 'F') a_val = a - 'A' + 10; + + /* Manual isxdigit check and conversion for 'b' */ + int b_val = -1; + if (b >= '0' && b <= '9') b_val = b - '0'; + else if (b >= 'a' && b <= 'f') b_val = b - 'a' + 10; + else if (b >= 'A' && b <= 'F') b_val = b - 'A' + 10; + + /* If both were valid hex, combine them */ + if (a_val != -1 && b_val != -1) { + *dst++ = (char)((a_val << 4) | b_val); + src += 3; + continue; + } + } + + /* Handle '+' as space, otherwise copy character literally */ + if (*src == '+') { + *dst++ = ' '; + } else { + *dst++ = *src; + } + src++; + } + *dst = '\0'; +}
--- a/seobeo/seobeo.h Sat Jan 24 06:37:43 2026 -0800 +++ b/seobeo/seobeo.h Tue Jan 27 06:51:44 2026 -0800 @@ -91,6 +91,9 @@ /* Destroy response and free all resources. */ extern void Seobeo_Client_Response_Destroy(Seobeo_Client_Response *p_resp); +// --- HTTP Web related helper functions --- // +extern void Seobeo_Url_Decode(char *dst, const char *src); + /** * WebSocket Client API * ------ @@ -324,18 +327,22 @@ extern void Seobeo_WebSocket_Server_Connection_Close(Seobeo_WebSocket_Server_Connection *p_conn, uint16 code, const char *reason); /* Initialize the router system (called automatically by Seobeo_Web_Server_Start) */ -extern void Seobeo_Router_Init(); +extern void Seobeo_Router_Init(); /* Register an API route handler. Call before starting server. */ -extern void Seobeo_Router_Register(const char *method, const char *path_pattern, Seobeo_Route_Handler handler); +extern void Seobeo_Router_Register(const char *method, const char *path_pattern, Seobeo_Route_Handler handler); +/* Register a streaming route handler. Handler receives client handle for direct streaming. */ +extern void Seobeo_Router_Register_Stream(const char *method, const char *path_pattern, Seobeo_Stream_Handler handler); /* Clean up router resources */ -extern void Seobeo_Router_Destroy(); +extern void Seobeo_Router_Destroy(); /* Find matching route handler (internal use) */ -extern Seobeo_Route_Handler Seobeo_Router_Find_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena); +extern Seobeo_Route_Handler Seobeo_Router_Find_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena); /* Send HTTP response from response map (internal use) */ -extern void Seobeo_Router_Send_Response(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena); +extern void Seobeo_Router_Send_Response(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena); /* Send HTTP response with keep-alive option */ -extern void Seobeo_Router_Send_Response_KeepAlive(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena, boolean keep_alive); -extern char *Seobeo_Web_LoadFile(const char *file_path, size_t *p_file_size); +extern void Seobeo_Router_Send_Response_KeepAlive(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena, boolean keep_alive); +extern char *Seobeo_Web_LoadFile(const char *file_path, size_t *p_file_size); +/* Being a proxy and keeping the client open */ +extern Seobeo_Stream_Handler Seobeo_Router_Find_Stream_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena); // --- Helper functions --- // /* Destroy handle. It will handle all NULL poointers. */
--- a/seobeo/seobeo_internal.h Sat Jan 24 06:37:43 2026 -0800 +++ b/seobeo/seobeo_internal.h Tue Jan 27 06:51:44 2026 -0800 @@ -87,6 +87,13 @@ Dowa_Arena *p_arena ); +// Streaming handler - gets direct access to client handle for streaming responses +typedef void (*Seobeo_Stream_Handler)( + Seobeo_Handle *p_client_handle, + Seobeo_Request_Entry *p_request_map, + Dowa_Arena *p_arena +); + // --- Parse Header into Dowa Map ---// extern int Seobeo_Web_Header_Parse(Seobeo_Handle *p_handle, Seobeo_Request_Entry **pp_map, Dowa_Arena *p_arena);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sori/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,13 @@ +# sori + +Audio library for C. + +## Files + +- `main.c` - Audio implementation + +## Building + +```bash +bazel build //sori:sori +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/README.md Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,26 @@ +# third_party + +External dependencies and libraries. + +## Contents + +| Directory | Description | +|-----------|-------------| +| `bun/` | Bun JavaScript runtime | +| `emsdk/` | Emscripten SDK for WASM compilation | +| `highlight/` | highlight.js for syntax highlighting | +| `libuv/` | Async I/O library | +| `luajit/` | LuaJIT interpreter | +| `raylib/` | Game/GUI library | +| `sqlite3/` | SQLite database | +| `wrk/` | HTTP benchmarking tool | + +## Usage + +Reference in BUILD files: + +```starlark +deps = ["//third_party/sqlite3:sqlite3"] +deps = ["//third_party/raylib:raylib"] +deps = ["//third_party/libuv:libuv"] +```
--- a/third_party/bun/tsconfig.json Sat Jan 24 06:37:43 2026 -0800 +++ b/third_party/bun/tsconfig.json Tue Jan 27 06:51:44 2026 -0800 @@ -1,29 +1,8 @@ { "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "baseUrl": ".", + "paths": { + "*": ["*"] + } } }
--- a/third_party/highlight/highlight.js Sat Jan 24 06:37:43 2026 -0800 +++ b/third_party/highlight/highlight.js Tue Jan 27 06:51:44 2026 -0800 @@ -8163,4 +8163,429 @@ })(); hljs.registerLanguage('x86asm', hljsGrammar); - })(); \ No newline at end of file + })(); + (function(){ + var hljsGrammar = (function () { + 'use strict'; + + + /* + Language: Shell Session + Requires: bash.js + Author: TSUYUSATO Kitsune <[email protected]> + Category: common + Audit: 2020 + */ +/* +Language: Bash +Author: vah <[email protected]> +Contributrors: Benjamin Pannell <[email protected]> +Website: https://www.gnu.org/software/bash/ +Category: common, scripting +*/ + +/** @type LanguageFn */ + function bash(hljs) { + const regex = hljs.regex; + const VAR = {}; + const BRACED_VAR = { + begin: /\$\{/, + end: /\}/, + contains: [ + "self", + { + begin: /:-/, + contains: [ VAR ] + } // default values + ] + }; + Object.assign(VAR, { + className: 'variable', + variants: [ + { begin: regex.concat(/\$[\w\d#@][\w\d_]*/, + // negative look-ahead tries to avoid matching patterns that are not + // Perl at all like $ident$, @ident@, etc. + `(?![\\w\\d])(?![$])`) }, + BRACED_VAR + ] + }); + + const SUBST = { + className: 'subst', + begin: /\$\(/, + end: /\)/, + contains: [ hljs.BACKSLASH_ESCAPE ] + }; + const COMMENT = hljs.inherit( + hljs.COMMENT(), + { + match: [ + /(^|\s)/, + /#.*$/ + ], + scope: { + 2: 'comment' + } + } + ); + const HERE_DOC = { + begin: /<<-?\s*(?=\w+)/, + starts: { contains: [ + hljs.END_SAME_AS_BEGIN({ + begin: /(\w+)/, + end: /(\w+)/, + className: 'string' + }) + ] } + }; + const QUOTE_STRING = { + className: 'string', + begin: /"/, + end: /"/, + contains: [ + hljs.BACKSLASH_ESCAPE, + VAR, + SUBST + ] + }; + SUBST.contains.push(QUOTE_STRING); + const ESCAPED_QUOTE = { + match: /\\"/ + }; + const APOS_STRING = { + className: 'string', + begin: /'/, + end: /'/ + }; + const ESCAPED_APOS = { + match: /\\'/ + }; + const ARITHMETIC = { + begin: /\$?\(\(/, + end: /\)\)/, + contains: [ + { + begin: /\d+#[0-9a-f]+/, + className: "number" + }, + hljs.NUMBER_MODE, + VAR + ] + }; + const SH_LIKE_SHELLS = [ + "fish", + "bash", + "zsh", + "sh", + "csh", + "ksh", + "tcsh", + "dash", + "scsh", + ]; + const KNOWN_SHEBANG = hljs.SHEBANG({ + binary: `(${SH_LIKE_SHELLS.join("|")})`, + relevance: 10 + }); + const FUNCTION = { + className: 'function', + begin: /\w[\w\d_]*\s*\(\s*\)\s*\{/, + returnBegin: true, + contains: [ hljs.inherit(hljs.TITLE_MODE, { begin: /\w[\w\d_]*/ }) ], + relevance: 0 + }; + + const KEYWORDS = [ + "if", + "then", + "else", + "elif", + "fi", + "time", + "for", + "while", + "until", + "in", + "do", + "done", + "case", + "esac", + "coproc", + "function", + "select" + ]; + + const LITERALS = [ + "true", + "false" + ]; + + // to consume paths to prevent keyword matches inside them + const PATH_MODE = { match: /(\/[a-z._-]+)+/ }; + + // http://www.gnu.org/software/bash/manual/html_node/Shell-Builtin-Commands.html + const SHELL_BUILT_INS = [ + "break", + "cd", + "continue", + "eval", + "exec", + "exit", + "export", + "getopts", + "hash", + "pwd", + "readonly", + "return", + "shift", + "test", + "times", + "trap", + "umask", + "unset" + ]; + + const BASH_BUILT_INS = [ + "alias", + "bind", + "builtin", + "caller", + "command", + "declare", + "echo", + "enable", + "help", + "let", + "local", + "logout", + "mapfile", + "printf", + "read", + "readarray", + "source", + "sudo", + "type", + "typeset", + "ulimit", + "unalias" + ]; + + const ZSH_BUILT_INS = [ + "autoload", + "bg", + "bindkey", + "bye", + "cap", + "chdir", + "clone", + "comparguments", + "compcall", + "compctl", + "compdescribe", + "compfiles", + "compgroups", + "compquote", + "comptags", + "comptry", + "compvalues", + "dirs", + "disable", + "disown", + "echotc", + "echoti", + "emulate", + "fc", + "fg", + "float", + "functions", + "getcap", + "getln", + "history", + "integer", + "jobs", + "kill", + "limit", + "log", + "noglob", + "popd", + "print", + "pushd", + "pushln", + "rehash", + "sched", + "setcap", + "setopt", + "stat", + "suspend", + "ttyctl", + "unfunction", + "unhash", + "unlimit", + "unsetopt", + "vared", + "wait", + "whence", + "where", + "which", + "zcompile", + "zformat", + "zftp", + "zle", + "zmodload", + "zparseopts", + "zprof", + "zpty", + "zregexparse", + "zsocket", + "zstyle", + "ztcp" + ]; + + const GNU_CORE_UTILS = [ + "chcon", + "chgrp", + "chown", + "chmod", + "cp", + "dd", + "df", + "dir", + "dircolors", + "ln", + "ls", + "mkdir", + "mkfifo", + "mknod", + "mktemp", + "mv", + "realpath", + "rm", + "rmdir", + "shred", + "sync", + "touch", + "truncate", + "vdir", + "b2sum", + "base32", + "base64", + "cat", + "cksum", + "comm", + "csplit", + "cut", + "expand", + "fmt", + "fold", + "head", + "join", + "md5sum", + "nl", + "numfmt", + "od", + "paste", + "ptx", + "pr", + "sha1sum", + "sha224sum", + "sha256sum", + "sha384sum", + "sha512sum", + "shuf", + "sort", + "split", + "sum", + "tac", + "tail", + "tr", + "tsort", + "unexpand", + "uniq", + "wc", + "arch", + "basename", + "chroot", + "date", + "dirname", + "du", + "echo", + "env", + "expr", + "factor", + // "false", // keyword literal already + "groups", + "hostid", + "id", + "link", + "logname", + "nice", + "nohup", + "nproc", + "pathchk", + "pinky", + "printenv", + "printf", + "pwd", + "readlink", + "runcon", + "seq", + "sleep", + "stat", + "stdbuf", + "stty", + "tee", + "test", + "timeout", + // "true", // keyword literal already + "tty", + "uname", + "unlink", + "uptime", + "users", + "who", + "whoami", + "yes" + ]; + + return { + name: 'Bash', + aliases: [ + 'sh', + 'zsh' + ], + keywords: { + $pattern: /\b[a-z][a-z0-9._-]+\b/, + keyword: KEYWORDS, + literal: LITERALS, + built_in: [ + ...SHELL_BUILT_INS, + ...BASH_BUILT_INS, + // Shell modifiers + "set", + "shopt", + ...ZSH_BUILT_INS, + ...GNU_CORE_UTILS + ] + }, + contains: [ + KNOWN_SHEBANG, // to catch known shells and boost relevancy + hljs.SHEBANG(), // to catch unknown shells but still highlight the shebang + FUNCTION, + ARITHMETIC, + COMMENT, + HERE_DOC, + PATH_MODE, + QUOTE_STRING, + ESCAPED_QUOTE, + APOS_STRING, + ESCAPED_APOS, + VAR + ] + }; + } +} + return bash; +})(); + + hljs.registerLanguage('bash', hljsGrammar); + })();
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/third_party/highlight/languages/shell.js Tue Jan 27 06:51:44 2026 -0800 @@ -0,0 +1,31 @@ +/* +Language: Shell Session +Requires: bash.js +Author: TSUYUSATO Kitsune <[email protected]> +Category: common +Audit: 2020 +*/ + +/** @type LanguageFn */ +export default function(hljs) { + return { + name: 'Shell Session', + aliases: [ + 'console', + 'shellsession' + ], + contains: [ + { + className: 'meta.prompt', + // We cannot add \s (spaces) in the regular expression otherwise it will be too broad and produce unexpected result. + // For instance, in the following example, it would match "echo /path/to/home >" as a prompt: + // echo /path/to/home > t.exe + begin: /^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/, + starts: { + end: /[^\\](?=\s*$)/, + subLanguage: 'bash' + } + } + ] + }; +}