╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Refs.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
48 49 // read one window of each column from an opened repo. the ref names are duped 50 // into the page arena so they outlive the repo handle. 51 pub fn init( 52 comptime repo_kind: rp.RepoKind, 53 comptime repo_opts: rp.RepoOpts(repo_kind), 54 arena: *std.heap.ArenaAllocator, 55 repo: *rp.Repo(repo_kind, repo_opts), 56 io: std.Io, 57 gpa: std.mem.Allocator, 58 identity: []const u8, 59 kind: ui.RoutablePage.RefKind, 60 from: []const u8, 61 ) !Self { 62 const aa = arena.allocator(); 63 var result = try emptyResult(arena, identity, kind, from); 64 // the window root only applies to its own column; a ref name arrives 65 // url-encoded, and the iterator seeks to the first name at or after it. 66 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, from)); 67 for ( 68 [_]ui.RoutablePage.RefKind{ .branch, .tag }, 69 [_]*Column{ &result.branches, &result.tags }, 70 ) |col_kind, column| { 71 const col_from: []const u8 = if (kind == col_kind) decoded else ""; 72 // an unreadable listing leaves the column empty 73 var iter = listRefs(repo_kind, repo_opts, repo, io, gpa, col_kind, iterStart(col_from)) catch continue; 74 defer iter.deinit(); 75 var names = try std.ArrayListUnmanaged([]const u8).initCapacity(aa, page_size); 76 while (names.items.len < page_size) { 77 const ref = try iter.next() orelse break; 78 names.appendAssumeCapacity(try aa.dupe(u8, ref.name)); 79 } 80 column.names = names.items; 81 column.next = if (try iter.next()) |ref| try aa.dupe(u8, ref.name) else null; 82 if (col_from.len != 0) { 83 var prev_iter = listRefs(repo_kind, repo_opts, repo, io, gpa, col_kind, .beginning) catch continue; 84 defer prev_iter.deinit(); 85 column.prev = try prevRoot(aa, &prev_iter, col_from); 86 } 87 } 88 return result; 89 } 90 91 fn listRefs( 92 comptime repo_kind: rp.RepoKind, 93 comptime repo_opts: rp.RepoOpts(repo_kind), 94 repo: *rp.Repo(repo_kind, repo_opts), 95 io: std.Io, 96 gpa: std.mem.Allocator, 97 kind: ui.RoutablePage.RefKind, 98 start: xit.ref.RefIteratorStart, 99 ) !xit.ref.RefIterator(repo_kind, repo_opts) { 100 return switch (kind) { 101 .branch => repo.listBranches(io, gpa, start), 102 .tag => repo.listTags(io, gpa, start), 103 }; 104 } 105 106 fn iterStart(from: []const u8) xit.ref.RefIteratorStart { 107 return if (from.len == 0) .beginning else .{ .key = from }; 108 } 109 110 // the root of the window before the one starting at `from` (decoded): the ref 111 // a page before it ("" = the first window), or null when nothing precedes it. 112 fn prevRoot(aa: std.mem.Allocator, iter: anytype, from: []const u8) !?[]const u8 { 113 var ring: [page_size][]const u8 = undefined; 114 var count: usize = 0; 115 while (try iter.next()) |ref| { 116 if (!std.mem.lessThan(u8, ref.name, from)) break; 117 ring[count % page_size] = try aa.dupe(u8, ref.name); 118 count += 1; 119 } 120 if (count == 0) return null; 121 if (count <= page_size) return ""; 122 return ring[count % page_size]; 123 } 124 125 pub const View = struct { 126 // a horizontal box of two vertical columns. each column is a fixed, 127 // centered, non-focusable label above a Scroll of focusable rows, so the 128 // label stays visible while the rows scroll. focus tracks a single selected 129 // row across both columns: the box's focus path points at the active column, 130 // that column's at its scroll, and the scroll's (its rows') at the row. 131 box: wgt.Box(ui.Widget), 132 data: *const Self, 133 134 const left_col = 0; 135 const right_col = 1; 136 137 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 138 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 139 errdefer box.deinit(allocator); 140 141 try addColumn(allocator, &box, session, data.identity, .branch, &data.branches); 142 try addColumn(allocator, &box, session, data.identity, .tag, &data.tags); 143 144 var self = View{ .box = box, .data = data }; 145 // select the first row of the first column that has one. 146 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 147 if (child.widget.box.getFocus().child_id != null) { 148 self.box.getFocus().child_id = id; 149 break; 150 } 151 } 152 return self; 153 } 154 155 fn addColumn( 156 allocator: std.mem.Allocator, 157 box: *wgt.Box(ui.Widget), 158 session: *ui.Session, 159 identity: []const u8, 160 kind: ui.RoutablePage.RefKind, 161 data: *const Column, 162 ) !void { 163 var column = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 164 errdefer column.deinit(allocator); 165 166 // a fixed, non-focusable header in the SubTitle font, nudged off the 167 // left edge by a one-column space. it stays visible while the rows 168 // scroll below, and declares its height (2 rows) as a min so the (fill) 169 // scroll reserves room for it rather than consuming the whole column. 170 { 171 var header_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 172 errdefer header_box.deinit(allocator); 173 { 174 var space = try wgt.Text.init(allocator, " "); 175 errdefer space.deinit(allocator); 176 try header_box.children.put(allocator, space.getFocus().id, .{ .widget = .{ .text = space }, .rect = null, .min_size = null }); 177 } 178 { 179 var header = try ui.SubTitle.View.init(allocator, &data.label); 180 errdefer header.deinit(allocator); 181 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); 182 } 183 try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 2 } }); 184 } 185 186 // a long ref list overflows the page height; the rows scroll on their 187 // own (the label above stays put) so they lay out under a fixed height. 188 { 189 var scroll = blk: { 190 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 191 errdefer rows.deinit(allocator); 192 193 // window-navigation rows bracket the names: "← previous" off the 194 // first window, "next →" when more remain. each is a full reload. 195 if (data.prev) |p| try addRow(allocator, &rows, "← previous", try windowLink(session.page_arena, identity, kind, p)); 196 // each ref name links to the files tab at that ref's root. 197 for (data.names) |name| try addRow(allocator, &rows, name, try refLink(session.page_arena, identity, kind, name)); 198 if (data.next) |n| try addRow(allocator, &rows, "next →", try windowLink(session.page_arena, identity, kind, n)); 199 200 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 201 202 // fill the column (rows top-aligned, scroll bar pinned to the 203 // edge) rather than shrinking to the widest row. 204 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 205 }; 206 errdefer scroll.deinit(allocator); 207 208 // the column's focus path skips the label and points at the scroll. 209 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 210 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 211 } 212 213 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 214 } 215 216 // [0] = the fixed label, [1] = the scrollable rows. 217 fn columnScroll(column: *wgt.Box(ui.Widget)) *wgt.Scroll(ui.Widget) { 218 return &column.children.values()[1].widget.scroll; 219 } 220 221 // a focusable row in a column. `link`, when present, is the row's `a:` 222 // navigation kind (the window-navigation rows); ref names pass null. 223 fn addRow(allocator: std.mem.Allocator, col: *wgt.Box(ui.Widget), label: []const u8, link: ?[]const u8) !void { 224 var row = try wgt.TextBox.init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 225 errdefer row.deinit(allocator); 226 row.getFocus().mode = .all; 227 if (link) |l| row.getFocus().kind = .{ .custom = l }; 228 try col.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 229 } 230 231 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 232 self.box.deinit(allocator); 233 } 234 235 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 236 self.clearGrid(); 237 238 // the selected row (in the active column) shows a single border; the 239 // focused TextBox upgrades it to a double border itself. 240 const active_col_id = self.box.getFocus().child_id; 241 for (self.box.children.values()) |*child| { 242 const column = &child.widget.box; 243 const col = &columnScroll(column).child.box; 244 const active = column.getFocus().id == active_col_id; 245 for (col.children.keys(), col.children.values()) |id, *row| { 246 switch (row.widget) { 247 .text_box => |*tb| tb.options.border_style = if (active and col.getFocus().child_id == id) .single else .hidden, 248 else => {}, 249 } 250 } 251 } 252 253 // split the available width evenly between the two columns when it's 254 // known; otherwise let them size to their content. each column's Scroll 255 // has `fill = true`, so it stretches across the column (rows/scroll bar) 256 // rather than hugging the widest row. 257 if (constraint.max_size.width) |w| { 258 const half = w / 2; 259 for (self.box.children.values()) |*child| { 260 child.min_size = .{ .width = half, .height = null }; 261 child.max_size = .{ .width = half, .height = null }; 262 } 263 } else { 264 for (self.box.children.values()) |*child| { 265 child.min_size = null; 266 child.max_size = null; 267 } 268 } 269 270 // clear the incoming min height; each column's Scroll fills the viewport 271 // height itself (via `fill`), keeping its bar pinned to the edge. 272 try self.box.build(allocator, .{ 273 .min_size = .{ .width = null, .height = null }, 274 .max_size = constraint.max_size, 275 }, root_focus); 276 } 277 278 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 279 _ = allocator; 280 // up/down (and the scroll wheel) move within the active column a row; 281 // page up/down and home/end jump; left/right switch columns, keeping the 282 // same row offset. the parent (Repo) intercepts up at the top row to move 283 // focus to the header. 284 if (inp.rowDelta(key, self.columnRowCount())) |delta| { 285 const scroll = self.activeScroll() orelse return; 286 ui.widget.moveRowFocus(&scroll.child.box, scroll, root_focus, delta); 287 return; 288 } 289 switch (key) { 290 .arrow_left => try self.switchColumn(root_focus, left_col), 291 .arrow_right => try self.switchColumn(root_focus, right_col), 292 else => {}, 293 } 294 } 295 296 // the active column's row count, used as a clamp-to-end delta for home/end. 297 fn columnRowCount(self: *View) isize { 298 const col = self.activeColumn() orelse return 0; 299 return @intCast(col.children.count()); 300 } 301 302 fn activeScroll(self: *View) ?*wgt.Scroll(ui.Widget) { 303 const id = self.box.getFocus().child_id orelse return null; 304 const index = self.box.children.getIndex(id) orelse return null; 305 return columnScroll(&self.box.children.values()[index].widget.box); 306 } 307 308 fn activeColumn(self: *View) ?*wgt.Box(ui.Widget) { 309 return &(self.activeScroll() orelse return null).child.box; 310 } 311 312 fn switchColumn(self: *View, root_focus: *Focus, target: usize) !void { 313 if (target >= self.box.children.count()) return; 314 const cur_col = self.activeColumn() orelse return; 315 const target_scroll = columnScroll(&self.box.children.values()[target].widget.box); 316 const target_col = &target_scroll.child.box; 317 // an empty column has nothing to select; ignore. 318 if (target_col.children.count() == 0) return; 319 // keep the same row offset, clamped to the target column's last row. 320 const cur_id = cur_col.getFocus().child_id orelse return; 321 const cur = cur_col.children.getIndex(cur_id) orelse return; 322 const last = target_col.children.count() - 1; 323 const next = @min(cur, last); 324 root_focus.setFocus(target_col.children.keys()[next]); 325 if (target_col.children.values()[next].rect) |rect| target_scroll.scrollToRect(rect); 326 } 327 328 pub fn clearGrid(self: *View) void { 329 self.box.clearGrid(); 330 } 331 332 pub fn getGrid(self: View) ?Grid { 333 return self.box.getGrid(); 334 } 335 336 pub fn getFocus(self: *View) *Focus { 337 return self.box.getFocus(); 338 } 339 340 pub fn atTop(self: *View) bool { 341 const col = self.activeColumn() orelse return false; 342 const cur_id = col.getFocus().child_id orelse return false; 343 return col.children.getIndex(cur_id) == 0; 344 } 345 }; 346 347 // the "a:" link to `kind`'s column windowed from ref `name` (raw; "" = the 348 // first window) within `identity` ("owner/name"). the other column resets to 349 // its first window. 350 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 351 const encoded = try ui.urlEncodeRef(page_arena.allocator(), name); 352 const route = ui.RoutablePage.repoRefsRoute(identity, kind, encoded) orelse return error.RouteTooLong; 353 const url = try route.toUrl(page_arena); 354 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 355 } 356 357 // the "a:" link to the files tab at ref `name` (a branch or tag) within 358 // `identity` ("owner/name"), at its root directory. the name is percent-encoded 359 // since it can contain a '/'. 360 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 361 const aa = page_arena.allocator(); 362 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 363 .branch => .branch, 364 .tag => .tag, 365 }; 366 const value = try ui.urlEncodeRef(aa, name); 367 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 368 const url = try route.toUrl(page_arena); 369 return std.fmt.allocPrint(aa, "a:{s}", .{url}); 370 } 371