changeset 104:2301aeb7503b

[Hg Web] Super simple mercurial server.
author June Park <parkjune1995@gmail.com>
date Sat, 03 Jan 2026 10:20:45 -0800
parents f6d2f2eaaf84
children 4de2fb74ce82
files hg-web/BUILD hg-web/deploy.sh hg-web/main.c hg-web/src/base.css hg-web/src/index.css hg-web/src/index.html
diffstat 6 files changed, 823 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/BUILD	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,36 @@
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle")
+
+move_files_into_dir(
+  name = "compiled_ts",
+  srcs = [
+    "//markdown_converter:markdown_to_html",
+  ],
+  dest = "src",
+)
+
+filegroup(
+  name = "src_files",
+  srcs = glob(["src/**"]) + [":compiled_ts"],
+)
+
+cc_binary(
+  name = "hg_web_server",
+  srcs = ["main.c"],
+  deps = ["//seobeo:seobeo_server"],
+  data = [":src_files"],
+  defines = ["REPO_ROOT=\\\"\"/home/mrjunejune/zenbu\"\\\""],
+)
+
+bundle(
+  name = "hg_web_server_bundle",
+  binary = ":hg_web_server",
+)
+
+cc_binary(
+  name = "hg_web_server_dev",
+  srcs = ["main.c"],
+  deps = ["//seobeo:seobeo_server_dev"],
+  data = [":src_files"],
+  defines = ["REPO_ROOT=\\\"\"/Users/mrjunejune/zenbu\"\\\""],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/deploy.sh	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,13 @@
+#!/bin/bash
+bazel build -c opt //hg-web:hg_web_server_bundle
+
+# Create
+sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new
+sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new
+
+# Swap
+sudo rm -rf /opt/hg_web_server_bundle_active
+sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active
+
+sudo systemctl restart hg_web_server.service
+echo "Deployment complete!"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/main.c	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,279 @@
+#include "seobeo/seobeo.h"
+#include "dowa/dowa.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+#include <dirent.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#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 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)
+  {
+    char *empty = Dowa_Arena_Allocate(arena, 1);
+    empty[0] = '\0';
+    return empty;
+  }
+
+  size_t len = strlen(input_path);
+  char *result = Dowa_Arena_Allocate(arena, len + 1);
+  size_t j = 0;
+
+  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] == '.') {
+        // Skip ".."
+        i++;
+        continue;
+      }
+      // Skip "."
+      continue;
+    }
+    result[j++] = input_path[i];
+  }
+  result[j] = '\0';
+
+  // Remove leading/trailing slashes
+  while (result[0] == '/')
+    memmove(result, result + 1, strlen(result));
+  while (j > 0 && result[j-1] == '/')
+    result[--j] = '\0';
+
+  return result;
+}
+
+Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  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);
+
+  char *safe_path = sanitize_path(decoded_path, arena);
+  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);
+
+  if (!is_directory(full_path))
+  {
+    char *error_json = Dowa_Arena_Allocate(arena, 256);
+    snprintf(error_json, 256, "{\"error\":\"Directory not found\"}");
+
+    Dowa_HashMap_Push_Arena(resp, "status", "404", 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);
+    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, "]}");
+
+  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);
+
+  return resp;
+}
+
+Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  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);
+  char *safe_path = sanitize_path(decoded_path, arena);
+
+  Seobeo_Log(SEOBEO_DEBUG, "rel_path: %s\n", rel_path);
+  Seobeo_Log(SEOBEO_DEBUG, "decoded_path: %s\n", decoded_path);
+  Seobeo_Log(SEOBEO_DEBUG, "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);
+    return resp;
+  }
+
+  fseek(file, 0, SEEK_END);
+  size_t file_size = ftell(file);
+  fseek(file, 0, SEEK_SET);
+
+  char *file_data = malloc(file_size + 1);
+  if (!file_data)
+  {
+    fclose(file);
+    char *error_msg = "Memory allocation failed";
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", error_msg, 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)
+  {
+    char *error = Dowa_Arena_Allocate(arena, 64);
+    strcpy(error, "Cannot read file");
+
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", error, 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";
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", content_type, arena);
+  Dowa_HashMap_Push_Arena(resp, "body", body, arena);
+
+  return resp;
+}
+
+Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) {
+  return ApiGetFile(req, arena);
+}
+
+int main(void) {
+  Seobeo_Router_Init();
+
+  Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory);
+  Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile);
+  Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
+
+  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);
+
+  Seobeo_Router_Destroy();
+
+  return result;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/base.css	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,141 @@
+/* --- Colors  ---*/
+: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;
+}
+
+.dark {
+  --bg: #0d1117;
+  --fg: #c9d1d9;
+  --border: #30363d;
+  --hover: #161b22;
+  --accent: #58a6ff;
+  --accent-hover: #79c0ff;
+  --secondary: #8b949e;
+  --success: #3fb950;
+  --warning: #d29922;
+  --danger: #f85149;
+  --code-bg: #161b22;
+  --link: #58a6ff;
+  --link-hover: #79c0ff;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not(.light-mode) {
+    --bg: #0d1117;
+    --fg: #c9d1d9;
+    --border: #30363d;
+    --hover: #161b22;
+    --accent: #58a6ff;
+    --accent-hover: #79c0ff;
+    --secondary: #8b949e;
+    --success: #3fb950;
+    --warning: #d29922;
+    --danger: #f85149;
+    --code-bg: #161b22;
+    --link: #58a6ff;
+    --link-hover: #79c0ff;
+  }
+}
+
+/* --- Reset and Base Styles --- */
+* {
+  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;
+  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;
+}
+
+a {
+  color: var(--link);
+  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);
+  padding: 0.2em 0.4em;
+  border-radius: 3px;
+  font-family: 'Monaco', 'Courier New', monospace;
+  font-size: 0.9em;
+}
+
+pre {
+  background: var(--code-bg);
+  padding: 1rem;
+  border-radius: 6px;
+  overflow-x: auto;
+  margin-bottom: 1rem;
+}
+
+pre code {
+  background: none;
+  padding: 0;
+}
+
+/* Mobile responsive */
+@media (max-width: 768px) {
+  body {
+    font-size: 14px;
+  }
+
+  main {
+    padding: 1rem;
+  }
+
+  h1 { font-size: 1.75rem; }
+  h2 { font-size: 1.5rem; }
+  h3 { font-size: 1.25rem; }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/index.css	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,179 @@
+.header {
+  border-bottom: 1px solid var(--border);
+  padding-bottom: 1rem;
+  margin-bottom: 2rem;
+}
+
+.header h1 {
+  margin-bottom: 0.5rem;
+}
+
+.header .description {
+  color: var(--secondary);
+  font-size: 0.95rem;
+}
+
+.clone-info {
+  background: var(--code-bg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 1rem;
+  margin-bottom: 2rem;
+}
+
+.clone-info code {
+  background: none;
+  color: var(--fg);
+  font-size: 0.95rem;
+}
+
+.breadcrumb {
+  margin-bottom: 1.5rem;
+  font-size: 0.95rem;
+}
+
+.breadcrumb a {
+  color: var(--link);
+}
+
+.breadcrumb a:hover {
+  text-decoration: underline;
+}
+
+.breadcrumb span {
+  color: var(--secondary);
+  margin: 0 0.5rem;
+}
+
+.file-list {
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  overflow: hidden;
+}
+
+.file-item {
+  display: flex;
+  align-items: center;
+  padding: 0.75rem 1rem;
+  border-bottom: 1px solid var(--border);
+  transition: background-color 0.2s;
+}
+
+.file-item:last-child {
+  border-bottom: none;
+}
+
+.file-item:hover {
+  background: var(--hover);
+}
+
+.file-item .icon {
+  margin-right: 0.75rem;
+  font-size: 1.2rem;
+  width: 20px;
+  text-align: center;
+}
+
+.file-item .name {
+  flex: 1;
+  font-family: 'Monaco', 'Courier New', monospace;
+  font-size: 0.9rem;
+}
+
+.file-item .name a {
+  color: var(--fg);
+}
+
+.file-item .name a:hover {
+  color: var(--link);
+}
+
+.file-item.directory .icon {
+  color: var(--accent);
+}
+
+.file-item.file .icon {
+  color: var(--secondary);
+}
+
+.readme-section {
+  margin-top: 2rem;
+  padding-top: 2rem;
+  border-top: 1px solid var(--border);
+}
+
+.readme-section h2 {
+  margin-bottom: 1rem;
+  font-size: 1.5rem;
+}
+
+.readme-content {
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 1.5rem;
+  background: var(--code-bg);
+}
+
+.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; }
+
+.readme-content h1:first-child,
+.readme-content h2:first-child,
+.readme-content h3:first-child {
+  margin-top: 0;
+}
+
+.readme-content ul,
+.readme-content ol {
+  margin-left: 2rem;
+  margin-bottom: 1rem;
+}
+
+.readme-content li {
+  margin-bottom: 0.5rem;
+}
+
+.readme-content img {
+  max-width: 100%;
+  height: auto;
+  border-radius: 6px;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--secondary);
+}
+
+.error-message {
+  background: var(--danger);
+  color: white;
+  padding: 1rem;
+  border-radius: 6px;
+  margin-bottom: 1rem;
+}
+
+/* Mobile responsive */
+@media (max-width: 768px) {
+  main {
+    padding: 1rem;
+  }
+
+  .file-item {
+    padding: 0.5rem 0.75rem;
+  }
+
+  .file-item .name {
+    font-size: 0.85rem;
+  }
+
+  .clone-info {
+    padding: 0.75rem;
+    overflow-x: auto;
+  }
+
+  .readme-content {
+    padding: 1rem;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/index.html	Sat Jan 03 10:20:45 2026 -0800
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Zenbu Repository</title>
+    <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>
+
+        <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>
+    </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>
+</body>
+</html>