╭ 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 Diff = @import("Diff.zig"); 7 const Undo = @import("Undo.zig"); 8 const obj = xit.object; 9 const mrg = xit.merge; 10 const srch_cmmt = @import("../../search_commit.zig"); 11 const srch = @import("../../search.zig"); 12 const xitui = xit.xitui; 13 const wgt = xitui.widget; 14 const layout = xitui.layout; 15 const Key = xitui.input.Key; 16 const Grid = xitui.grid.Grid; 17 const Focus = xitui.focus.Focus; 18 const inp = @import("../input.zig"); 19 20 // how many commits a page shows before a "next" link appears. 21 const page_size = 20; 22 // the largest message read even when it has fewer than the preview's line 23 // limit. 24 const max_message_size = 100 * 1024; 25 26 // one commit on the current page. 27 pub const Commit = struct { 28 oid: []const u8, 29 // what its diff is against: the first parent, all zeros for a root commit 30 parent_oid: []const u8, 31 date: []const u8, // "YYYY-MM-DD" 32 message: []const u8, // trimmed, may be multi-line 33 // whether `message` is a shortened preview. 34 message_truncated: bool = false, 35 author: ui.Author = .unknown, 36 // .unknown when the committer is the author. 37 committer: ui.Author = .unknown, 38 // the committer timestamp, human-readable. 39 timestamp: []const u8, 40 // whether it has a second parent. 41 merge: bool = false, 42 stats: ?xit.patch.CommitStats = null, 43 }; 44 45 // where links and clone commands for this repository are rooted, and who views it. 46 handle: ui.RepoHandle, 47 // the resolved ref/oid this log walks from (the default branch when the route 48 // didn't name one), so the page can canonicalize its url to it. 49 ref_or_oid: ui.RoutablePage.RefOrOid, 50 ref_or_oid_value: []const u8, 51 base_oid: []const u8 = "", 52 commit_count: ?u64 = null, 53 commits: []const Commit, 54 // the first oid of the next page, or null when this is the last page. 55 next_start: ?[]const u8, 56 // the pane shows the whole message of the commit the log walks from. 57 message: bool = false, 58 // the decoded query this list holds the results of, or null for a plain log. 59 search: ?[]const u8 = null, 60 // whether the viewed ref has a commit index, so the search box shows and a 61 // search: route resolves. travels in the page json for the wasm view. 62 search_available: bool = false, 63 // an object view or a base-bounded list searches the default branch: that 64 // branch's url-encoded name, when it is indexed 65 default_branch: []const u8 = "", 66 // the repo's default branch has no commit yet, so the tab shows a placeholder 67 no_commits: bool = false, 68 69 const Self = @This(); 70 71 // walk the log for an opened repo. generic over the repo's backend and hash 72 // kind so the oid buffers it threads through match the repo's opts. `message` 73 // reads the walk root's message whole, up to the safety limit above. walks 74 // with the arena's backing allocator (transient; the commits we keep are 75 // duped into the page arena so they outlive it). 76 pub fn init( 77 comptime repo_kind: rp.RepoKind, 78 comptime repo_opts: rp.RepoOpts(repo_kind), 79 arena: *std.heap.ArenaAllocator, 80 repo: *rp.Repo(repo_kind, repo_opts), 81 io: std.Io, 82 gpa: std.mem.Allocator, 83 // the admin db's moment, for resolving author emails to user names (null 84 // in local mode, which has no users) 85 admin_moment: ?evt.AdminDB.HashMap(.read_only), 86 handle: ui.RepoHandle, 87 requested_ref_or_oid: ?ui.RoutablePage.RefOrOid, 88 requested_value: []const u8, 89 message: bool, 90 base_oid: []const u8, 91 search: []const u8, 92 from: []const u8, 93 ) !Self { 94 const aa = arena.allocator(); 95 const hex_len = ui.ResolvedRefOrOid(repo_kind, repo_opts).hex_len; 96 if (base_oid.len != 0) { 97 evt.PatchRev.validateOid(repo_opts.hash, base_oid) catch return error.NotFound; 98 } 99 if (from.len != 0) { 100 evt.PatchRev.validateOid(repo_opts.hash, from) catch return error.NotFound; 101 } 102 const query: ?[]const u8 = if (search.len == 0) null else std.Uri.percentDecodeInPlace(try aa.dupe(u8, search)); 103 104 // resolve the requested ref (or the default branch) to the commit oid to 105 // walk from. an explicitly named ref that doesn't resolve is a bad url 106 // (NotFound -> 404); the default-branch path falls through to empty. 107 var resolved = (try ui.ResolvedRefOrOid(repo_kind, repo_opts).init(repo, io, aa, requested_ref_or_oid, requested_value)) orelse { 108 if (requested_ref_or_oid != null) return error.NotFound; 109 var data = try emptyResult(aa, handle, .branch, requested_value, base_oid); 110 data.no_commits = true; 111 return data; 112 }; 113 114 var moment = repo.core.latestMoment() catch return emptyResult(aa, handle, resolved.ref_or_oid, resolved.value, base_oid); 115 const state = rp.Repo(repo_kind, repo_opts).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 116 117 // resolve annotated tags once, independently of the page's starting commit. 118 { 119 var tip = obj.Object(repo_kind, repo_opts).initCommit(state, io, gpa, &resolved.oid) catch { 120 if (query != null) return error.NotFound; 121 return emptyResult(aa, handle, resolved.ref_or_oid, resolved.value, base_oid); 122 }; 123 defer tip.deinit(); 124 resolved.oid = tip.oid; 125 } 126 127 // collect this page's commits, plus a peek at the one after it (its oid is 128 // the next page's start). 129 var buf: [page_size]Commit = undefined; 130 var count: usize = 0; 131 var next_start: ?[]const u8 = null; 132 var search_available = false; 133 var default_branch: []const u8 = ""; 134 var searched = false; 135 136 // the index covers a repository's branch and tag tips, so a fork gets no 137 // search box and refuses a search: url. an object view or a list bounded by 138 // a base, whose version would also cover the commits before it, searches 139 // the default branch instead. 140 if (comptime repo_kind == .xit) index: { 141 if (handle.location != .repo) break :index; 142 if (resolved.ref_or_oid == .object or base_oid.len != 0) { 143 var head_buffer: [xit.ref.MAX_REF_CONTENT_SIZE]u8 = undefined; 144 const branch = switch (repo.head(io, &head_buffer) catch break :index) { 145 .ref => |ref| ref.name, 146 .oid => break :index, 147 }; 148 const tip = (try repo.readRef(io, .{ .kind = .head, .name = branch })) orelse break :index; 149 if (try srch_cmmt.lookup(repo_opts, moment, &tip) == null) break :index; 150 search_available = true; 151 default_branch = try ui.urlEncodeRef(aa, branch); 152 break :index; 153 } 154 const index = (try srch_cmmt.lookup(repo_opts, moment, &resolved.oid)) orelse break :index; 155 search_available = true; 156 const text = query orelse break :index; 157 158 var results = try srch.Query(rp.Repo(.xit, repo_opts).DB).init(index, aa, text); 159 if (from.len != 0) { 160 var start = obj.Object(.xit, repo_opts).initCommit(state, io, gpa, from[0..hex_len]) catch return error.NotFound; 161 defer start.deinit(); 162 const key = try srch_cmmt.docKey(repo_opts.hash, start.content.commit.metadata.timestamp, &start.oid); 163 results.seek(&key); 164 } 165 while (try results.next()) |key| { 166 const oid = try srch_cmmt.keyOid(repo_opts.hash, key); 167 if (count == page_size) { 168 next_start = try aa.dupe(u8, &oid); 169 break; 170 } 171 var commit_object = try obj.Object(.xit, repo_opts).initCommit(state, io, gpa, &oid); 172 defer commit_object.deinit(); 173 buf[count] = try commitEntry(repo_kind, repo_opts, arena, repo, io, gpa, admin_moment, &commit_object, message and count == 0); 174 count += 1; 175 } 176 searched = true; 177 } 178 if (query != null and !searched) return error.NotFound; 179 180 if (!searched) { 181 // paging keeps the ref and starts the walk at the url's commit 182 var start_oid = resolved.oid; 183 if (from.len != 0) @memcpy(&start_oid, from); 184 185 var iter = repo.log(io, gpa, .{ .start_oids = &.{start_oid}, .first_parent = true }) catch return emptyResult(aa, handle, resolved.ref_or_oid, resolved.value, base_oid); 186 defer iter.deinit(); 187 while (try iter.next(gpa)) |commit_object| { 188 defer commit_object.deinit(); 189 // the base is a stopping point, not an ancestry exclusion 190 if (std.mem.eql(u8, &commit_object.oid, base_oid)) break; 191 if (count == page_size) { 192 next_start = try aa.dupe(u8, &commit_object.oid); 193 break; 194 } 195 buf[count] = try commitEntry(repo_kind, repo_opts, arena, repo, io, gpa, admin_moment, commit_object, message and count == 0); 196 count += 1; 197 } 198 } 199 200 return .{ 201 .handle = try handle.dupe(aa), 202 .ref_or_oid = resolved.ref_or_oid, 203 .ref_or_oid_value = resolved.value, 204 .base_oid = try aa.dupe(u8, base_oid), 205 .commit_count = if (repo_kind == .xit and handle.location == .repo) blk: { 206 if (base_oid.len == 0) { 207 const stats = (xit.patch.readCommitStats(repo_opts, state.extra.moment, &resolved.oid) catch null) orelse break :blk null; 208 break :blk stats.first_parent_depth; 209 } 210 break :blk evt.PatchRev.commitCount(repo_kind, repo_opts, state, io, gpa, base_oid, &resolved.oid) catch null; 211 } else null, 212 .commits = try aa.dupe(Commit, buf[0..count]), 213 .next_start = next_start, 214 .message = message, 215 .search = query, 216 .search_available = search_available, 217 .default_branch = default_branch, 218 }; 219 } 220 221 // one commit's row: its message, author and stats. 222 fn commitEntry( 223 comptime repo_kind: rp.RepoKind, 224 comptime repo_opts: rp.RepoOpts(repo_kind), 225 arena: *std.heap.ArenaAllocator, 226 repo: *rp.Repo(repo_kind, repo_opts), 227 io: std.Io, 228 gpa: std.mem.Allocator, 229 admin_moment: ?evt.AdminDB.HashMap(.read_only), 230 commit_object: *obj.Object(repo_kind, repo_opts), 231 full_message: bool, 232 ) !Commit { 233 const aa = arena.allocator(); 234 const md = commit_object.content.commit.metadata; 235 const text, const truncated = try readMessage(repo_kind, repo_opts, aa, commit_object, full_message); 236 return .{ 237 .oid = try aa.dupe(u8, &commit_object.oid), 238 .parent_oid = if (md.firstParent()) |parent| try aa.dupe(u8, parent) else "0" ** xit.hash.hexLen(repo_opts.hash), 239 .date = try formatDate(aa, md.timestamp), 240 .message = text, 241 .message_truncated = truncated, 242 .author = try ui.Author.init(admin_moment, arena, md.author orelse ""), 243 .committer = if (md.committer) |committer| 244 (if (std.mem.eql(u8, identityOf(committer), identityOf(md.author orelse ""))) .unknown else try ui.Author.init(admin_moment, arena, committer)) 245 else 246 .unknown, 247 .timestamp = try Undo.formatTimestamp(aa, std.math.cast(i64, md.timestamp) orelse -1), 248 .merge = if (md.parent_oids) |parent_oids| parent_oids.len > 1 else false, 249 .stats = if (repo_kind == .xit) try repo.commitStats(io, gpa, .{ .oid = &commit_object.oid }) else null, 250 }; 251 } 252 253 // what merge `merge_oid` brought in: its second parent's log back to the 254 // parents' common ancestor, or the whole log when they share none. 255 pub fn resolveMerge( 256 comptime repo_kind: rp.RepoKind, 257 comptime repo_opts: rp.RepoOpts(repo_kind), 258 arena: *std.heap.ArenaAllocator, 259 repo: *rp.Repo(repo_kind, repo_opts), 260 io: std.Io, 261 gpa: std.mem.Allocator, 262 merge_oid: []const u8, 263 ) !struct { oid: []const u8, base_oid: []const u8 } { 264 const aa = arena.allocator(); 265 evt.PatchRev.validateOid(repo_opts.hash, merge_oid) catch return error.NotFound; 266 var moment = try repo.core.latestMoment(); 267 const state = rp.Repo(repo_kind, repo_opts).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 268 var merge = obj.Object(repo_kind, repo_opts).initCommit(state, io, gpa, merge_oid[0..comptime xit.hash.hexLen(repo_opts.hash)]) catch return error.NotFound; 269 defer merge.deinit(); 270 const parent_oids = merge.content.commit.metadata.parent_oids orelse &.{}; 271 if (parent_oids.len < 2) return error.NotFound; 272 const oid = try aa.dupe(u8, &parent_oids[1]); 273 const base_oid = mrg.commonAncestor(repo_kind, repo_opts, state, io, gpa, &parent_oids[0], &parent_oids[1]) catch |err| switch (err) { 274 error.NoCommonAncestor => return .{ .oid = oid, .base_oid = "" }, 275 error.MultipleMergeBases => return error.NotFound, 276 else => return err, 277 }; 278 return .{ .oid = oid, .base_oid = try aa.dupe(u8, &base_oid) }; 279 } 280 281 // an author or committer line without its timestamp. 282 fn identityOf(line: []const u8) []const u8 { 283 const close_bracket = std.mem.indexOfScalar(u8, line, '>') orelse return line; 284 return line[0 .. close_bracket + 1]; 285 } 286 287 // an empty listing pinned to a ref, for the wasm / no-repo / unresolved paths. 288 pub fn emptyResult(aa: std.mem.Allocator, handle: ui.RepoHandle, ref_or_oid: ui.RoutablePage.RefOrOid, value: []const u8, base_oid: []const u8) !Self { 289 return .{ 290 .handle = try handle.dupe(aa), 291 .ref_or_oid = ref_or_oid, 292 .ref_or_oid_value = try aa.dupe(u8, value), 293 .base_oid = try aa.dupe(u8, base_oid), 294 .commit_count = null, 295 .commits = &.{}, 296 .next_start = null, 297 }; 298 } 299 300 // "YYYY-MM-DD" for a unix timestamp. 301 fn formatDate(arena: std.mem.Allocator, timestamp: u64) ![]const u8 { 302 const epoch_secs = std.time.epoch.EpochSeconds{ .secs = timestamp }; 303 const year_day = epoch_secs.getEpochDay().calculateYearDay(); 304 const month_day = year_day.calculateMonthDay(); 305 return std.fmt.allocPrint(arena, "{d:0>4}-{d:0>2}-{d:0>2}", .{ 306 year_day.year, 307 month_day.month.numeric(), 308 month_day.day_index + 1, 309 }); 310 } 311 312 // the commit message, trimmed and read into `aa`, plus whether its preview was 313 // cut short. 314 fn readMessage( 315 comptime repo_kind: rp.RepoKind, 316 comptime repo_opts: rp.RepoOpts(repo_kind), 317 aa: std.mem.Allocator, 318 commit_object: *xit.object.Object(repo_kind, repo_opts), 319 full_message: bool, 320 ) !struct { []const u8, bool } { 321 var message: std.ArrayList(u8) = .empty; 322 const byte_truncated = if (commit_object.readMessage(aa, &message, .limited(max_message_size))) |_| 323 false 324 else |err| switch (err) { 325 error.StreamTooLong => true, 326 else => |e| return e, 327 }; 328 const preview_end = if (full_message) null else ui.detailPreviewEnd(message.items); 329 const text = if (preview_end) |end| 330 message.items[0..end] 331 else if (byte_truncated) 332 trimIncompleteCodepoint(message.items) 333 else 334 message.items; 335 return .{ std.mem.trim(u8, text, " \t\r\n"), byte_truncated or preview_end != null }; 336 } 337 338 /// drop any incomplete UTF data from the end of a byte array 339 fn trimIncompleteCodepoint(bytes: []const u8) []const u8 { 340 var i = bytes.len; 341 while (i > 0 and bytes.len - i < 4) { 342 i -= 1; 343 const byte = bytes[i]; 344 if (byte & 0b1100_0000 == 0b1000_0000) continue; // continuation byte 345 const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch return bytes; 346 return if (i + seq_len > bytes.len) bytes[0..i] else bytes; 347 } 348 return bytes; 349 } 350 351 // a focusable, word-wrapping box holding a commit message. `bottom_label` 352 // marks a message the page only shows part of ("" when it's whole). 353 fn messageBox(allocator: std.mem.Allocator, message: []const u8, bottom_label: []const u8) !wgt.TextBox { 354 var tb = try wgt.TextBox.init(allocator, message, .{ 355 .border_style = .single, 356 .round_corners = true, 357 .wrap_kind = .word, 358 .label = " message ", 359 .bottom_label = bottom_label, 360 .detect_links = true, 361 }); 362 tb.getFocus().mode = .all; 363 return tb; 364 } 365 366 // the first line of a commit message, for the one-row list entries. 367 fn firstLine(message: []const u8) []const u8 { 368 const nl = std.mem.indexOfScalar(u8, message, '\n'); 369 return if (nl) |i| std.mem.trimEnd(u8, message[0..i], " \t\r") else message; 370 } 371 372 pub const View = struct { 373 // a vertical stack: the clone url row on top, then a horizontal 374 // split with the commit list on the left and a pane on the right 375 // showing the selected commit's details. 376 box: wgt.Box(ui.Widget), // vert: [header_index] = clone url row, [content_index] = split 377 data: *const Self, 378 session: *ui.Session, 379 // the commit the pane currently shows (index into data.commits). 380 detailed_index: ?usize, 381 382 // the sub-header leads the box where there is one; local mode has none 383 const header_index: usize = 0; 384 // indices within the content box (the horizontal split). 385 const list_index: usize = 0; 386 const detail_index: usize = 1; 387 const list_max_width: usize = 35; 388 const detail_min_width: usize = 40; 389 390 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 391 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 392 errdefer outer.deinit(allocator); 393 394 if (data.no_commits) { 395 var center = try ui.widget.initNoCommits(allocator, session, data.handle); 396 errdefer center.deinit(allocator); 397 outer.getFocus().child_id = center.getFocus().id; 398 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 399 return .{ .box = outer, .data = data, .session = session, .detailed_index = null }; 400 } 401 402 // the search box and the clone url at the top. local mode has neither. 403 if (session.data.host_kind == .server) { 404 var header_view = try ui.widget.SearchHeader.init(allocator, session, data.handle, " search ", "search", data.search, data.search_available); 405 errdefer header_view.deinit(allocator); 406 // a row taller than the header, leaving a blank line beneath it 407 try outer.children.put(allocator, header_view.getFocus().id, .{ .widget = .{ .search_header = header_view }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 408 } 409 410 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 411 errdefer box.deinit(allocator); 412 413 // the commit list (one focusable row each), plus a "next" link 414 { 415 var list_scroll = blk: { 416 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 417 errdefer list_box.deinit(allocator); 418 for (data.commits, 0..) |commit, index| { 419 // an in-page "ai:" anchor so a commit row is clickable with 420 // js off (the browser follows it, rooting the list there); 421 // with wasm the click just selects it and swaps the pane. 422 try addRow(allocator, &list_box, firstLine(commit.message), try commitRowLink(session.page_arena, data, commit, index == 0 and data.message), if (commit.merge) "(merge)" else ""); 423 } 424 if (data.next_start) |next| { 425 try addRow(allocator, &list_box, "next →", try nextPageLink(session.page_arena, data, next), ""); 426 } 427 if (list_box.children.count() > 0) list_box.getFocus().child_id = list_box.children.keys()[0]; 428 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 429 }; 430 errdefer list_scroll.deinit(allocator); 431 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 432 } 433 434 // the detail pane: a frame around a scroll of its rows 435 { 436 var detail_outer = blk: { 437 var detail_scroll = try Diff.View.initEmpty(allocator, session); 438 errdefer detail_scroll.deinit(allocator); 439 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 440 errdefer frame.deinit(allocator); 441 // the frame's selected child is its scroll, so the focus chain 442 // reaches a row (populateDetail points the scroll's inner box at 443 // one), letting focus recovery descend into the pane after 444 // it's laid out beside a too-narrow list. 445 frame.getFocus().child_id = detail_scroll.getFocus().id; 446 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .diff_view = detail_scroll }, .rect = null, .min_size = null }); 447 break :blk frame; 448 }; 449 errdefer detail_outer.deinit(allocator); 450 detail_outer.getFocus().mode = .mouse; 451 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 452 } 453 454 box.getFocus().child_id = box.children.keys()[list_index]; 455 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 456 457 // focus lives in the split, except that search results start in the 458 // search box so the query can be refined right away. 459 outer.getFocus().child_id = outer.children.keys()[if (data.search != null) header_index else outer.children.count() - 1]; 460 461 return .{ 462 .box = outer, 463 .data = data, 464 .session = session, 465 .detailed_index = null, 466 }; 467 } 468 469 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8, bottom_label: []const u8) !void { 470 var row = try wgt.TextBox.init(allocator, label, .{ .border_style = .hidden, .round_corners = true, .wrap_kind = .word, .bottom_label = bottom_label }); 471 errdefer row.deinit(allocator); 472 row.getFocus().mode = .all; 473 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 474 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 475 } 476 477 // a focusable row following the "a:" `link`. 478 fn addLink(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 479 var tb = try wgt.TextBox.init(allocator, label, .{ .border_style = .single, .round_corners = true, .wrap_kind = .none }); 480 errdefer tb.deinit(allocator); 481 tb.getFocus().mode = .all; 482 tb.getFocus().kind = .{ .custom = link }; 483 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 484 } 485 486 // how much of the message a pane's box holds: a truncated preview links to 487 // the pane holding the whole thing, which is that link's destination. 488 const MessageBox = enum { preview, whole }; 489 490 // the selected commit's message. 491 fn addMessageBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), commit: Commit, kind: MessageBox) !void { 492 // a truncated message keeps its box even with no whole line to show, 493 // since the box is what links to the whole thing. 494 if (commit.message.len == 0 and !commit.message_truncated) return; 495 const cut_short = commit.message_truncated and kind == .preview; 496 var tb = try messageBox(allocator, commit.message, if (cut_short) " click or press enter to see more " else ""); 497 errdefer tb.deinit(allocator); 498 if (cut_short) tb.getFocus().kind = .{ .custom = try messageLink(self.session.page_arena, self.data, commit.oid) }; 499 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 500 } 501 502 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 503 self.box.deinit(allocator); 504 } 505 506 // the split is last, after the sub-header where there is one 507 fn contentIndex(self: *View) usize { 508 return self.box.children.count() - 1; 509 } 510 511 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 512 return &self.box.children.values()[self.contentIndex()].widget.box; 513 } 514 515 fn header(self: *View) ?*ui.widget.SearchHeader { 516 switch (self.box.children.values()[header_index].widget) { 517 .search_header => |*header_view| return header_view, 518 else => return null, 519 } 520 } 521 522 fn headerActive(self: *View) bool { 523 const header_view = self.header() orelse return false; 524 return self.box.getFocus().child_id == header_view.getFocus().id; 525 } 526 527 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 528 return &self.contentBox().children.values()[list_index].widget.scroll; 529 } 530 531 fn listBox(self: *View) *wgt.Box(ui.Widget) { 532 return &self.listScroll().child.box; 533 } 534 535 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 536 return &self.contentBox().children.values()[detail_index].widget.box; 537 } 538 539 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 540 return &self.detailOuter().children.values()[0].widget.diff_view.scroll; 541 } 542 543 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 544 return &self.detailScroll().child.box; 545 } 546 547 fn detailActive(self: *View) bool { 548 const content = self.contentBox(); 549 const cid = content.getFocus().child_id orelse return false; 550 return content.children.getIndex(cid) == detail_index; 551 } 552 553 // the selected commit's index, or null when the "next" row is selected. 554 fn selectedCommitIndex(self: *View) ?usize { 555 const lb = self.listBox(); 556 const cid = lb.getFocus().child_id orelse return null; 557 const idx = lb.children.getIndex(cid) orelse return null; 558 if (idx >= self.data.commits.len) return null; 559 return idx; 560 } 561 562 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 563 self.clearGrid(); 564 if (self.data.no_commits) return self.box.build(allocator, constraint, root_focus); 565 566 // swap the detail pane to the selected commit when it changes. 567 try self.refreshDetail(allocator); 568 569 // the selected list row shows a border (the focused TextBox upgrades it 570 // to a double border itself); the rest stay borderless. 571 const lb = self.listBox(); 572 for (lb.children.keys(), lb.children.values()) |id, *child| { 573 switch (child.widget) { 574 .text_box => |*tb| ui.widget.markSelected(tb, lb.getFocus().child_id == id), 575 else => {}, 576 } 577 } 578 579 // cap the list at list_max_width only while the detail pane fits beside it. 580 // the box drops the pane when the width can't hold both minimums, so when 581 // it's that narrow we lift the cap and let the list fill the whole width. 582 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 583 self.contentBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 584 585 // stretch the detail pane across the rest of the width so it fills the area 586 // rather than shrinking to its content; its scroll fills the pane. when 587 // too narrow for both, it fills the whole width. 588 const detail_width: usize = if (constraint.max_size.width) |w| 589 (if (both_panes_fit) w - list_max_width else w) 590 else 591 detail_min_width; 592 self.contentBox().children.values()[detail_index].min_size = .{ .width = detail_width, .height = null }; 593 594 // the message is the pane's only wrapping row, and wrapping needs a 595 // bounded width, which the pane's scroll doesn't grant (it scrolls 596 // horizontally too). cap it to the pane, leaving room for the border 597 // and the scrollbar column. re-capped each build so it tracks resizes. 598 for (self.detailInner().children.values()) |*child| switch (child.widget) { 599 .text_box => |text_box| if (text_box.options.wrap_kind == .word) { 600 child.max_size = .{ .width = detail_width -| 3, .height = null }; 601 }, 602 else => {}, 603 }; 604 605 // the web bounds the layout to the browser viewport like the terminal; 606 // each Scroll's web-native mode hands its full content to a real 607 // scrollable element, so we no longer build unbounded here. 608 // 609 // crossing panes only re-selects the content box's child (focusDetail / 610 // focusList); when the window is too narrow to show both, the pane that 611 // held focus is dropped here. the framework recovers focus after the 612 // top-level build by re-deriving it down the selected-child chain, so 613 // there's nothing to fix up afterward. 614 try self.box.build(allocator, constraint, root_focus); 615 } 616 617 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 618 const sel = self.selectedCommitIndex() orelse return; 619 if (self.detailed_index) |d| if (d == sel) return; 620 try self.populateDetail(allocator, sel); 621 self.detailed_index = sel; 622 } 623 624 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 625 const commit = self.data.commits[sel]; 626 const inner = self.detailInner(); 627 628 for (inner.children.values()) |*child| child.widget.deinit(allocator); 629 inner.children.clearAndFree(allocator); 630 inner.getFocus().child_id = null; 631 632 if (sel == 0 and self.data.message) { 633 try addLink(allocator, inner, "← back to commit", try commitsLink(self.session.page_arena, self.data, commit.oid)); 634 try self.addMessageBox(allocator, inner, commit, .whole); 635 } else { 636 const pa = self.session.page_arena; 637 { 638 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 639 errdefer row.deinit(allocator); 640 const diff_route = self.data.handle.location.commitDiffRoute(commit.oid, commit.parent_oid) orelse return error.RouteTooLong; 641 try addLink(allocator, &row, "view diff", try std.fmt.allocPrint(pa.allocator(), "a:{s}", .{try diff_route.toUrl(pa)})); 642 try addLink(allocator, &row, "view files at this commit", try filesObjectLink(pa, self.data.handle.location, commit.oid)); 643 row.getFocus().child_id = row.children.keys()[0]; 644 try inner.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 645 } 646 if (commit.merge) if (self.data.handle.location.commitsMergeRoute(commit.oid)) |route| { 647 try addLink(allocator, inner, "view commits from this merge", try std.fmt.allocPrint(pa.allocator(), "a:{s}", .{try route.toUrl(pa)})); 648 }; 649 if (commit.author != .unknown or commit.committer != .unknown) { 650 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 651 errdefer row.deinit(allocator); 652 if (commit.author != .unknown) { 653 var tb = try ui.authorBox(allocator, pa, commit.author); 654 errdefer tb.deinit(allocator); 655 try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 656 } 657 if (commit.committer != .unknown) { 658 var tb = try ui.authorBox(allocator, pa, commit.committer); 659 errdefer tb.deinit(allocator); 660 tb.options.label = " committer "; 661 try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 662 } 663 row.getFocus().child_id = row.children.keys()[0]; 664 try inner.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 665 } 666 { 667 var tb = try wgt.TextBox.init(allocator, commit.timestamp, .{ .border_style = .single, .round_corners = true, .wrap_kind = .none, .label = " timestamp " }); 668 errdefer tb.deinit(allocator); 669 tb.getFocus().mode = .all; 670 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 671 } 672 if (commit.stats) |stats| { 673 var text: std.Io.Writer.Allocating = .init(allocator); 674 defer text.deinit(); 675 try text.writer.print("lines changed: {d}", .{stats.lines_added + stats.lines_changed}); 676 if (stats.lines_removed != 0) try text.writer.print("\nlines removed: {d}", .{stats.lines_removed}); 677 const bytes_added = stats.bytes_added >= stats.bytes_removed; 678 const bytes = if (bytes_added) stats.bytes_added - stats.bytes_removed else stats.bytes_removed - stats.bytes_added; 679 try text.writer.print("\nbytes {s}: {d}\nfiles changed: {d}", .{ 680 if (bytes_added) "added" else "removed", bytes, stats.files_added + stats.files_changed, 681 }); 682 if (stats.files_removed != 0) try text.writer.print("\nfiles removed: {d}", .{stats.files_removed}); 683 var tb = try wgt.TextBox.init(allocator, text.written(), .{ .border_style = .single, .round_corners = true, .wrap_kind = .none, .label = " stats " }); 684 errdefer tb.deinit(allocator); 685 tb.getFocus().mode = .all; 686 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 687 } 688 try self.addMessageBox(allocator, inner, commit, .preview); 689 } 690 691 // point the pane at its first row so focus recovery can land here. 692 if (inner.children.count() > 0) inner.getFocus().child_id = inner.children.keys()[0]; 693 694 // reset the scroll to the top for the newly-shown commit: directly on the 695 // terminal (the wasm offset), and via a version bump on the web (so the 696 // renderer's scroll id changes and JS drops the preserved position). 697 const sc = self.detailScroll(); 698 sc.x = 0; 699 sc.y = 0; 700 sc.getFocus().version +%= 1; 701 } 702 703 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 704 if (self.data.no_commits) return self.box.input(allocator, key, root_focus); 705 // scrolling crosses the sub header boundary the same way arrows do 706 const direction = inp.vertDirection(key); 707 if (self.headerActive()) { 708 // active means there is one 709 const header_view = self.header() orelse unreachable; 710 if (direction == .down) { 711 root_focus.setFocus(self.contentBox().getFocus().id); 712 return; 713 } 714 if (key == .enter) if (try header_view.submittedText(allocator)) |text| { 715 defer allocator.free(text); 716 return self.submit(text); 717 }; 718 return header_view.input(allocator, key, root_focus); 719 } 720 if (direction == .up and self.contentAtTop() and self.focusHeader(root_focus)) return; 721 if (self.detailActive()) { 722 const detail_view = &self.detailOuter().children.values()[0].widget.diff_view; 723 if (key == .arrow_left and self.detailScroll().x == 0) { 724 if (!detail_view.moveInRow(root_focus, false)) self.focusList(root_focus); 725 } else { 726 try detail_view.input(allocator, key, root_focus); 727 } 728 } else { 729 try self.listInput(key, root_focus); 730 } 731 } 732 733 // navigate to the typed query's results, pinned to the list's ref. an 734 // empty query leaves the results for the plain log. 735 fn submit(self: *View, text: []const u8) !void { 736 if (text.len == 0 and self.data.search == null) return; 737 // an object view or a base-bounded list searches the default branch, so the url says so 738 const route = if (self.data.default_branch.len != 0) 739 self.data.handle.location.commitsRoute(.branch, self.data.default_branch, "") 740 else 741 self.data.handle.location.commitsRoute(self.data.ref_or_oid, self.data.ref_or_oid_value, self.data.base_oid); 742 try self.session.navigate((route orelse return).withSearch(text) orelse return); 743 } 744 745 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 746 // up/down (and the scroll wheel) move the selection a row; page up/down 747 // jump a fixed amount. right/Enter cross into the detail pane. Enter/clicks 748 // on the "next" row become navigation in the host before reaching here. 749 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 750 ui.widget.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 751 return; 752 } 753 switch (key) { 754 .enter => if (self.selectedCommitIndex() != null) 755 self.focusDetail(root_focus) 756 else if (self.data.next_start) |next| { 757 if (pageRoute(self.data, next)) |route| try self.session.navigate(route); 758 }, 759 .arrow_right => self.focusDetail(root_focus), 760 else => {}, 761 } 762 } 763 764 // enter the detail pane. the host arrives here on right-arrow or Enter from the 765 // list. a pane with no rows can't be entered. setFocus handles the too-narrow 766 // case where the pane isn't laid out yet (it gets selected, then focused after 767 // the next build, landing on its remembered row). 768 fn focusDetail(self: *View, root_focus: *Focus) void { 769 if (self.detailInner().children.count() == 0) return; 770 root_focus.setFocus(self.detailOuter().getFocus().id); 771 } 772 773 // return to the list. 774 fn focusList(self: *View, root_focus: *Focus) void { 775 root_focus.setFocus(self.listScroll().getFocus().id); 776 } 777 778 pub fn clearGrid(self: *View) void { 779 self.box.clearGrid(); 780 } 781 782 pub fn getGrid(self: View) ?Grid { 783 return self.box.getGrid(); 784 } 785 786 pub fn getFocus(self: *View) *Focus { 787 return self.box.getFocus(); 788 } 789 790 fn contentAtTop(self: *View) bool { 791 if (self.detailActive()) { 792 const inner = self.detailInner(); 793 const cid = inner.getFocus().child_id orelse return true; 794 const first_focused = inner.children.count() > 0 and cid == inner.children.keys()[0]; 795 return first_focused and self.detailScroll().y == 0; 796 } 797 const lb = self.listBox(); 798 const cid = lb.getFocus().child_id orelse return true; 799 return lb.children.getIndex(cid) == 0; 800 } 801 802 // only the header's own row sits directly below the repository header. 803 pub fn atTop(self: *View) bool { 804 if (self.data.no_commits) return true; 805 const focusable = if (self.header()) |header_view| header_view.hasFocusable() else false; 806 return self.headerActive() or (!focusable and self.contentAtTop()); 807 } 808 809 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 810 const header_view = self.header() orelse return false; 811 return header_view.focusHeader(root_focus); 812 } 813 }; 814 815 // the "a:" navigation link for the commits page walking from commit `oid` within 816 // `data.handle.location`. 817 fn commitsLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, oid: []const u8) ![]const u8 { 818 const route = data.handle.location.commitsRoute(.object, oid, data.base_oid) orelse return error.RouteTooLong; 819 const url = try route.toUrl(page_arena); 820 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 821 } 822 823 // this list's page starting at `oid`: a repo's branch or tag keeps its ref (and 824 // its query) and moves `from` along, so the search box survives paging. a fork 825 // or an object view roots the page at the commit itself. 826 fn pageRoute(data: *const Self, oid: []const u8) ?ui.RoutablePage { 827 if (data.handle.location != .repo or data.ref_or_oid == .object) { 828 return data.handle.location.commitsRoute(.object, oid, data.base_oid); 829 } 830 const route = data.handle.location.commitsRoute(data.ref_or_oid, data.ref_or_oid_value, data.base_oid) orelse return null; 831 const paged = route.withFrom(oid) orelse return null; 832 return paged.withSearch(data.search orelse return paged); 833 } 834 835 fn nextPageLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, oid: []const u8) ![]const u8 { 836 const route = pageRoute(data, oid) orelse return error.RouteTooLong; 837 const url = try route.toUrl(page_arena); 838 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 839 } 840 841 // the "a:" link to the files tab at commit `oid` (an object ref), at its root 842 // directory, within `location`. 843 fn filesObjectLink(page_arena: *std.heap.ArenaAllocator, location: ui.RoutablePage.RepoLocation, oid: []const u8) ![]const u8 { 844 const route = location.filesRoute(.object, oid, "", 0) orelse return error.RouteTooLong; 845 const url = try route.toUrl(page_arena); 846 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 847 } 848 849 // the in-page "ai:" anchor for selecting a commit, showing its whole message 850 // when `message` is set. the href is only followed with js off. 851 fn commitRowLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, commit: Commit, message: bool) ![]const u8 { 852 // a result roots the same results page at its commit, so the query 853 // survives following the row 854 if (data.search != null) { 855 const route = pageRoute(data, commit.oid) orelse return error.RouteTooLong; 856 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{try route.toUrl(page_arena)}); 857 } 858 const route = (if (message) 859 data.handle.location.commitMessageRoute(.object, commit.oid, data.base_oid) 860 else 861 data.handle.location.commitsRoute(.object, commit.oid, data.base_oid)) orelse return error.RouteTooLong; 862 const url = try route.toUrl(page_arena); 863 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 864 } 865 866 // the "a:" link to the page showing `oid`'s message on its own. 867 fn messageLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, oid: []const u8) ![]const u8 { 868 const route = data.handle.location.commitMessageRoute(.object, oid, data.base_oid) orelse return error.RouteTooLong; 869 const url = try route.toUrl(page_arena); 870 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 871 } 872