╭ Diff.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const ui = @import("../../ui.zig"); 3 const xit = @import("xit"); 4 const rp = xit.repo; 5 const df = xit.diff; 6 const wgt = xit.xitui.widget; 7 const layout = xit.xitui.layout; 8 const Key = xit.xitui.input.Key; 9 const Grid = xit.xitui.grid.Grid; 10 const Focus = xit.xitui.focus.Focus; 11 12 pub const page_size = 10; 13 pub const Hunk = struct { 14 path: ?[]const u8 = null, 15 text: []const u8, 16 }; 17 pub const Window = struct { 18 hunks: []const Hunk = &.{}, 19 start: usize = 0, 20 has_more: bool = false, 21 }; 22 23 route: Route, 24 path: []const u8 = "", 25 window: Window, 26 27 const Self = @This(); 28 29 pub const Route = union(enum) { 30 commit: struct { location: ui.RoutablePage.RepoLocation, oid: []const u8 }, 31 fork: struct { identity: []const u8, id: []const u8 }, 32 33 fn link(self: Route, arena: *std.heap.ArenaAllocator, start: usize, path: []const u8) ![]const u8 { 34 const route = switch (self) { 35 .commit => |c| c.location.commitsRoute(.object, c.oid, start, path), 36 .fork => |f| ui.RoutablePage.forkDiffRoute(f.identity, f.id, start, path), 37 } orelse return error.RouteTooLong; 38 return std.fmt.allocPrint(arena.allocator(), "a:{s}", .{try route.toUrl(arena)}); 39 } 40 }; 41 42 // render a window of the net diff between two commits 43 pub fn render( 44 comptime repo_kind: rp.RepoKind, 45 comptime repo_opts: rp.RepoOpts(repo_kind), 46 io: std.Io, 47 gpa: std.mem.Allocator, 48 arena: std.mem.Allocator, 49 repo: *rp.Repo(repo_kind, repo_opts), 50 base_oid: ?*const [xit.hash.hexLen(repo_opts.hash)]u8, 51 head_oid: [xit.hash.hexLen(repo_opts.hash)]u8, 52 start: usize, 53 path: []const u8, 54 ) !Window { 55 var tree_diff = try repo.treeDiff(io, gpa, base_oid, &head_oid); 56 defer tree_diff.deinit(); 57 58 var file_iter = try repo.filePairs(io, gpa, .{ .tree = .{ .tree_diff = &tree_diff } }); 59 60 var hunks: std.ArrayList(Hunk) = .empty; 61 var index: usize = 0; // running hunk index across all files 62 var has_more = false; 63 64 file_loop: while (try file_iter.next()) |pair_val| { 65 var pair = pair_val; 66 defer pair.deinit(); 67 68 if (path.len != 0 and !std.mem.eql(u8, pair.path, path)) continue; 69 70 var hunk_iter = try df.HunkIterator(repo_kind, repo_opts).init(gpa, &pair.a, &pair.b); 71 defer hunk_iter.deinit(gpa); 72 73 // the path label rides on the first of this file's hunks we actually show. 74 var path_attached = false; 75 while (try hunk_iter.next(gpa)) |hunk_val| { 76 var hunk = hunk_val; 77 defer hunk.deinit(gpa); 78 79 const i = index; 80 index += 1; 81 if (i < start) continue; // before this window 82 if (hunks.items.len >= page_size) { 83 has_more = true; 84 break :file_loop; 85 } 86 try hunks.append(arena, .{ 87 .path = if (path_attached) null else try arena.dupe(u8, pair.path), 88 .text = try renderHunk(repo_kind, repo_opts, arena, &hunk_iter, &hunk), 89 }); 90 path_attached = true; 91 } 92 } 93 94 return .{ .hunks = try hunks.toOwnedSlice(arena), .start = start, .has_more = has_more }; 95 } 96 97 // render edits with right-aligned line numbers 98 fn renderHunk( 99 comptime repo_kind: rp.RepoKind, 100 comptime repo_opts: rp.RepoOpts(repo_kind), 101 arena: std.mem.Allocator, 102 hunk_iter: *df.HunkIterator(repo_kind, repo_opts), 103 hunk: *df.Hunk(repo_kind, repo_opts), 104 ) ![]const u8 { 105 var out: std.Io.Writer.Allocating = .init(arena); 106 defer out.deinit(); 107 var max_num: usize = 1; 108 for (hunk.edits.items) |edit| { 109 const n = editLineNum(edit) + 1; 110 if (n > max_num) max_num = n; 111 } 112 const width = std.fmt.count("{d}", .{max_num}); 113 for (hunk.edits.items, 0..) |edit, i| { 114 const text = switch (edit) { 115 .eql => |e| try hunk_iter.line_iter_b.get(e.new_line.num), 116 .ins => |e| try hunk_iter.line_iter_b.get(e.new_line.num), 117 .del => |e| try hunk_iter.line_iter_a.get(e.old_line.num), 118 }; 119 defer switch (edit) { 120 .eql, .ins => hunk_iter.line_iter_b.free(text), 121 .del => hunk_iter.line_iter_a.free(text), 122 }; 123 const prefix: u8 = switch (edit) { 124 .eql => ' ', 125 .ins => '+', 126 .del => '-', 127 }; 128 if (i != 0) try out.writer.writeByte('\n'); 129 const num = editLineNum(edit) + 1; 130 try out.writer.splatByteAll(' ', width - std.fmt.count("{d}", .{num})); 131 try out.writer.print("{d} {c}{s}", .{ num, prefix, text }); 132 } 133 return out.toOwnedSlice(); 134 } 135 136 // the line number to show for an edit: the new-side number for kept and inserted 137 // lines, the old-side number for deletions (0-based in the diff machinery). 138 fn editLineNum(edit: df.Edit) usize { 139 return switch (edit) { 140 .eql => |e| e.new_line.num, 141 .ins => |e| e.new_line.num, 142 .del => |e| e.old_line.num, 143 }; 144 } 145 146 pub fn appendWindow(data: @This(), allocator: std.mem.Allocator, session: *ui.Session, box: *wgt.Box(ui.Widget)) !void { 147 if (data.path.len != 0) { 148 try addLink(allocator, box, data.path, ""); 149 try addLink(allocator, box, "← all files", try data.route.link(session.page_arena, 0, "")); 150 } 151 if (data.window.start > 0) try addLink(allocator, box, "← previous", try data.route.link(session.page_arena, data.window.start -| page_size, data.path)); 152 for (data.window.hunks) |hunk| { 153 if (data.path.len == 0) if (hunk.path) |path| try addLink(allocator, box, path, try data.route.link(session.page_arena, 0, path)); 154 if (hunk.text.len != 0) try addLink(allocator, box, hunk.text, ""); 155 } 156 if (data.window.has_more) try addLink(allocator, box, "next →", try data.route.link(session.page_arena, data.window.start + page_size, data.path)); 157 } 158 159 fn addLink(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: []const u8) !void { 160 var tb = try wgt.TextBox.init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 161 errdefer tb.deinit(allocator); 162 tb.getFocus().mode = .all; 163 if (link.len != 0) tb.getFocus().kind = .{ .custom = link }; 164 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null, .flex = .shrink }); 165 } 166 167 pub const View = struct { 168 scroll: wgt.Scroll(ui.Widget), 169 session: *ui.Session, 170 171 pub fn initEmpty(allocator: std.mem.Allocator, session: *ui.Session) !View { 172 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 173 errdefer box.deinit(allocator); 174 return .{ 175 .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .both, .web_native = !session.is_terminal, .fill = true }), 176 .session = session, 177 }; 178 } 179 180 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 181 var view = try initEmpty(allocator, session); 182 errdefer view.deinit(allocator); 183 const box = view.inner(); 184 try data.appendWindow(allocator, session, box); 185 if (box.children.count() == 0) try addLink(allocator, box, "no changes", ""); 186 box.getFocus().child_id = box.children.keys()[0]; 187 return view; 188 } 189 190 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 191 self.scroll.deinit(allocator); 192 } 193 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 194 self.clearGrid(); 195 try self.scroll.build(allocator, constraint, root_focus); 196 } 197 pub fn clearGrid(self: *View) void { 198 self.scroll.clearGrid(); 199 } 200 pub fn getGrid(self: View) ?Grid { 201 return self.scroll.getGrid(); 202 } 203 pub fn getFocus(self: *View) *Focus { 204 return self.scroll.getFocus(); 205 } 206 pub fn atTop(self: *View) bool { 207 const box = self.inner(); 208 return self.scroll.y == 0 and (box.children.count() == 0 or box.getFocus().child_id == box.children.keys()[0]); 209 } 210 fn inner(self: *View) *wgt.Box(ui.Widget) { 211 return &self.scroll.child.box; 212 } 213 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 214 _ = allocator; 215 const sc = &self.scroll; 216 switch (key) { 217 // scroll horizontally within the diff 218 .arrow_left => { 219 if (sc.x > 0) { 220 sc.x -= 1; 221 self.scroll.clampToContent(); 222 } 223 }, 224 .arrow_right => { 225 sc.x += 1; 226 self.scroll.clampToContent(); 227 }, 228 .arrow_up => self.moveDiff(root_focus, -1), 229 .arrow_down => self.moveDiff(root_focus, 1), 230 .page_up => self.pageDiff(root_focus, -10), 231 .page_down => self.pageDiff(root_focus, 10), 232 .home => self.jumpDiff(root_focus, false), 233 .end => self.jumpDiff(root_focus, true), 234 // Enter / a click on a "previous"/"next" row follow its "a:" link in 235 // the host (it reloads the adjacent window), so they don't reach here. 236 .mouse => |mouse| switch (mouse.action) { 237 .scroll => |dir| self.moveDiff(root_focus, if (dir == .up) -1 else 1), 238 else => {}, 239 }, 240 else => {}, 241 } 242 } 243 244 // move one step in `delta` (+ down, - up). in the terminal, `delta` is a 245 // line count unless there is a new hunk visible (in which case it is a 246 // hunk count). on the web `delta` is always a hunk count. 247 fn moveDiff(self: *View, root_focus: *Focus, delta: isize) void { 248 const box = self.inner(); 249 const keys = box.children.keys(); 250 if (keys.len == 0) return; 251 const cur: usize = if (root_focus.grandchild_id) |g| (box.children.getIndex(g) orelse 0) else 0; 252 const target = @as(isize, @intCast(cur)) + delta; 253 const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); 254 255 if (self.session.is_terminal) { 256 if (in_range and self.hunkVisible(@intCast(target))) { 257 root_focus.setFocus(keys[@intCast(target)]); 258 return; 259 } 260 261 const sc = &self.scroll; 262 sc.y += delta * 5; // magnify because it's a line count 263 self.scroll.clampToContent(); 264 if (in_range and self.hunkVisible(@intCast(target))) { 265 root_focus.setFocus(keys[@intCast(target)]); 266 } 267 } else { 268 if (in_range) { 269 root_focus.setFocus(keys[@intCast(target)]); 270 } 271 } 272 } 273 274 // page a fixed number of lines, then focus the leading visible hunk 275 // (bottom-most when paging down, top-most when paging up). in the 276 // terminal, `delta` is a line count, and on the web it's a hunk count. 277 fn pageDiff(self: *View, root_focus: *Focus, delta: isize) void { 278 if (self.session.is_terminal) { 279 const sc = &self.scroll; 280 sc.y += delta * 5; // magnify because it's a line count 281 self.scroll.clampToContent(); 282 self.focusVisible(root_focus, delta > 0); 283 } else { 284 const box = self.inner(); 285 const keys = box.children.keys(); 286 if (keys.len == 0) return; 287 const cur: isize = if (root_focus.grandchild_id) |g| @intCast(box.children.getIndex(g) orelse 0) else 0; 288 const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); 289 root_focus.setFocus(keys[target]); 290 } 291 } 292 293 // jump to the first or last hunk. on the web the browser scrolls to the 294 // focused hunk; on the terminal pin the scroll to the top/bottom too. 295 fn jumpDiff(self: *View, root_focus: *Focus, to_end: bool) void { 296 const box = self.inner(); 297 const keys = box.children.keys(); 298 if (keys.len == 0) return; 299 if (self.session.is_terminal) { 300 const sc = &self.scroll; 301 sc.y = if (to_end) std.math.maxInt(isize) else 0; 302 self.scroll.clampToContent(); 303 } 304 root_focus.setFocus(if (to_end) keys[keys.len - 1] else keys[0]); 305 } 306 307 // whether hunk `index` is at least partly within the diff viewport, per the 308 // last build's layout rects (content space) and the current scroll offset. 309 fn hunkVisible(self: *View, index: usize) bool { 310 const box = self.inner(); 311 const sc = &self.scroll; 312 const vp = sc.grid orelse return false; 313 const r = box.children.values()[index].rect orelse return false; 314 const top = sc.y; 315 const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); 316 return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; 317 } 318 319 // focus the visible hunk at the scroll's leading edge. `prefer_last` picks 320 // the bottom-most visible (for downward motion), else the top-most. 321 fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) void { 322 const box = self.inner(); 323 const sc = &self.scroll; 324 const vp = sc.grid orelse return; 325 const top = sc.y; 326 const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); 327 var chosen: ?usize = null; 328 for (box.children.keys(), box.children.values()) |id, *child| { 329 const r = child.rect orelse continue; // content-space layout rect 330 const r_top = r.y; 331 const r_bot = r.y + @as(isize, @intCast(r.size.height)); 332 if (r_bot <= top or r_top >= bottom) continue; // not visible 333 chosen = id; 334 if (!prefer_last) break; // first visible 335 } 336 if (chosen) |id| root_focus.setFocus(id); 337 } 338 }; 339