╭ 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 = 50; 16 17 // "owner/name", needed to build the columns' window-navigation links. 18 identity: []const u8, 19 // only one column windows at a time: `from` (a url-encoded ref name, "" = the 20 // first window) roots `kind`'s column; the other always shows its first window. 21 kind: ui.RoutablePage.RefKind, 22 from: []const u8, 23 branches: Column, 24 tags: Column, 25 26 const Self = @This(); 27 28 // one ref column: the current window of names, the raw ref names its 29 // "← previous" / "next →" rows window from (null = no row; a "" prev means 30 // the first window), and the header label. 31 pub const Column = struct { 32 names: []const []const u8 = &.{}, 33 prev: ?[]const u8 = null, 34 next: ?[]const u8 = null, 35 label: ui.SubTitle, 36 }; 37 38 // empty columns, for the wasm / no-repo paths. 39 pub fn emptyResult(arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, from: []const u8) !Self { 40 return .{ 41 .identity = try arena.allocator().dupe(u8, identity), 42 .kind = kind, 43 .from = try arena.allocator().dupe(u8, from), 44 .branches = .{ .label = try ui.SubTitle.init(arena, "branches") }, 45 .tags = .{ .label = try ui.SubTitle.init(arena, "tags") }, 46 }; 47 } 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 columns, each a fixed label above a Scroll of a 127 // TagFlow, so the names wrap across the column and scroll beneath the 128 // label. focus points at the active column and, in its flow, the selected 129 // name. 130 box: wgt.Box(ui.Widget), 131 session: *ui.Session, 132 133 const left_col = 0; 134 const right_col = 1; 135 136 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 137 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 138 errdefer box.deinit(allocator); 139 140 try addColumn(allocator, &box, session, data.identity, .branch, &data.branches); 141 try addColumn(allocator, &box, session, data.identity, .tag, &data.tags); 142 143 // select the first name of the first column that has one 144 for (box.children.keys(), box.children.values()) |id, *child| { 145 if (child.widget.box.getFocus().child_id != null) { 146 box.getFocus().child_id = id; 147 break; 148 } 149 } 150 return .{ .box = box, .session = session }; 151 } 152 153 fn addColumn( 154 allocator: std.mem.Allocator, 155 box: *wgt.Box(ui.Widget), 156 session: *ui.Session, 157 identity: []const u8, 158 kind: ui.RoutablePage.RefKind, 159 data: *const Column, 160 ) !void { 161 var column = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 162 errdefer column.deinit(allocator); 163 164 // a fixed, non-focusable header in the SubTitle font, nudged off the 165 // left edge by a one-column space. it declares its height (2 rows plus 166 // a blank one beneath) as a min so the (fill) scroll reserves room for it. 167 { 168 var header_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 169 errdefer header_box.deinit(allocator); 170 { 171 var space = try wgt.Text.init(allocator, " "); 172 errdefer space.deinit(allocator); 173 try header_box.children.put(allocator, space.getFocus().id, .{ .widget = .{ .text = space }, .rect = null, .min_size = null }); 174 } 175 { 176 var header = try ui.SubTitle.View.init(allocator, &data.label); 177 errdefer header.deinit(allocator); 178 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); 179 } 180 try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 181 } 182 183 // each name links to the files tab at that ref's root. window 184 // navigation brackets them: "← previous" off the first window, "next →" 185 // when more remain, each a full reload. 186 { 187 var items: std.ArrayList(ui.widget.TagFlow.Item) = .empty; 188 defer items.deinit(allocator); 189 if (data.prev) |p| try items.append(allocator, .{ .text = "← previous", .link = try windowLink(session.page_arena, identity, kind, p) }); 190 for (data.names) |name| try items.append(allocator, .{ .text = name, .link = try refLink(session.page_arena, identity, kind, name) }); 191 if (data.next) |n| try items.append(allocator, .{ .text = "next →", .link = try windowLink(session.page_arena, identity, kind, n) }); 192 193 var scroll = blk: { 194 var flow = try ui.widget.TagFlow.init(allocator); 195 errdefer flow.deinit(allocator); 196 try flow.setItems(allocator, items.items); 197 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .tag_flow = flow }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 198 }; 199 errdefer scroll.deinit(allocator); 200 201 // the column's focus path skips the label and points at the flow 202 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 203 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 204 } 205 206 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 207 } 208 209 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 210 self.box.deinit(allocator); 211 } 212 213 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 214 self.clearGrid(); 215 216 // split the available width evenly between the two columns when it's 217 // known; otherwise let them size to their content. 218 if (constraint.max_size.width) |w| { 219 const half = w / 2; 220 for (self.box.children.values()) |*child| { 221 child.min_size = .{ .width = half, .height = null }; 222 child.max_size = .{ .width = half, .height = null }; 223 } 224 } else { 225 for (self.box.children.values()) |*child| { 226 child.min_size = null; 227 child.max_size = null; 228 } 229 } 230 231 // clear the incoming min height; each column's Scroll fills the viewport 232 // height itself (via `fill`), keeping its bar pinned to the edge. 233 try self.box.build(allocator, .{ 234 .min_size = .{ .width = null, .height = null }, 235 .max_size = constraint.max_size, 236 }, root_focus); 237 } 238 239 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 240 _ = allocator; 241 // arrows and the scroll wheel move the selection within the active 242 // flow. left from a row's first name and right from its last cross to 243 // the other column's same row. the parent (Repo) takes up from the first 244 // row to the header. 245 const index = self.activeIndex() orelse return; 246 const flow = self.flowAt(index); 247 const cur = flow.indexOfFocusId(flow.focus.child_id orelse return) orelse return; 248 // a flow last laid out at zero width has names without positions 249 if (cur >= flow.rects.items.len) return; 250 const count = flow.text_boxes.items.len; 251 const cur_y = flow.rects.items[cur].y; 252 switch (inp.vertDirection(key)) { 253 .up => if (flow.rowStep(cur, false)) |i| return self.focusName(index, i, root_focus), 254 .down => if (flow.rowStep(cur, true)) |i| return self.focusName(index, i, root_focus), 255 .none => {}, 256 } 257 switch (key) { 258 .arrow_left => if (index == right_col and rowStart(flow, cur)) { 259 self.crossTo(left_col, cur_y, root_focus); 260 } else if (cur > 0) self.focusName(index, cur - 1, root_focus), 261 .arrow_right => if (index == left_col and rowEnd(flow, cur)) { 262 self.crossTo(right_col, cur_y, root_focus); 263 } else if (cur + 1 < count) self.focusName(index, cur + 1, root_focus), 264 .home => self.focusName(index, 0, root_focus), 265 .end => self.focusName(index, count - 1, root_focus), 266 else => {}, 267 } 268 } 269 270 fn activeIndex(self: *View) ?usize { 271 const id = self.box.getFocus().child_id orelse return null; 272 return self.box.children.getIndex(id); 273 } 274 275 // [0] = the fixed label, [1] = the scrolling flow 276 fn scrollAt(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 277 return &self.box.children.values()[index].widget.box.children.values()[1].widget.scroll; 278 } 279 280 fn flowAt(self: *View, index: usize) *ui.widget.TagFlow { 281 return &self.scrollAt(index).child.tag_flow; 282 } 283 284 fn rowStart(flow: *ui.widget.TagFlow, item: usize) bool { 285 const rects = flow.rects.items; 286 return item == 0 or rects[item - 1].y != rects[item].y; 287 } 288 289 fn rowEnd(flow: *ui.widget.TagFlow, item: usize) bool { 290 const rects = flow.rects.items; 291 return item + 1 == rects.len or rects[item + 1].y != rects[item].y; 292 } 293 294 // land on the other column's row at or above the current one: its first 295 // name when moving right, its last when moving left. names are laid out in 296 // order, so that row ends at the last name no lower than the current one. 297 fn crossTo(self: *View, target: usize, from_y: isize, root_focus: *Focus) void { 298 // the other column may have no names at all 299 const rects = self.flowAt(target).rects.items; 300 if (rects.len == 0) return; 301 var last: usize = 0; 302 for (rects, 0..) |rect, i| { 303 if (rect.y <= from_y) last = i; 304 } 305 var first = last; 306 while (first > 0 and rects[first - 1].y == rects[last].y) first -= 1; 307 self.focusName(target, if (target == right_col) first else last, root_focus); 308 } 309 310 fn focusName(self: *View, index: usize, item: usize, root_focus: *Focus) void { 311 const flow = self.flowAt(index); 312 root_focus.setFocus(flow.text_boxes.items[item].getFocus().id); 313 // the browser scrolls natively; the terminal brings the name into view 314 if (self.session.is_terminal and item < flow.rects.items.len) self.scrollAt(index).scrollToRect(flow.rects.items[item]); 315 } 316 317 pub fn clearGrid(self: *View) void { 318 self.box.clearGrid(); 319 } 320 321 pub fn getGrid(self: View) ?Grid { 322 return self.box.getGrid(); 323 } 324 325 pub fn getFocus(self: *View) *Focus { 326 return self.box.getFocus(); 327 } 328 329 // on the active flow's first row, where up leaves the tab 330 pub fn atTop(self: *View) bool { 331 const index = self.activeIndex() orelse return false; 332 const flow = self.flowAt(index); 333 const cur = flow.indexOfFocusId(flow.focus.child_id orelse return false) orelse return false; 334 return flow.rowStep(cur, false) == null; 335 } 336 }; 337 338 // the "a:" link to `kind`'s column windowed from ref `name` (raw; "" = the 339 // first window) within `identity` ("owner/name"). the other column resets to 340 // its first window. 341 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 342 const encoded = try ui.urlEncodeRef(page_arena.allocator(), name); 343 const route = ui.RoutablePage.repoRefsRoute(identity, kind, encoded) orelse return error.RouteTooLong; 344 const url = try route.toUrl(page_arena); 345 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 346 } 347 348 // the "a:" link to the files tab at ref `name` (a branch or tag) within 349 // `identity` ("owner/name"), at its root directory. the name is percent-encoded 350 // since it can contain a '/'. 351 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 352 const aa = page_arena.allocator(); 353 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 354 .branch => .branch, 355 .tag => .tag, 356 }; 357 const value = try ui.urlEncodeRef(aa, name); 358 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 359 const url = try route.toUrl(page_arena); 360 return std.fmt.allocPrint(aa, "a:{s}", .{url}); 361 } 362