commit c8fbee744ae4e6e681807513dbd19061b3692f56
Author: tri <tri@thac.loan>
Date:   Wed Oct 1 11:59:35 2025 +0700

    split repo page to 3 tabs: source, readme, commits
    
    Readme tab is only present if a readme file exists.
    Maybe look into some templating system next.

diff --git a/src/assets/style.css b/src/assets/style.css
index 01ceec8..c7171bf 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -1,4 +1,7 @@
 :root {
+  --bg: white;
+  --fg: black;
+
   --table-odd-row-bg: #eee;
   --table-border-color: #ddd;
   --table-row-hover-bg: papayawhip;
@@ -12,6 +15,8 @@
     ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas,
     "DejaVu Sans Mono", monospace;
 
+  background-color: var(--bg);
+  color: var(--fg);
   font-family: var(--serif-fonts);
   font-size: 100%;
 }
@@ -151,11 +156,35 @@ details:open > summary {
   }
 }
 
+.repo-nav {
+  ul {
+    list-style-type: none;
+    padding: 0;
+    margin: 0;
+  }
+  li {
+    display: inline-block;
+    padding: 0.5rem 1rem;
+    border: 1px solid transparent;
+
+    &.active {
+      border: 1px solid var(--fg);
+      border-bottom: 1px solid var(--bg);
+      z-index: 1;
+    }
+  }
+}
+.repo-content {
+  border: 1px solid var(--fg);
+  margin-top: -1px; /* so nav item's bottom border overlaps its border */
+  padding: 0 1rem;
+  width: fit-content;
+}
+
 @media (prefers-color-scheme: dark) {
   :root {
-    background-color: #222;
-    color: #ddd;
-    font-family: var(--sans-serif-fonts);
+    --bg: #222;
+    --fg: #ddd;
 
     --table-odd-row-bg: #333;
     --table-border-color: #444;
@@ -163,6 +192,8 @@ details:open > summary {
     --table-border-color: #ddd;
     --pre-bg: #444;
     --pre-border-color: #555;
+
+    font-family: var(--sans-serif-fonts);
   }
 
   a {
diff --git a/src/git.zig b/src/git.zig
index 55ed1e3..a86f685 100644
--- a/src/git.zig
+++ b/src/git.zig
@@ -1,6 +1,7 @@
 const std = @import("std");
 const mem = std.mem;
 const fs = std.fs;
+const t = std.testing;
 
 pub fn findGitDir(arena: mem.Allocator, dir: fs.Dir) ![]const u8 {
     var proc = try std.process.Child.run(.{
@@ -148,3 +149,71 @@ pub fn readFileAlloc(arena: mem.Allocator, dir: fs.Dir, file_name: []const u8) !
 
     return proc.stdout;
 }
+
+pub const File = struct {
+    hash: []const u8,
+    size: u64,
+    path: []const u8,
+};
+
+pub const Walker = struct {
+    remaining: []const u8,
+
+    pub fn next(self: *Walker) !?File {
+        if (self.remaining.len == 0) return null;
+
+        const hashEndIdx = mem.indexOfScalar(u8, self.remaining, ' ').?;
+        const hash = self.remaining[0..hashEndIdx];
+
+        const sizeEndIdx = mem.indexOfScalarPos(u8, self.remaining, hashEndIdx + 1, ' ').?;
+        const sizeStr = self.remaining[hashEndIdx + 1 .. sizeEndIdx];
+        const size =
+            if (mem.eql(u8, sizeStr, "-")) // is a symlink
+                0
+            else
+                try std.fmt.parseUnsigned(u64, sizeStr, 10);
+
+        const pathEndIdx = mem.indexOfScalarPos(u8, self.remaining, sizeEndIdx + 1, '\x00').?;
+        const path = self.remaining[sizeEndIdx + 1 .. pathEndIdx];
+
+        self.remaining = self.remaining[pathEndIdx + 1 ..];
+        return File{ .hash = hash, .size = size, .path = path };
+    }
+};
+
+pub fn walkTree(arena: mem.Allocator, dir: fs.Dir) !Walker {
+    var proc = try std.process.Child.run(.{
+        .allocator = arena,
+        .cwd_dir = dir,
+        .max_output_bytes = 1024 * 1024 * 64,
+        .argv = &.{
+            "git",
+            "ls-tree",
+            "HEAD",
+            "-r",
+            "-z",
+            "--format",
+            "%(objectname) %(objectsize) %(path)",
+        },
+    });
+    return Walker{ .remaining = proc.stdout };
+}
+
+//test "walkTree" {
+//    var arena_impl = std.heap.ArenaAllocator.init(t.allocator_instance.allocator());
+//    defer arena_impl.deinit();
+//    const arena = arena_impl.allocator();
+//
+//    var dir = try fs.cwd().openDir("demo/khoe", .{});
+//    defer dir.close();
+//
+//    var walker = try walkTree(arena, dir);
+//    try t.expectEqualDeep(
+//        File{
+//            .hash = "f216b65cf70beaeda50910cf5c778d466d297bad",
+//            .size = 33,
+//            .path = ".gitignore",
+//        },
+//        (try walker.next()).?,
+//    );
+//}
diff --git a/src/main.zig b/src/main.zig
index 2354c4c..e310cc6 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -9,7 +9,9 @@ const html = @import("html.zig");
 const markdown = @import("markdown.zig");
 
 const web_path = "_";
-const assets_path = web_path ++ "/_khoe-hang";
+const web_assets_path = web_path ++ "/_khoe-hang";
+const web_readme_path = "readme.html";
+const web_commits_path = "commits.html";
 
 const Mode = union(enum) {
     all: void,
@@ -106,7 +108,7 @@ pub fn main() !u8 {
             .last_commit_time = if (commits.len == 0) "" else try arena.dupe(u8, commits[0].time),
         });
 
-        try writeRepoPage(.{
+        try processRepo(&.{
             .arena = repo_arena,
             .site_url = site_url,
             .repo_name = entry.name,
@@ -125,7 +127,7 @@ pub fn main() !u8 {
 
     try writeHomePage(arena, target_dir, repo_summaries.items);
 
-    var assets_dir = try target_dir.makeOpenPath(assets_path, .{});
+    var assets_dir = try target_dir.makeOpenPath(web_assets_path, .{});
     inline for (.{ "style.css", "script.js" }) |asset_name| {
         try assets_dir.writeFile(.{
             .data = @embedFile("assets/" ++ asset_name),
@@ -238,103 +240,242 @@ const RepoArgs = struct {
     git_dir: fs.Dir,
 };
 
-pub fn writeRepoPage(args: RepoArgs) !void {
+pub fn processRepo(args: *const RepoArgs) !void {
     const arena = args.arena;
-    const site_url = args.site_url;
-    const repo_name = args.repo_name;
-    const target_dir = args.target_dir;
-    const in_repo_dir = args.in_repo_dir;
-    const commits = args.commits;
-
-    var buf: [1024]u8 = undefined;
-    var out_repo_dir = try target_dir.makeOpenPath(
-        try std.fmt.bufPrint(&buf, "{s}/{s}", .{ web_path, repo_name }),
+
+    var out_repo_dir = try args.target_dir.makeOpenPath(
+        try std.fmt.allocPrint(arena, "{s}/{s}", .{ web_path, args.repo_name }),
         .{},
     );
     defer out_repo_dir.close();
 
+    const maybe_readme_path = try git.findReadme(arena, args.in_repo_dir);
+
+    try writeRepoPage(args, out_repo_dir, maybe_readme_path);
+    try writeReadmePage(args, out_repo_dir, maybe_readme_path);
+    try writeCommitsPage(args, out_repo_dir, maybe_readme_path);
+}
+
+pub fn writeRepoPage(
+    args: *const RepoArgs,
+    out_repo_dir: fs.Dir,
+    maybe_readme_filename: ?[]const u8,
+) !void {
+    const arena = args.arena;
+
     var file = try out_repo_dir.createFile("index.html", .{});
     defer file.close();
 
     var buf2: [1024 * 16]u8 = undefined;
-    var writer = file.writer(&buf2);
-    try writer.interface.print(
-        \\<!doctype html>
-        \\<html lang="en">
-        \\  <head>
-        \\    <meta charset="utf-8" />
-        \\    <title>{0s} | Khoe</title>
-        \\    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        \\    <link rel="stylesheet" href="/_/_khoe-hang/style.css">
-        \\    <script src="/_/_khoe-hang/script.js"></script>
-        \\  </head>
-        \\  <body>
-        \\    <header>
-        \\      <div id="breadcrumbs">
-        \\        /<a href="/">repos</a>/<h1>{0s}</h1>/
-        \\      </div>
-        \\    </header>
-        \\    <p style="margin:0; font-style:italic;">{4s}</p>
-        \\    <p style="margin:0">{5s} | {1d} commits</p>
+    var file_writer = file.writer(&buf2);
+    const writer = &file_writer.interface;
+
+    try writer.print(repo_html_start, .{
+        args.repo_name,
+        args.commits.len,
+        args.site_url,
+        if (mem.eql(u8, args.git_dir_path, "."))
+            args.repo_name
+        else
+            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
+        try html.escapeAlloc(arena, args.description),
+        try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
+    });
+
+    try writer.print(
+        \\<nav class="repo-nav">
+        \\  <ul>
+        \\    <li class="active"><a href="./">source</a></li>
+        \\    {0s}
+        \\    <li><a href="{1s}">commits</a></li>
+        \\  </ul>
+        \\</nav>
+        \\
+    ,
+        .{
+            if (maybe_readme_filename) |readme_filename|
+                try std.fmt.allocPrint(
+                    arena,
+                    "<li><a href=\"{s}\">{s}</a></li>",
+                    .{ web_readme_path, readme_filename },
+                )
+            else
+                "",
+            web_commits_path,
+        },
+    );
+
+    try writer.writeAll(
+        \\<div class="repo-content">
         \\
-        \\    <pre class="git-clone-command">git clone {2s}/{3s}</pre>
+        \\    <div style="overflow-x:auto; padding-bottom:1rem">
+        \\      <table style="margin-top: 1rem">
+        \\        <thead>
+        \\          <tr>
+        \\            <th>name</th>
+        \\            <th>size</th>
+        \\          </tr>
+        \\        </thead>
+        \\        <tbody>
+    );
+
+    var treeWalker = try git.walkTree(arena, args.in_repo_dir);
+    while (try treeWalker.next()) |src_file| {
+        try writer.print(
+            \\<tr>
+            \\  <td>{0s}</td>
+            \\  <td style="opacity:0.4">{1s}</td>
+            \\</tr>
+            \\
+        , .{
+            src_file.path,
+            try utils.humanReadableSize(arena, src_file.size),
+        });
+    }
+
+    try writer.writeAll(
+        \\        </tbody>
+        \\      </table>
+        \\    </div>
+        \\</div>
         \\
-    , .{
-        repo_name,
-        commits.len,
-        site_url,
+    );
+    try writer.writeAll(repo_html_end);
+    try writer.flush();
+}
+
+pub fn writeReadmePage(
+    args: *const RepoArgs,
+    out_repo_dir: fs.Dir,
+    maybe_readme_filename: ?[]const u8,
+) !void {
+    if (maybe_readme_filename == null) return;
+    const readme_filename = maybe_readme_filename.?;
+
+    const arena = args.arena;
+
+    var readme_file = try out_repo_dir.createFile(web_readme_path, .{});
+    defer readme_file.close();
+
+    var writerBuf: [4096]u8 = undefined;
+    var file_writer = readme_file.writer(&writerBuf);
+    const writer = &file_writer.interface;
+
+    try writer.print(repo_html_start, .{
+        args.repo_name,
+        args.commits.len,
+        args.site_url,
         if (mem.eql(u8, args.git_dir_path, "."))
-            repo_name
+            args.repo_name
         else
-            try std.fmt.bufPrint(&buf, "{s}/{s}", .{ repo_name, args.git_dir_path }),
+            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
         try html.escapeAlloc(arena, args.description),
         try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
     });
 
-    // If there's a readme file, include it in repo index:
-    if (try git.findReadme(arena, in_repo_dir)) |readme_path| {
-        //println("   {s}", .{readme_path});
-        const readme_text = try git.readFileAlloc(arena, in_repo_dir, readme_path);
-        const is_markdown = (ascii.endsWithIgnoreCase(readme_path, ".md") or
-            ascii.endsWithIgnoreCase(readme_path, ".markdown"));
+    try writer.print(
+        \\<nav class="repo-nav">
+        \\  <ul>
+        \\    <li><a href="./">source</a></li>
+        \\    <li class="active"><a href="{0s}">{2s}</a></li>
+        \\    <li><a href="{1s}">commits</a></li>
+        \\  </ul>
+        \\</nav>
+        \\
+    ,
+        .{
+            web_readme_path,
+            web_commits_path,
+            readme_filename,
+        },
+    );
 
-        try writer.interface.print(
-            \\<details class="readme-container">
-            \\  <summary>{0s}</summary>
-        ,
-            .{readme_path},
+    const readme_text = try git.readFileAlloc(arena, args.in_repo_dir, readme_filename);
+    const is_markdown = (ascii.endsWithIgnoreCase(readme_filename, ".md") or
+        ascii.endsWithIgnoreCase(readme_filename, ".markdown"));
+
+    if (is_markdown) {
+        const readme_html = try markdown.toHtml(arena, readme_text);
+        try writer.writeAll(
+            \\<div class="repo-content readme-content">
+        );
+        try writer.writeAll(readme_html);
+        try writer.writeAll(
+            \\</div>
         );
+    } else {
+        try writer.writeAll(
+            \\<pre class="repo-content readme-content pre-wrap">
+        );
+        try html.escape(
+            writer,
+            mem.trimEnd(u8, readme_text, "\n"),
+        );
+        try writer.writeAll(
+            \\</pre>
+        );
+    }
 
-        if (is_markdown) {
-            const readme_html = try markdown.toHtml(arena, readme_text);
-            try writer.interface.writeAll(
-                \\<div class="readme-content">
-            );
-            try writer.interface.writeAll(readme_html);
-            try writer.interface.writeAll(
-                \\</div>
-            );
-        } else {
-            try writer.interface.writeAll(
-                \\<pre class="readme-content pre-wrap">
-            );
-            try html.escape(
-                &writer.interface,
-                mem.trimEnd(u8, readme_text, "\n"),
-            );
-            try writer.interface.writeAll(
-                \\</pre>
-            );
-        }
+    try writer.flush();
+}
 
-        try writer.interface.print(
-            \\</details>
-        , .{});
-    }
+pub fn writeCommitsPage(
+    args: *const RepoArgs,
+    out_repo_dir: fs.Dir,
+    maybe_readme_filename: ?[]const u8,
+) !void {
+    const arena = args.arena;
 
-    try writer.interface.print(
+    var commits_file = try out_repo_dir.createFile(web_commits_path, .{});
+    defer commits_file.close();
+
+    var writerBuf: [4096]u8 = undefined;
+    var file_writer = commits_file.writer(&writerBuf);
+    const writer = &file_writer.interface;
+
+    try writer.print(repo_html_start, .{
+        args.repo_name,
+        args.commits.len,
+        args.site_url,
+        if (mem.eql(u8, args.git_dir_path, "."))
+            args.repo_name
+        else
+            try std.fmt.allocPrint(arena, "{s}/{s}", .{ args.repo_name, args.git_dir_path }),
+        try html.escapeAlloc(arena, args.description),
+        try utils.humanReadableSize(arena, try utils.dirSize(arena, args.git_dir)),
+    });
+
+    try writer.print(
+        \\<nav class="repo-nav">
+        \\  <ul>
+        \\    <li><a href="./">source</a></li>
+        \\    {0s}
+        \\    <li class="active"><a href="{1s}">commits</a></li>
+        \\  </ul>
+        \\</nav>
+        \\
+    ,
+        .{
+            if (maybe_readme_filename) |readme_filename|
+                try std.fmt.allocPrint(
+                    arena,
+                    "<li><a href=\"{s}\">{s}</a></li>",
+                    .{ web_readme_path, readme_filename },
+                )
+            else
+                "",
+            web_commits_path,
+        },
+    );
+
+    try writer.writeAll(
+        \\<div class="repo-content">
+        \\
+    );
+
+    try writer.print(
         \\    <div style="overflow-x:auto; padding-bottom:1rem">
-        \\      <table style="margin-top: 1.5rem">
+        \\      <table style="margin-top: 1rem">
         \\        <thead>
         \\          <tr>
         \\            <th>hash</th>
@@ -347,14 +488,14 @@ pub fn writeRepoPage(args: RepoArgs) !void {
         \\
     , .{});
 
-    for (commits) |cmt| {
+    for (args.commits) |cmt| {
         const escaped_subject = try html.escapeAlloc(
             arena,
             cmt.subject[0..@min(cmt.subject.len, 80)],
         );
         const escaped_author = try html.escapeAlloc(arena, cmt.author_name);
 
-        try writer.interface.print(
+        try writer.print(
             \\<tr>
             \\  <td class="monospace" title="commit diff coming Soon™">{0s}</td>
             \\  <td>{1s}{2s}</td>
@@ -374,17 +515,42 @@ pub fn writeRepoPage(args: RepoArgs) !void {
         );
     }
 
-    try writer.interface.writeAll(
-        \\        </tbody>
-        \\      </table>
-        \\    </div>
-        \\  </body>
-        \\</html>
+    try writer.writeAll(
+        \\</div>
+        \\
     );
-
-    try writer.interface.flush();
+    try writer.writeAll(repo_html_end);
 }
 
+const repo_html_start =
+    \\<!doctype html>
+    \\<html lang="en">
+    \\  <head>
+    \\    <meta charset="utf-8" />
+    \\    <title>{0s} | Khoe</title>
+    \\    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    \\    <link rel="stylesheet" href="/_/_khoe-hang/style.css">
+    \\    <script src="/_/_khoe-hang/script.js"></script>
+    \\  </head>
+    \\  <body>
+    \\    <header>
+    \\      <div id="breadcrumbs">
+    \\        /<a href="/">repos</a>/<h1>{0s}</h1>/
+    \\      </div>
+    \\    </header>
+    \\    <p style="margin:0; font-style:italic;">{4s}</p>
+    \\    <p style="margin:0">{5s} | {1d} commits</p>
+    \\
+    \\    <pre class="git-clone-command">git clone {2s}/{3s}</pre>
+    \\
+;
+
+const repo_html_end =
+    \\  </body>
+    \\</html>
+    \\
+;
+
 test "all" {
     _ = @import("html.zig");
     _ = @import("markdown.zig");