╭ Refs.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 // how many refs one window of a column shows. 14 pub const page_size = 20; 15 16 // "owner/name", needed to build the columns' window-navigation links. 17 identity: []const u8, 18 // only one column paginates at a time: `after` is the window start of `kind`'s 19 // column; the other column always starts at 0. 20 kind: ui.RoutablePage.RefKind, 21 after: usize, 22 // the current window of each column, plus the next window's start (null at the 23 // end) so the view can decide whether to show a "next →" row. 24 branches: []const []const u8, 25 tags: []const []const u8, 26 branches_next_after: ?usize, 27 tags_next_after: ?usize, 28 29 const Self = @This(); 30 31 const Window = struct { names: []const []const u8, next_after: ?usize }; 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 kind: ui.RoutablePage.RefKind, 39 after: usize, 40 ) !Self { 41 const aa = arena.allocator(); 42 // the offset only applies to its own column; the other stays at 0. 43 const branches_after: usize = if (kind == .branch) after else 0; 44 const tags_after: usize = if (kind == .tag) after else 0; 45 const empty: Self = .{ 46 .identity = try aa.dupe(u8, identity), 47 .kind = kind, 48 .after = after, 49 .branches = &.{}, 50 .tags = &.{}, 51 .branches_next_after = null, 52 .tags_next_after = null, 53 }; 54 55 // no filesystem (wasm) or nowhere to look: empty lists. the wasm path never 56 // calls init anyway — it rebuilds from the serialized snapshot. 57 const io = session.io orelse return empty; 58 const repos_dir = session.repos_dir orelse return empty; 59 60 // the repo's working copy lives at <repos_dir>/<hex event id>. 61 const hex = std.fmt.bytesToHex(event_id.*, .lower); 62 const repo_path = try std.fs.path.join(aa, &.{ repos_dir, &hex }); 63 64 // open with the arena's backing allocator (transient; the ref names are 65 // duped into the page arena so they outlive the repo handle). 66 const gpa = arena.child_allocator; 67 var repo = rp.Repo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return empty; 68 defer repo.deinit(io, gpa); 69 70 var branch_iter = repo.listBranches(io, gpa) catch return empty; 71 defer branch_iter.deinit(io); 72 const branches = try collectWindow(io, aa, &branch_iter, branches_after); 73 74 var tag_iter = repo.listTags(io, gpa) catch return empty; 75 defer tag_iter.deinit(io); 76 const tags = try collectWindow(io, aa, &tag_iter, tags_after); 77 78 return .{ 79 .identity = empty.identity, 80 .kind = kind, 81 .after = after, 82 .branches = branches.names, 83 .tags = tags.names, 84 .branches_next_after = branches.next_after, 85 .tags_next_after = tags.next_after, 86 }; 87 } 88 89 // window a ref iterator without materializing the whole list: skip the prior 90 // windows, take this window's names into the page arena (`aa`), then peek one 91 // more to learn whether a "next →" window exists. 92 fn collectWindow(io: std.Io, aa: std.mem.Allocator, iter: anytype, after: usize) !Window { 93 var skipped: usize = 0; 94 while (skipped < after) : (skipped += 1) { 95 if (try iter.next(io) == null) return .{ .names = &.{}, .next_after = null }; 96 } 97 var names = try std.ArrayListUnmanaged([]const u8).initCapacity(aa, page_size); 98 while (names.items.len < page_size) { 99 const ref = try iter.next(io) orelse break; 100 names.appendAssumeCapacity(try aa.dupe(u8, ref.name)); 101 } 102 const has_more = (try iter.next(io)) != null; 103 return .{ .names = names.items, .next_after = if (has_more) after + page_size else null }; 104 } 105 106 pub const View = struct { 107 // a horizontal box of two vertical columns. each column is a fixed, 108 // centered, non-focusable label above a Scroll of focusable rows, so the 109 // label stays visible while the rows scroll. focus tracks a single selected 110 // row across both columns: the box's focus path points at the active column, 111 // that column's at its scroll, and the scroll's (its rows') at the row. 112 box: wgt.Box(ui.Widget), 113 data: *const Self, 114 115 const left_col = 0; 116 const right_col = 1; 117 118 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 119 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 120 errdefer box.deinit(allocator); 121 122 // the offset only applies to its own column; the other starts at 0. 123 const branches_after: usize = if (data.kind == .branch) data.after else 0; 124 const tags_after: usize = if (data.kind == .tag) data.after else 0; 125 try addColumn(allocator, &box, session, data.identity, .branch, "branches:", data.branches, branches_after, data.branches_next_after); 126 try addColumn(allocator, &box, session, data.identity, .tag, "tags:", data.tags, tags_after, data.tags_next_after); 127 128 var self = View{ .box = box, .data = data }; 129 // select the first row of the first column that has one. 130 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 131 if (child.widget.box.getFocus().child_id != null) { 132 self.box.getFocus().child_id = id; 133 break; 134 } 135 } 136 return self; 137 } 138 139 fn addColumn( 140 allocator: std.mem.Allocator, 141 box: *wgt.Box(ui.Widget), 142 session: *ui.Session, 143 identity: []const u8, 144 kind: ui.RoutablePage.RefKind, 145 label: []const u8, 146 names: []const []const u8, 147 after: usize, 148 next_after: ?usize, 149 ) !void { 150 var column = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 151 errdefer column.deinit(allocator); 152 153 // a fixed, non-focusable header styled like the rows so it left-aligns 154 // with them; it stays visible while the rows scroll below. it declares 155 // its height as a min so the (fill) scroll reserves room for it rather 156 // than consuming the whole column. one text line framed by a (hidden) 157 // border above and below. 158 { 159 var header = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 160 errdefer header.deinit(allocator); 161 try column.children.put(allocator, header.getFocus().id, .{ .widget = .{ .text_box = header }, .rect = null, .min_size = .{ .width = null, .height = 1 + 2 } }); 162 } 163 164 // a long ref list overflows the page height; the rows scroll on their 165 // own (the label above stays put) so they lay out under a fixed height. 166 { 167 var scroll = blk: { 168 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 169 errdefer rows.deinit(allocator); 170 171 // window-navigation rows bracket the names: "← previous" off the 172 // first window, "next →" when more remain. each is a full reload. 173 if (after > 0) try addRow(allocator, &rows, "← previous", try windowLink(session.page_arena, identity, kind, after -| page_size)); 174 for (names) |name| try addRow(allocator, &rows, name, null); 175 if (next_after) |na| try addRow(allocator, &rows, "next →", try windowLink(session.page_arena, identity, kind, na)); 176 177 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 178 179 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal }); 180 }; 181 errdefer scroll.deinit(allocator); 182 183 // the column's focus path skips the label and points at the scroll. 184 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 185 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 186 } 187 188 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 189 } 190 191 // [0] = the fixed label, [1] = the scrollable rows. 192 fn columnScroll(column: *wgt.Box(ui.Widget)) *wgt.Scroll(ui.Widget) { 193 return &column.children.values()[1].widget.scroll; 194 } 195 196 // a focusable row in a column. `link`, when present, is the row's `a:` 197 // navigation kind (the window-navigation rows); ref names pass null. 198 fn addRow(allocator: std.mem.Allocator, col: *wgt.Box(ui.Widget), label: []const u8, link: ?[]const u8) !void { 199 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 200 errdefer row.deinit(allocator); 201 row.getFocus().focusable = true; 202 if (link) |l| row.getFocus().kind = .{ .custom = l }; 203 try col.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 204 } 205 206 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 207 self.box.deinit(allocator); 208 } 209 210 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 211 self.clearGrid(); 212 213 // the selected row (in the active column) shows a single border; the 214 // focused TextBox upgrades it to a double border itself. 215 const active_col_id = self.box.getFocus().child_id; 216 for (self.box.children.values()) |*child| { 217 const column = &child.widget.box; 218 const col = &columnScroll(column).child.box; 219 const active = column.getFocus().id == active_col_id; 220 for (col.children.keys(), col.children.values()) |id, *row| { 221 switch (row.widget) { 222 .text_box => |*tb| tb.options.border_style = if (active and col.getFocus().child_id == id) .single else .hidden, 223 else => {}, 224 } 225 } 226 } 227 228 // split the available width evenly between the two columns when it's 229 // known; otherwise let them size to their content. the scroll's own 230 // min width stretches it (and its rows/scroll bar) across the column 231 // rather than only the widest row; the Scroll passes that min down to 232 // its rows. (index 1 in each column box is the scroll; 0 is the label.) 233 if (constraint.max_size.width) |w| { 234 const half = w / 2; 235 for (self.box.children.values()) |*child| { 236 child.min_size = .{ .width = half, .height = null }; 237 child.max_size = .{ .width = half, .height = null }; 238 child.widget.box.children.values()[1].min_size = .{ .width = half, .height = null }; 239 } 240 } else { 241 for (self.box.children.values()) |*child| { 242 child.min_size = null; 243 child.max_size = null; 244 child.widget.box.children.values()[1].min_size = null; 245 } 246 } 247 248 // clear the incoming min height so each column sizes to its content and 249 // its Scroll clips to the viewport rather than stretching to fill. 250 try self.box.build(allocator, .{ 251 .min_size = .{ .width = null, .height = null }, 252 .max_size = constraint.max_size, 253 }, root_focus); 254 } 255 256 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 257 _ = allocator; 258 // up/down (and the scroll wheel) move within the active column a row; 259 // page up/down and home/end jump; left/right switch columns, keeping the 260 // same row offset. the parent (Repo) intercepts up at the top row to move 261 // focus to the header. 262 switch (key) { 263 .arrow_up => try self.moveRow(root_focus, -1), 264 .arrow_down => try self.moveRow(root_focus, 1), 265 .arrow_left => try self.switchColumn(root_focus, left_col), 266 .arrow_right => try self.switchColumn(root_focus, right_col), 267 .page_down => try self.moveRow(root_focus, 10), 268 .page_up => try self.moveRow(root_focus, -10), 269 .end => try self.moveRow(root_focus, self.columnRowCount()), 270 .home => try self.moveRow(root_focus, -self.columnRowCount()), 271 .mouse => |mouse| switch (mouse.action) { 272 .scroll => |dir| try self.moveRow(root_focus, if (dir == .up) -1 else 1), 273 else => {}, 274 }, 275 else => {}, 276 } 277 } 278 279 // the active column's row count, used as a clamp-to-end delta for home/end. 280 fn columnRowCount(self: *View) isize { 281 const col = self.activeColumn() orelse return 0; 282 return @intCast(col.children.count()); 283 } 284 285 fn activeScroll(self: *View) ?*wgt.Scroll(ui.Widget) { 286 const id = self.box.getFocus().child_id orelse return null; 287 const index = self.box.children.getIndex(id) orelse return null; 288 return columnScroll(&self.box.children.values()[index].widget.box); 289 } 290 291 fn activeColumn(self: *View) ?*wgt.Box(ui.Widget) { 292 return &(self.activeScroll() orelse return null).child.box; 293 } 294 295 fn moveRow(self: *View, root_focus: *Focus, delta: isize) !void { 296 const scroll = self.activeScroll() orelse return; 297 const col = &scroll.child.box; 298 const keys = col.children.keys(); 299 if (keys.len == 0) return; 300 const cur_id = col.getFocus().child_id orelse return; 301 const cur: isize = @intCast(col.children.getIndex(cur_id) orelse return); 302 // clamp a delta past either end to the first/last row. 303 const last: isize = @intCast(keys.len - 1); 304 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 305 if (next == @as(usize, @intCast(cur))) return; 306 try root_focus.setFocus(keys[next]); 307 if (col.children.values()[next].rect) |rect| scroll.scrollToRect(rect); 308 } 309 310 fn switchColumn(self: *View, root_focus: *Focus, target: usize) !void { 311 if (target >= self.box.children.count()) return; 312 const cur_col = self.activeColumn() orelse return; 313 const target_scroll = columnScroll(&self.box.children.values()[target].widget.box); 314 const target_col = &target_scroll.child.box; 315 // an empty column has nothing to select; ignore. 316 if (target_col.children.count() == 0) return; 317 // keep the same row offset, clamped to the target column's last row. 318 const cur_id = cur_col.getFocus().child_id orelse return; 319 const cur = cur_col.children.getIndex(cur_id) orelse return; 320 const last = target_col.children.count() - 1; 321 const next = @min(cur, last); 322 try root_focus.setFocus(target_col.children.keys()[next]); 323 if (target_col.children.values()[next].rect) |rect| target_scroll.scrollToRect(rect); 324 } 325 326 pub fn clearGrid(self: *View) void { 327 self.box.clearGrid(); 328 } 329 330 pub fn getGrid(self: View) ?Grid { 331 return self.box.getGrid(); 332 } 333 334 pub fn getFocus(self: *View) *Focus { 335 return self.box.getFocus(); 336 } 337 338 // the selected row's offset within the active column (0 = first row), so the 339 // parent knows when up should escape to the header. 340 pub fn getSelectedIndex(self: View) ?usize { 341 var copy = self; 342 const col = copy.activeColumn() orelse return null; 343 const cur_id = col.getFocus().child_id orelse return null; 344 return col.children.getIndex(cur_id); 345 } 346 }; 347 348 // the "a:" link to `kind`'s column paginated to `after` within `identity` 349 // ("owner/name"). the other column resets to its first window. 350 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, after: usize) ![]const u8 { 351 const route = ui.RoutablePage.repoRefsRoute(identity, kind, after) orelse return error.RouteTooLong; 352 const url = try route.urlAlloc(page_arena); 353 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 354 } 355