╭ 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 Key = xitui.input.Key; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 const inp = @import("../input.zig"); 13 14 // how many refs one window of a column shows. 15 pub const page_size = 20; 16 17 // "owner/name", needed to build the columns' window-navigation links. 18 identity: []const u8, 19 // only one column paginates at a time: `after` is the window start of `kind`'s 20 // column; the other column always starts at 0. 21 kind: ui.RoutablePage.RefKind, 22 after: usize, 23 // the current window of each column, plus the next window's start (null at the 24 // end) so the view can decide whether to show a "next →" row. 25 branches: []const []const u8, 26 tags: []const []const u8, 27 branches_next_after: ?usize, 28 tags_next_after: ?usize, 29 // the columns' headers, in the half-height SubTitle font. 30 branches_label: ui.SubTitle, 31 tags_label: ui.SubTitle, 32 33 const Self = @This(); 34 35 const Window = struct { names: []const []const u8, next_after: ?usize }; 36 37 // empty columns, for the wasm / no-repo paths. 38 pub fn emptyResult(arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, after: usize) !Self { 39 return .{ 40 .identity = try arena.allocator().dupe(u8, identity), 41 .kind = kind, 42 .after = after, 43 .branches = &.{}, 44 .tags = &.{}, 45 .branches_next_after = null, 46 .tags_next_after = null, 47 .branches_label = try ui.SubTitle.init(arena, "branches"), 48 .tags_label = try ui.SubTitle.init(arena, "tags"), 49 }; 50 } 51 52 // read one window of each column from an opened repo. the ref names are duped 53 // into the page arena so they outlive the repo handle. 54 pub fn init( 55 comptime repo_kind: rp.RepoKind, 56 comptime repo_opts: rp.RepoOpts(repo_kind), 57 arena: *std.heap.ArenaAllocator, 58 repo: *rp.Repo(repo_kind, repo_opts), 59 io: std.Io, 60 gpa: std.mem.Allocator, 61 identity: []const u8, 62 kind: ui.RoutablePage.RefKind, 63 after: usize, 64 ) !Self { 65 const aa = arena.allocator(); 66 // the offset only applies to its own column; the other stays at 0. 67 const branches_after: usize = if (kind == .branch) after else 0; 68 const tags_after: usize = if (kind == .tag) after else 0; 69 var result = try emptyResult(arena, identity, kind, after); 70 71 // xit seeks each iterator straight to its window; the git backend can't 72 // seek, so it starts at the beginning and collectWindow discards the 73 // entries before the window. 74 const can_seek = repo_kind == .xit; 75 76 var branch_iter = repo.listBranches(io, gpa, if (can_seek) .{ .index = branches_after } else .beginning) catch return result; 77 defer branch_iter.deinit(io); 78 const branches = try collectWindow(io, aa, &branch_iter, branches_after, if (can_seek) 0 else branches_after); 79 80 var tag_iter = repo.listTags(io, gpa, if (can_seek) .{ .index = tags_after } else .beginning) catch return result; 81 defer tag_iter.deinit(io); 82 const tags = try collectWindow(io, aa, &tag_iter, tags_after, if (can_seek) 0 else tags_after); 83 84 result.branches = branches.names; 85 result.tags = tags.names; 86 result.branches_next_after = branches.next_after; 87 result.tags_next_after = tags.next_after; 88 return result; 89 } 90 91 // window a ref iterator without materializing the whole list. `skip` entries 92 // are discarded first, for backends whose iterators can't seek. 93 fn collectWindow(io: std.Io, aa: std.mem.Allocator, iter: anytype, after: usize, skip: usize) !Window { 94 for (0..skip) |_| { 95 if (try iter.next(io) == null) break; 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, &data.branches_label, data.branches, branches_after, data.branches_next_after); 126 try addColumn(allocator, &box, session, data.identity, .tag, &data.tags_label, 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 ui.SubTitle, 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 in the SubTitle font, nudged off the 154 // left edge by a one-column space. it stays visible while the rows 155 // scroll below, and declares its height (2 rows) as a min so the (fill) 156 // scroll reserves room for it rather than consuming the whole column. 157 { 158 var header_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 159 errdefer header_box.deinit(allocator); 160 { 161 var space = try wgt.Text(ui.Widget).init(allocator, " "); 162 errdefer space.deinit(allocator); 163 try header_box.children.put(allocator, space.getFocus().id, .{ .widget = .{ .text = space }, .rect = null, .min_size = null }); 164 } 165 { 166 var header = try ui.SubTitle.View.init(allocator, label); 167 errdefer header.deinit(allocator); 168 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); 169 } 170 try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 2 } }); 171 } 172 173 // a long ref list overflows the page height; the rows scroll on their 174 // own (the label above stays put) so they lay out under a fixed height. 175 { 176 var scroll = blk: { 177 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 178 errdefer rows.deinit(allocator); 179 180 // window-navigation rows bracket the names: "← previous" off the 181 // first window, "next →" when more remain. each is a full reload. 182 if (after > 0) try addRow(allocator, &rows, "← previous", try windowLink(session.page_arena, identity, kind, after -| page_size)); 183 // each ref name links to the files tab at that ref's root. 184 for (names) |name| try addRow(allocator, &rows, name, try refLink(session.page_arena, identity, kind, name)); 185 if (next_after) |na| try addRow(allocator, &rows, "next →", try windowLink(session.page_arena, identity, kind, na)); 186 187 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 188 189 // fill the column (rows top-aligned, scroll bar pinned to the 190 // edge) rather than shrinking to the widest row. 191 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 192 }; 193 errdefer scroll.deinit(allocator); 194 195 // the column's focus path skips the label and points at the scroll. 196 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 197 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 198 } 199 200 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 201 } 202 203 // [0] = the fixed label, [1] = the scrollable rows. 204 fn columnScroll(column: *wgt.Box(ui.Widget)) *wgt.Scroll(ui.Widget) { 205 return &column.children.values()[1].widget.scroll; 206 } 207 208 // a focusable row in a column. `link`, when present, is the row's `a:` 209 // navigation kind (the window-navigation rows); ref names pass null. 210 fn addRow(allocator: std.mem.Allocator, col: *wgt.Box(ui.Widget), label: []const u8, link: ?[]const u8) !void { 211 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 212 errdefer row.deinit(allocator); 213 row.getFocus().focusable = true; 214 if (link) |l| row.getFocus().kind = .{ .custom = l }; 215 try col.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 216 } 217 218 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 219 self.box.deinit(allocator); 220 } 221 222 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 223 self.clearGrid(); 224 225 // the selected row (in the active column) shows a single border; the 226 // focused TextBox upgrades it to a double border itself. 227 const active_col_id = self.box.getFocus().child_id; 228 for (self.box.children.values()) |*child| { 229 const column = &child.widget.box; 230 const col = &columnScroll(column).child.box; 231 const active = column.getFocus().id == active_col_id; 232 for (col.children.keys(), col.children.values()) |id, *row| { 233 switch (row.widget) { 234 .text_box => |*tb| tb.options.border_style = if (active and col.getFocus().child_id == id) .single else .hidden, 235 else => {}, 236 } 237 } 238 } 239 240 // split the available width evenly between the two columns when it's 241 // known; otherwise let them size to their content. each column's Scroll 242 // has `fill = true`, so it stretches across the column (rows/scroll bar) 243 // rather than hugging the widest row. 244 if (constraint.max_size.width) |w| { 245 const half = w / 2; 246 for (self.box.children.values()) |*child| { 247 child.min_size = .{ .width = half, .height = null }; 248 child.max_size = .{ .width = half, .height = null }; 249 } 250 } else { 251 for (self.box.children.values()) |*child| { 252 child.min_size = null; 253 child.max_size = null; 254 } 255 } 256 257 // clear the incoming min height; each column's Scroll fills the viewport 258 // height itself (via `fill`), keeping its bar pinned to the edge. 259 try self.box.build(allocator, .{ 260 .min_size = .{ .width = null, .height = null }, 261 .max_size = constraint.max_size, 262 }, root_focus); 263 } 264 265 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 266 _ = allocator; 267 // up/down (and the scroll wheel) move within the active column a row; 268 // page up/down and home/end jump; left/right switch columns, keeping the 269 // same row offset. the parent (Repo) intercepts up at the top row to move 270 // focus to the header. 271 if (inp.rowDelta(key, self.columnRowCount())) |delta| { 272 const scroll = self.activeScroll() orelse return; 273 ui.moveRowFocus(&scroll.child.box, scroll, root_focus, delta); 274 return; 275 } 276 switch (key) { 277 .arrow_left => try self.switchColumn(root_focus, left_col), 278 .arrow_right => try self.switchColumn(root_focus, right_col), 279 else => {}, 280 } 281 } 282 283 // the active column's row count, used as a clamp-to-end delta for home/end. 284 fn columnRowCount(self: *View) isize { 285 const col = self.activeColumn() orelse return 0; 286 return @intCast(col.children.count()); 287 } 288 289 fn activeScroll(self: *View) ?*wgt.Scroll(ui.Widget) { 290 const id = self.box.getFocus().child_id orelse return null; 291 const index = self.box.children.getIndex(id) orelse return null; 292 return columnScroll(&self.box.children.values()[index].widget.box); 293 } 294 295 fn activeColumn(self: *View) ?*wgt.Box(ui.Widget) { 296 return &(self.activeScroll() orelse return null).child.box; 297 } 298 299 fn switchColumn(self: *View, root_focus: *Focus, target: usize) !void { 300 if (target >= self.box.children.count()) return; 301 const cur_col = self.activeColumn() orelse return; 302 const target_scroll = columnScroll(&self.box.children.values()[target].widget.box); 303 const target_col = &target_scroll.child.box; 304 // an empty column has nothing to select; ignore. 305 if (target_col.children.count() == 0) return; 306 // keep the same row offset, clamped to the target column's last row. 307 const cur_id = cur_col.getFocus().child_id orelse return; 308 const cur = cur_col.children.getIndex(cur_id) orelse return; 309 const last = target_col.children.count() - 1; 310 const next = @min(cur, last); 311 root_focus.setFocus(target_col.children.keys()[next]); 312 if (target_col.children.values()[next].rect) |rect| target_scroll.scrollToRect(rect); 313 } 314 315 pub fn clearGrid(self: *View) void { 316 self.box.clearGrid(); 317 } 318 319 pub fn getGrid(self: View) ?Grid { 320 return self.box.getGrid(); 321 } 322 323 pub fn getFocus(self: *View) *Focus { 324 return self.box.getFocus(); 325 } 326 327 // the selected row's offset within the active column (0 = first row), so the 328 // parent knows when up should escape to the header. 329 pub fn getSelectedIndex(self: View) ?usize { 330 var copy = self; 331 const col = copy.activeColumn() orelse return null; 332 const cur_id = col.getFocus().child_id orelse return null; 333 return col.children.getIndex(cur_id); 334 } 335 }; 336 337 // the "a:" link to `kind`'s column paginated to `after` within `identity` 338 // ("owner/name"). the other column resets to its first window. 339 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, after: usize) ![]const u8 { 340 const route = ui.RoutablePage.repoRefsRoute(identity, kind, after) orelse return error.RouteTooLong; 341 const url = try route.urlAlloc(page_arena); 342 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 343 } 344 345 // the "a:" link to the files tab at ref `name` (a branch or tag) within 346 // `identity` ("owner/name"), at its root directory. the name is percent-encoded 347 // since it can contain a '/'. 348 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 349 const aa = page_arena.allocator(); 350 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 351 .branch => .branch, 352 .tag => .tag, 353 }; 354 const value = try ui.urlEncodeRef(aa, name); 355 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 356 const url = try route.urlAlloc(page_arena); 357 return std.fmt.allocPrint(aa, "a:{s}", .{url}); 358 } 359