changeset 135:ffb764d2fcc5

[HgWeb] Updated hg web so it works
author June Park <parkjune1995@gmail.com>
date Fri, 09 Jan 2026 11:17:20 -0800
parents 902e29c38d66
children 75c144fd6964
files hg-web/BUILD hg-web/main.c hg-web/src/index.html hg-web/src/index.js seobeo/s_web.c
diffstat 5 files changed, 465 insertions(+), 300 deletions(-) [+]
line wrap: on
line diff
--- a/hg-web/BUILD	Fri Jan 09 08:30:35 2026 -0800
+++ b/hg-web/BUILD	Fri Jan 09 11:17:20 2026 -0800
@@ -26,3 +26,13 @@
   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\"\\\""],
+)
+
--- a/hg-web/main.c	Fri Jan 09 08:30:35 2026 -0800
+++ b/hg-web/main.c	Fri Jan 09 11:17:20 2026 -0800
@@ -4,9 +4,14 @@
 #include <stdlib.h>
 #include <string.h>
 #include <ctype.h>
-#include <dirent.h>
-#include <sys/stat.h>
 #include <unistd.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <netdb.h>
+
+#define HG_SERVE_HOST "127.0.0.1"
+#define HG_SERVE_PORT 4444
 
 #define MAX_PATH 4096
 
@@ -37,19 +42,6 @@
   *dst = '\0';
 }
 
-static int is_directory(const char *path)
-{
-  struct stat st;
-  if (stat(path, &st) != 0) return 0;
-  return S_ISDIR(st.st_mode);
-}
-
-static int file_exists(const char *path)
-{
-  struct stat st;
-  return stat(path, &st) == 0;
-}
-
 static char* sanitize_path(const char *input_path, Dowa_Arena *arena)
 {
   if (!input_path || strlen(input_path) == 0)
@@ -87,6 +79,148 @@
   return result;
 }
 
+// Helper to connect to hg serve
+static int hg_proxy_connect(void)
+{
+    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);
+    }
+
+    // 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;
+
+    // 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;
+}
+
 Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
@@ -99,76 +233,30 @@
 
   char *safe_path = sanitize_path(decoded_path, arena);
 
-  Seobeo_Log(SEOBEO_INFO, "rel_path: %s\n", rel_path);
-  Seobeo_Log(SEOBEO_INFO, "decoded_path: %s\n", decoded_path);
-  Seobeo_Log(SEOBEO_INFO, "safe path: %s\n", safe_path);
-  Seobeo_Log(SEOBEO_INFO, "REPO_ROOT: %s\n", REPO_ROOT);
-  fflush(stdout);
+  Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path);
+
+  char hg_path[MAX_PATH];
+  if (strlen(safe_path) > 0)
+    snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path);
+  else
+    snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");
 
-  char full_path[MAX_PATH];
-  if (strlen(safe_path) > 0)
-    snprintf(full_path, sizeof(full_path), "%s/%s", REPO_ROOT, safe_path);
-  else
-    snprintf(full_path, sizeof(full_path), "%s", REPO_ROOT);
+  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);
 
-  if (!is_directory(full_path))
+  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%s body_len=%zu\n", status, body_len);
+
+  if (!hg_response || status[0] != '2')
   {
-    char *error_json = Dowa_Arena_Allocate(arena, 256);
-    snprintf(error_json, 256, "{\"error\":\"Directory not found\"}");
-
-    Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
+    Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
+    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
     Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", error_json, arena);
-    return resp;
-  }
-
-  DIR *dir = opendir(full_path);
-  if (!dir)
-  {
-    char *error_json = Dowa_Arena_Allocate(arena, 256);
-    snprintf(error_json, 256, "{\"error\":\"Cannot open directory\"}");
-
-    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", error_json, arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
     return resp;
   }
