╭ Commits.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../../event.zig"); 3 const ui = @import("../../ui.zig"); 4 const xit = @import("xit"); 5 const rp = xit.repo; 6 const xitui = xit.xitui; 7 const wgt = xitui.widget; 8 const layout = xitui.layout; 9 const inp = xitui.input; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 13 const Oid = [xit.hash.hexLen(.sha1)]u8; 14 15 // how many commits a page shows before a "next" link appears. 16 const page_size = 20; 17 18 // one commit on the current page. 19 pub const Commit = struct { 20 oid: []const u8, 21 date: []const u8, // "YYYY-MM-DD" 22 message: []const u8, // first line only 23 }; 24 25 // "owner/name", so the view can build /repo/owner/name/commits/<oid> links. 26 identity: []const u8, 27 commits: []const Commit, 28 // the first oid of the next page, or null when this is the last page. 29 next_start: ?[]const u8, 30 31 const Self = @This(); 32 33 pub fn init( 34 arena: *std.heap.ArenaAllocator, 35 session: *ui.Session, 36 event_id: *const [evt.event_id_size]u8, 37 identity: []const u8, 38 start_oid: []const u8, 39 ) !Self { 40 const aa = arena.allocator(); 41 const empty: Self = .{ .identity = try aa.dupe(u8, identity), .commits = &.{}, .next_start = null }; 42 43 // no filesystem (wasm) or nowhere to look: empty listing. the wasm path 44 // never calls init anyway — it rebuilds from the serialized snapshot. 45 const io = session.io orelse return empty; 46 const repos_dir = session.repos_dir orelse return empty; 47 48 // the repo's working copy lives at <repos_dir>/<hex event id>. 49 const hex = std.fmt.bytesToHex(event_id.*, .lower); 50 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); 51 52 // walk the log with the arena's backing allocator (transient; the commits 53 // we keep are duped into the page arena so they outlive it). 54 const gpa = arena.child_allocator; 55 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return empty; 56 defer repo.deinit(io, gpa); 57 58 // page 1 walks from HEAD (null); a later page starts at its first oid. 59 var start_arr: [1]Oid = undefined; 60 const start_oids: ?[]const Oid = if (start_oid.len == 0) null else blk: { 61 if (start_oid.len != start_arr[0].len) return empty; // malformed oid 62 @memcpy(&start_arr[0], start_oid); 63 break :blk start_arr[0..1]; 64 }; 65 66 var iter = repo.log(io, gpa, start_oids) catch return empty; 67 defer iter.deinit(); 68 69 // collect this page's commits, plus a peek at the one after it (its oid is 70 // the next page's start). 71 var buf: [page_size]Commit = undefined; 72 var count: usize = 0; 73 var next_start: ?[]const u8 = null; 74 while (try iter.next(gpa)) |commit_object| { 75 defer commit_object.deinit(); 76 if (count == page_size) { 77 next_start = try aa.dupe(u8, &commit_object.oid); 78 break; 79 } 80 const md = commit_object.content.commit.metadata; 81 buf[count] = .{ 82 .oid = try aa.dupe(u8, &commit_object.oid), 83 .date = try formatDate(aa, md.timestamp), 84 .message = try aa.dupe(u8, firstLine(md.message orelse "")), 85 }; 86 count += 1; 87 } 88 89 return .{ 90 .identity = empty.identity, 91 .commits = try aa.dupe(Commit, buf[0..count]), 92 .next_start = next_start, 93 }; 94 } 95 96 // "YYYY-MM-DD" for a unix timestamp. 97 fn formatDate(arena: std.mem.Allocator, timestamp: u64) ![]const u8 { 98 const epoch_secs = std.time.epoch.EpochSeconds{ .secs = timestamp }; 99 const year_day = epoch_secs.getEpochDay().calculateYearDay(); 100 const month_day = year_day.calculateMonthDay(); 101 return std.fmt.allocPrint(arena, "{d:0>4}-{d:0>2}-{d:0>2}", .{ 102 year_day.year, 103 month_day.month.numeric(), 104 month_day.day_index + 1, 105 }); 106 } 107 108 // the first line of a commit message, with surrounding whitespace trimmed. 109 fn firstLine(message: []const u8) []const u8 { 110 const trimmed = std.mem.trim(u8, message, " \t\r\n"); 111 const nl = std.mem.indexOfScalar(u8, trimmed, '\n'); 112 return if (nl) |i| trimmed[0..i] else trimmed; 113 } 114 115 pub const View = struct { 116 // a vertical list mirroring Files.View: one focusable row per commit 117 // (showing its date and message), and — when there's another page — a 118 // "next" row at the bottom that links to it. 119 scroll: wgt.Scroll(ui.Widget), // wraps a vertical Box of rows 120 data: *const Self, 121 122 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 123 var box = wgt.Box(ui.Widget).init(.{ .border_style = null, .direction = .vert }); 124 errdefer box.deinit(allocator); 125 126 // labels and link kinds are borrowed by the rows, so they live in the 127 // page arena (as long as this page's widget tree). 128 const aa = session.page_arena.allocator(); 129 for (data.commits) |commit| { 130 const label = try std.fmt.allocPrint(aa, "{s} {s}", .{ commit.date, commit.message }); 131 try addRow(allocator, &box, label, ""); 132 } 133 if (data.next_start) |next| { 134 try addRow(allocator, &box, "next", try commitsPageLink(session.page_arena, data.identity, next)); 135 } 136 if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; 137 138 var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .vert); 139 errdefer scroll.deinit(allocator); 140 return .{ .scroll = scroll, .data = data }; 141 } 142 143 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 144 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 145 errdefer row.deinit(allocator); 146 row.getFocus().focusable = true; 147 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 148 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 149 } 150 151 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 152 self.scroll.deinit(allocator); 153 } 154 155 fn innerBox(self: *View) *wgt.Box(ui.Widget) { 156 return &self.scroll.child.box; 157 } 158 159 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 160 self.clearGrid(); 161 // the selected row shows a border (the focused TextBox upgrades it to a 162 // double border itself); the rest stay borderless. 163 const box = self.innerBox(); 164 for (box.children.keys(), box.children.values()) |id, *child| { 165 switch (child.widget) { 166 .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, 167 else => {}, 168 } 169 } 170 // clear the incoming min_size so rows size to their content rather than 171 // stretching to fill the page height; max_size still bounds the scroll 172 // viewport so a long listing clips and scrolls. 173 try self.scroll.build(allocator, .{ 174 .min_size = .{ .width = null, .height = null }, 175 .max_size = constraint.max_size, 176 }, root_focus); 177 } 178 179 // how many rows a page up/down jumps. 180 const page_rows = 10; 181 182 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 183 _ = allocator; 184 // up/down (and the scroll wheel) move the selection a row; page up/down 185 // move it a fixed jump. the scroll wheel only reaches here in the TUI — 186 // the web build lets the browser scroll natively and never forwards it. 187 // Enter/clicks on the "next" row are turned into navigation by the host 188 // (crossPageLink). 189 switch (key) { 190 .arrow_down => try self.moveSelection(root_focus, 1), 191 .arrow_up => try self.moveSelection(root_focus, -1), 192 .page_down => try self.moveSelection(root_focus, page_rows), 193 .page_up => try self.moveSelection(root_focus, -page_rows), 194 .mouse => |mouse| switch (mouse.action) { 195 .scroll => |dir| try self.moveSelection(root_focus, if (dir == .up) -1 else 1), 196 else => {}, 197 }, 198 else => {}, 199 } 200 } 201 202 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 203 const box = self.innerBox(); 204 const keys = box.children.keys(); 205 if (keys.len == 0) return; 206 const cur_id = box.getFocus().child_id orelse return; 207 const cur: isize = @intCast(box.children.getIndex(cur_id) orelse return); 208 const last: isize = @intCast(keys.len - 1); 209 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 210 if (next == @as(usize, @intCast(cur))) return; 211 try root_focus.setFocus(keys[next]); 212 if (box.children.values()[next].rect) |rect| self.scroll.scrollToRect(rect); 213 } 214 215 pub fn clearGrid(self: *View) void { 216 self.scroll.clearGrid(); 217 } 218 219 pub fn getGrid(self: View) ?Grid { 220 return self.scroll.getGrid(); 221 } 222 223 pub fn getFocus(self: *View) *Focus { 224 return self.scroll.getFocus(); 225 } 226 227 pub fn getSelectedIndex(self: View) ?usize { 228 const box = &self.scroll.child.box; 229 const child_id = box.focus.child_id orelse return null; 230 return box.children.getIndex(child_id); 231 } 232 }; 233 234 // the "a:" link for the commits page starting at `start_oid` within `identity`. 235 fn commitsPageLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, start_oid: []const u8) ![]const u8 { 236 const route = ui.RoutablePage.repoCommitsRoute(identity, start_oid) orelse return error.RouteTooLong; 237 const url = try route.urlAlloc(page_arena); 238 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 239 } 240