-
-  char *json = Dowa_Arena_Allocate(arena, 1024 * 100);
-  strcpy(json, "{\"files\":[");
-
-  struct dirent *entry;
-  int first = 1;
-
-  while ((entry = readdir(dir)) != NULL)
-  {
-    if (entry->d_name[0] == '.') continue;
-
-    char entry_path[MAX_PATH];
-    snprintf(entry_path, sizeof(entry_path), "%s/%s", full_path, entry->d_name);
-
-    int is_dir = is_directory(entry_path);
-
-    char entry_rel_path[MAX_PATH];
-    if (strlen(safe_path) > 0)
-      snprintf(entry_rel_path, sizeof(entry_rel_path), "%s/%s", safe_path, entry->d_name);
-    else
-      snprintf(entry_rel_path, sizeof(entry_rel_path), "%s", entry->d_name);
-
-    if (!first) strcat(json, ",");
-    first = 0;
-
-    char entry_json[MAX_PATH * 2];
-    snprintf(entry_json, sizeof(entry_json),
-             "{\"name\":\"%s\",\"type\":\"%s\",\"path\":\"%s\"}",
-             entry->d_name,
-             is_dir ? "directory" : "file",
-             entry_rel_path);
-    strcat(json, entry_json);
-  }
-
-  closedir(dir);
-  strcat(json, "]}");
+  char *json = hg_response;
 
   Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
   Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
@@ -187,79 +275,59 @@
   url_decode(decoded_path, rel_path);
   char *safe_path = sanitize_path(decoded_path, arena);
 
-  Seobeo_Log(SEOBEO_INFO, "rel_path: %s\n", rel_path);
-  Seobeo_Log(SEOBEO_INFO, "decoded_path: %s\n", decoded_path);
-  Seobeo_Log(SEOBEO_INFO, "safe path: %s\n", safe_path);
-  Seobeo_Log(SEOBEO_INFO, "REPO_ROOT: %s\n", REPO_ROOT);
-  fflush(stdout);
+  Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);
 
   if (strlen(safe_path) == 0)
   {
-    char *error = Dowa_Arena_Allocate(arena, 64);
-    strcpy(error, "File path required");
-
     Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
     Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", error, arena);
-    return resp;
-  }
-
-  char full_path[MAX_PATH];
-  snprintf(full_path, sizeof(full_path), "%s/%s", REPO_ROOT, safe_path);
-  FILE *file = fopen(full_path, "rb");
-  if (!file)
-  {
-    char *error_msg = "File not found.";
-    Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", error_msg, arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
     return resp;
   }
 
-  fseek(file, 0, SEEK_END);
-  size_t file_size = ftell(file);
-  fseek(file, 0, SEEK_SET);
+  // 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);
 
-  char *file_data = malloc(file_size + 1);
-  if (!file_data)
+  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=%s body_len=%zu\n", status, body_len);
+
+  if (!body)
   {
-    fclose(file);
-    char *error_msg = "Memory allocation failed";
-    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
     Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", error_msg, arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
     return resp;
   }
 
-  fread(file_data, 1, file_size, file);
-  file_data[file_size] = '\0';
-  fclose(file);
-
-  char *body = Dowa_Arena_Allocate(arena, file_size + 1);
-  memcpy(body, file_data, file_size);
-  body[file_size] = '\0';
-  free(file_data);
-
-  if (!body)
+  if (status[0] != '2')
   {
-    char *error = Dowa_Arena_Allocate(arena, 64);
-    strcpy(error, "Cannot read file");
-
-    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error response: %s\n", 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", error, arena);
+    // Return actual error from hg serve if available
+    Dowa_HashMap_Push_Arena(resp, "body", body_len > 0 ? body : "File not found", arena);
     return resp;
   }
 
-  const char *content_type = "text/plain";
-  if (strstr(safe_path, ".md")) content_type = "text/markdown";
-  else if (strstr(safe_path, ".html")) content_type = "text/html";
-  else if (strstr(safe_path, ".css")) content_type = "text/css";
-  else if (strstr(safe_path, ".js")) content_type = "application/javascript";
-  else if (strstr(safe_path, ".json")) content_type = "application/json";
+  // 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";
+  }
 
   Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", content_type, arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", final_content_type, arena);
   Dowa_HashMap_Push_Arena(resp, "body", body, arena);
 
   return resp;
@@ -273,47 +341,133 @@
 {
     Seobeo_Request_Entry *resp = NULL;
 
-    void *cmd_kv = Dowa_HashMap_Get_Ptr(req, "query_cmd");
-    const char *cmd = cmd_kv ? ((Seobeo_Request_Entry*)cmd_kv)->value : "";
-    if (strlen(cmd) == 0)
+    // 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 : "";
+
+    // 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);
+
+    // Connect to hg serve
+    int sock = hg_proxy_connect();
+    if (sock < 0)
     {
-        Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
+        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;
     }
-    Seobeo_Log(SEOBEO_DEBUG, "cmd: %s\n", cmd);
 
-    char command[MAX_PATH];
-    snprintf(command, sizeof(command), "hg -R %s serve --stdio 2>&1", REPO_ROOT);
-    
-    FILE *hg_pipe = popen(command, "r+");
-    if (!hg_pipe)
+    // 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
     {
-        Seobeo_Log(SEOBEO_DEBUG, "Failed to open pipe\n");
+        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);
+    }
+
+    // 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;
     }
-    
-    // 2. Write the command
-    fprintf(hg_pipe, "capabilities\n");
-    fflush(hg_pipe);
-    
-    // 3. Read the response
-    int buffer_size = 1024 * 1024 * 5;
-    char *output = Dowa_Arena_Allocate(arena, buffer_size);
-    if (fgets(output, buffer_size, hg_pipe) != NULL) {
-        Seobeo_Log(SEOBEO_DEBUG, "SUCCESS! Received: %s\n", output);
-    } else {
-        Seobeo_Log(SEOBEO_DEBUG, "FAILURE: No output received from hg.\n");
+
+    // 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;
+
+    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 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;
     }
-    
-    // 4. Close and check exit code
-    int status = pclose(hg_pipe);
-    Seobeo_Log(SEOBEO_DEBUG, "Process exited with status: %d\n", status);
+
+    // 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';
+        }
+    }
 
-    Seobeo_Log(SEOBEO_DEBUG, "body: %s\n", output);
+    // 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;
+        }
+    }
 
-    Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "application/mercurial-0.2", arena);    
-    Dowa_HashMap_Push_Arena(resp, "body", output, arena);
+    // 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;
 }
--- a/hg-web/src/index.html	Fri Jan 09 08:30:35 2026 -0800
+++ b/hg-web/src/index.html	Fri Jan 09 11:17:20 2026 -0800
@@ -34,142 +34,6 @@
     </main>
 
     <script src="/markdown_to_html.js"></script>
-    <script>
-        const API_BASE = '/api/repo';
-
-        // Get current path from URL
-        function getCurrentPath() {
-            const params = new URLSearchParams(window.location.search);
-            return params.get('path') || '';
-        }
-
-        // Update URL without reloading
-        function updateURL(path) {
-            const url = path ? `?path=${encodeURIComponent(path)}` : '/';
-            window.history.pushState({path}, '', url);
-        }
-
-        // Render breadcrumb
-        function renderBreadcrumb(path) {
-            const breadcrumb = document.getElementById('breadcrumb');
-            if (!path) {
-                breadcrumb.innerHTML = '<a href="/">Root</a>';
-                return;
-            }
-
-            const parts = path.split('/').filter(p => p);
-            let currentPath = '';
-            let html = '<a href="/">Root</a>';
-
-            parts.forEach((part, index) => {
-                currentPath += (currentPath ? '/' : '') + part;
-                html += ` <span>/</span> `;
-                if (index === parts.length - 1) {
-                    html += `<span>${part}</span>`;
-                } else {
-                    html += `<a href="?path=${encodeURIComponent(currentPath)}">${part}</a>`;
-                }
-            });
-
-            breadcrumb.innerHTML = html;
-        }
-
-        // Render file list
-        function renderFiles(files) {
-            const fileList = document.getElementById('fileList');
-            const emptyState = document.getElementById('emptyState');
-
-            if (!files || files.length === 0) {
-                fileList.style.display = 'none';
-                emptyState.style.display = 'block';
-                return;
-            }
-
-            emptyState.style.display = 'none';
-            fileList.style.display = 'block';
-
-            // Sort: directories first, then files, alphabetically
-            files.sort((a, b) => {
-                if (a.type !== b.type) {
-                    return a.type === 'directory' ? -1 : 1;
-                }
-                return a.name.localeCompare(b.name);
-            });
-
-            let html = '';
-            files.forEach(file => {
-                const icon = file.type === 'directory' ? '📁' : '📄';
-                const className = file.type;
-                const href = file.type === 'directory'
-                    ? `?path=${encodeURIComponent(file.path)}`
-                    : `/api/repo/file?path=${encodeURIComponent(file.path)}`;
-                const target = file.type === 'directory' ? '' : 'target="_blank"';
-
-                html += `
-                    <div class="file-item ${className}">
-                        <span class="icon">${icon}</span>
-                        <span class="name">
-                            <a href="${href}" ${target}>${file.name}</a>
-                        </span>
-                    </div>
-                `;
-            });
-
-            fileList.innerHTML = html;
-        }
-
-        // Load and render README
-        async function loadReadme(path) {
-            const readmeSection = document.getElementById('readmeSection');
-            const readmeContent = document.getElementById('readmeContent');
-
-            try {
-                const readmePath = path ? `${path}/README.md` : 'README.md';
-                const response = await fetch(`/api/repo/readme?path=${encodeURIComponent(readmePath)}`);
-
-                if (response.ok) {
-                    const markdown = await response.text();
-                    readmeSection.style.display = 'block';
-                    renderMarkdown(readmeContent, markdown);
-                } else {
-                    readmeSection.style.display = 'none';
-                }
-            } catch (error) {
-                readmeSection.style.display = 'none';
-            }
-        }
-
-        // Load directory contents
-        async function loadDirectory(path) {
-            try {
-                const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`;
-                const response = await fetch(url);
-                const data = await response.json();
-
-                if (data.error) {
-                    throw new Error(data.error);
-                }
-
-                renderBreadcrumb(path);
-                renderFiles(data.files);
-                loadReadme(path);
-            } catch (error) {
-                console.error('Error loading directory:', error);
-                document.getElementById('fileList').innerHTML = `
-                    <div class="error-message">Error loading directory: ${error.message}</div>
-                `;
-            }
-        }
-
-        // Handle browser back/forward
-        window.addEventListener('popstate', (event) => {
-            const path = event.state?.path || '';
-            loadDirectory(path);
-        });
-
-        // Initial load
-        const currentPath = getCurrentPath();
-        loadDirectory(currentPath);
-    </script>
+    <script src="/index.js"></script>
 </body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/index.js	Fri Jan 09 11:17:20 2026 -0800
@@ -0,0 +1,126 @@
+const API_BASE = '/api/repo';
+
+function getCurrentPath() {
+  const params = new URLSearchParams(window.location.search);
+  return params.get('path') || '';
+}
+
+function renderBreadcrumb(path) {
+  const breadcrumb = document.getElementById('breadcrumb');
+  if (!path) {
+    breadcrumb.innerHTML = '<a href="/">Root</a>';
+    return;
+  }
+
+  const parts = path.split('/').filter(p => p);
+  let currentPath = '';
+  let html = '<a href="/">Root</a>';
+
+  parts.forEach((part, index) => {
+    currentPath += (currentPath ? '/' : '') + part;
+    html += ` <span>/</span> `;
+    if (index === parts.length - 1) {
+      html += `<span>${part}</span>`;
+    } else {
+      html += `<a href="?path=${encodeURIComponent(currentPath)}">${part}</a>`;
+    }
+  });
+
+  breadcrumb.innerHTML = html;
+}
+
+function renderFiles(files, directories) {
+  if (!files || files.length === 0) {
+    fileList.style.display = 'none';
+    emptyState.style.display = 'block';
+    return;
+  }
+
+  emptyState.style.display = 'none';
+  fileList.style.display = 'block';
+
+  let html = '';
+  directories.forEach(file => {
+    const icon = '📁';
+    const className = file.type;
+    const href = `?path=${encodeURIComponent(file.abspath)}`;
+    const target =  '';
+
+    html += `
+      <div class="file-item ${className}">
+        <span class="icon">${icon}</span>
+        <span class="name">
+          <a href="${href}" ${target}>${file.basename}</a>
+        </span>
+      </div>
+    `;
+  });
+  files.forEach(file => {
+    const icon = '📄';
+    const className = file.type;
+    const href = `/api/repo/file?path=${encodeURIComponent(file.abspath)}`;
+    const target = 'target="_blank"';
+
+    html += `
+      <div class="file-item ${className}">
+        <span class="icon">${icon}</span>
+        <span class="name">
+          <a href="${href}" ${target}>${file.basename}</a>
+        </span>
+      </div>
+    `;
+  });
+
+  fileList.innerHTML = html;
+}
+
+async function loadReadme(path) {
+  const readmeSection = document.getElementById('readmeSection');
+  const readmeContent = document.getElementById('readmeContent');
+
+  try {
+    const readmePath = path ? `${path}/README.md` : 'README.md';
+    const response = await fetch(`/api/repo/readme?path=${encodeURIComponent(readmePath)}`);
+
+    if (response.ok) {
+      const markdown = await response.text();
+      readmeSection.style.display = 'block';
+      renderMarkdown(readmeContent, markdown);
+    } else {
+      readmeSection.style.display = 'none';
+    }
+  } catch (error) {
+    readmeSection.style.display = 'none';
+  }
+}
+
+async function loadDirectory(path) {
+  try {
+    const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`;
+    const response = await fetch(url);
+    const data = await response.json();
+
+    if (data.error) {
+      throw new Error(data.error);
+    }
+
+    const { files, directories } = data;
+
+    renderBreadcrumb(path);
+    renderFiles(files, directories);
+    loadReadme(path);
+  } catch (error) {
+    console.error('Error loading directory:', error);
+    document.getElementById('fileList').innerHTML = `
+      <div class="error-message">Error loading directory: ${error.message}</div>
+    `;
+  }
+}
+
+window.addEventListener('popstate', (event) => {
+  const path = event.state?.path || '';
+  loadDirectory(path);
+});
+
+const currentPath = getCurrentPath();
+loadDirectory(currentPath);
--- a/seobeo/s_web.c	Fri Jan 09 08:30:35 2026 -0800
+++ b/seobeo/s_web.c	Fri Jan 09 11:17:20 2026 -0800
@@ -351,7 +351,18 @@
   if (!path_copy) return -1;
   strcpy(path_copy, raw_path);
   Dowa_HashMap_Push_Arena(*pp_map, "Path", path_copy, p_arena);
-  
+
+  // Store full query string for proxying
+  if (query_str && *query_str)
+  {
+    char *qs_copy = Dowa_Arena_Allocate(p_arena, strlen(query_str) + 1);
+    if (qs_copy)
+    {
+      strcpy(qs_copy, query_str);
+      Dowa_HashMap_Push_Arena(*pp_map, "QueryString", qs_copy, p_arena);
+    }
+  }
+
   // GET Params
   if (query_str && *query_str)
   {