╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Refs.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
21 kind: ui.RoutablePage.RefKind, 22 from: []const u8, 23 // the prefix both columns are narrowed to (decoded; null = no search) 24 search: ?[]const u8, 25 branches: Column, 26 tags: Column, 27 28 const Self = @This(); 29 30 // one ref column: the current window of names, the raw ref names its 31 // "← previous" / "next →" rows window from (null = no row; a "" prev means 32 // the first window), and the header label. 33 pub const Column = struct { 34 names: []const []const u8 = &.{}, 35 prev: ?[]const u8 = null, 36 next: ?[]const u8 = null, 37 label: ui.SubTitle, 38 }; 39 40 // empty columns, for the wasm / no-repo paths. 41 pub fn emptyResult(arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, from: []const u8, search: []const u8) !Self { 42 const aa = arena.allocator(); 43 return .{ 44 .identity = try aa.dupe(u8, identity), 45 .kind = kind, 46 .from = try aa.dupe(u8, from), 47 .search = if (search.len == 0) null else std.Uri.percentDecodeInPlace(try aa.dupe(u8, search)), 48 .branches = .{ .label = try ui.SubTitle.init(arena, "branches") }, 49 .tags = .{ .label = try ui.SubTitle.init(arena, "tags") }, 50 }; 51 } 52 53 // both columns empty with no previous row, which shows the empty message 54 fn isEmpty(self: *const Self) bool { 55 return self.branches.names.len == 0 and self.branches.prev == null and self.tags.names.len == 0 and self.tags.prev == null; 56 } 57 58 // read one window of each column from an opened repo. the ref names are duped 59 // into the page arena so they outlive the repo handle. 60 pub fn init( 61 comptime repo_kind: rp.RepoKind, 62 comptime repo_opts: rp.RepoOpts(repo_kind), 63 arena: *std.heap.ArenaAllocator, 64 repo: *rp.Repo(repo_kind, repo_opts), 65 io: std.Io, 66 gpa: std.mem.Allocator, 67 identity: []const u8, 68 kind: ui.RoutablePage.RefKind, 69 from: []const u8, 70 search: []const u8, 71 ) !Self { 72 const aa = arena.allocator(); 73 var result = try emptyResult(arena, identity, kind, from, search); 74 const prefix = result.search orelse ""; 75 // the window root only applies to its own column; a ref name arrives 76 // url-encoded, and the iterator seeks to the first name at or after it. 77 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, from)); 78 for ( 79 [_]ui.RoutablePage.RefKind{ .branch, .tag }, 80 [_]*Column{ &result.branches, &result.tags }, 81 ) |col_kind, column| { 82 const col_from: []const u8 = if (kind == col_kind) decoded else ""; 83 // an unreadable listing leaves the column empty. the names are sorted, 84 // so those with the prefix are contiguous from the prefix itself. 85 var iter = listRefs(repo_kind, repo_opts, repo, io, gpa, col_kind, iterStart(laterKey(prefix, col_from))) catch continue; 86 defer iter.deinit(); 87 var names = try std.ArrayListUnmanaged([]const u8).initCapacity(aa, page_size); 88 while (try iter.next()) |ref| { 89 if (!std.mem.startsWith(u8, ref.name, prefix)) break; 90 if (names.items.len == page_size) { 91 column.next = try aa.dupe(u8, ref.name); 92 break; 93 } 94 names.appendAssumeCapacity(try aa.dupe(u8, ref.name)); 95 } 96 column.names = names.items; 97 if (col_from.len != 0) { 98 var prev_iter = listRefs(repo_kind, repo_opts, repo, io, gpa, col_kind, iterStart(prefix)) catch continue; 99 defer prev_iter.deinit(); 100 column.prev = try prevRoot(aa, &prev_iter, prefix, col_from); 101 } 102 } 103 return result; 104 } 105 106 fn listRefs( 107 comptime repo_kind: rp.RepoKind, 108 comptime repo_opts: rp.RepoOpts(repo_kind), 109 repo: *rp.Repo(repo_kind, repo_opts), 110 io: std.Io, 111 gpa: std.mem.Allocator, 112 kind: ui.RoutablePage.RefKind, 113 start: xit.ref.RefIteratorStart, 114 ) !xit.ref.RefIterator(repo_kind, repo_opts) { 115 return switch (kind) { 116 .branch => repo.listBranches(io, gpa, start), 117 .tag => repo.listTags(io, gpa, start), 118 }; 119 } 120 121 fn iterStart(from: []const u8) xit.ref.RefIteratorStart { 122 return if (from.len == 0) .beginning else .{ .key = from }; 123 } 124 125 // the later of two ref names, so a window never starts before the prefix 126 fn laterKey(a: []const u8, b: []const u8) []const u8 { 127 return if (std.mem.lessThan(u8, a, b)) b else a; 128 } 129 130 // the root of the window before the one starting at `from` (decoded) among the 131 // names with `prefix`: the ref a page before it ("" = the first window), or null 132 // when nothing precedes it. `iter` starts at the prefix. 133 fn prevRoot(aa: std.mem.Allocator, iter: anytype, prefix: []const u8, from: []const u8) !?[]const u8 { 134 var ring: [page_size][]const u8 = undefined; 135 var count: usize = 0; 136 while (try iter.next()) |ref| { 137 if (!std.mem.startsWith(u8, ref.name, prefix) or !std.mem.lessThan(u8, ref.name, from)) break; 138 ring[count % page_size] = try aa.dupe(u8, ref.name); 139 count += 1; 140 } 141 if (count == 0) return null; 142 if (count <= page_size) return ""; 143 return ring[count % page_size]; 144 } 145 146 pub const View = struct { 147 // a vertical box: the search sub-header above a horizontal box of two 148 // columns. each column is a fixed label above a 149 // Scroll of a WordFlow, so the names wrap across the column and scroll 150 // beneath the label. focus points at the header or the active column and, 151 // in its flow, the selected name. 152 box: wgt.Box(ui.Widget), 153 data: *const Self, 154 session: *ui.Session, 155 156 const header_index = 0; 157 const columns_index = 1; 158 const left_col = 0; 159 const right_col = 1; 160 161 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 162 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 163 errdefer outer.deinit(allocator); 164 165 // both backends list refs in sorted order, so a prefix is just a seek 166 { 167 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 168 errdefer header.deinit(allocator); 169 { 170 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.search); 171 errdefer search_box.deinit(allocator); 172 header.getFocus().child_id = search_box.getFocus().id; 173 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 174 } 175 // the spacer keeps the box at its own width 176 { 177 var spacer = try ui.widget.Spacer.init(allocator); 178 errdefer spacer.deinit(allocator); 179 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 180 } 181 // a row taller than the box, leaving a blank line above the columns 182 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 183 } 184 185 // with both columns empty, the empty message stands in for them 186 if (data.isEmpty()) { 187 var center = try ui.widget.initEmpty(allocator); 188 errdefer center.deinit(allocator); 189 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 190 } else { 191 var split = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 192 errdefer split.deinit(allocator); 193 194 const search = data.search orelse ""; 195 try addColumn(allocator, &split, session, data.identity, search, .branch, &data.branches); 196 try addColumn(allocator, &split, session, data.identity, search, .tag, &data.tags); 197 198 // select the first name of the first column that has one 199 for (split.children.keys(), split.children.values()) |id, *child| { 200 if (child.widget.box.getFocus().child_id != null) { 201 split.getFocus().child_id = id; 202 break; 203 } 204 } 205 try outer.children.put(allocator, split.getFocus().id, .{ .widget = .{ .box = split }, .rect = null, .min_size = null }); 206 } 207 208 // search results start in the box so the term can be refined right away 209 outer.getFocus().child_id = outer.children.keys()[if (data.search != null) header_index else columns_index]; 210 return .{ .box = outer, .data = data, .session = session }; 211 } 212 213 fn addColumn( 214 allocator: std.mem.Allocator, 215 box: *wgt.Box(ui.Widget), 216 session: *ui.Session, 217 identity: []const u8, 218 search: []const u8, 219 kind: ui.RoutablePage.RefKind, 220 data: *const Column, 221 ) !void { 222 var column = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 223 errdefer column.deinit(allocator); 224 225 // a fixed, non-focusable header in the SubTitle font, nudged off the 226 // left edge by a one-column space. it declares its height (2 rows plus 227 // a blank one beneath) as a min so the (fill) scroll reserves room for it. 228 { 229 var header_box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 230 errdefer header_box.deinit(allocator); 231 { 232 var space = try wgt.Text.init(allocator, " "); 233 errdefer space.deinit(allocator); 234 try header_box.children.put(allocator, space.getFocus().id, .{ .widget = .{ .text = space }, .rect = null, .min_size = null }); 235 } 236 { 237 var header = try ui.SubTitle.View.init(allocator, &data.label); 238 errdefer header.deinit(allocator); 239 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); 240 } 241 try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 242 } 243 244 // each name links to the files tab at that ref's root. window 245 // navigation brackets them: "← previous" off the first window, "next →" 246 // when more remain, each a full reload. 247 { 248 var items: std.ArrayList(ui.widget.WordFlow.Item) = .empty; 249 defer items.deinit(allocator); 250 if (data.prev) |p| try items.append(allocator, .{ .text = "← previous", .link = try windowLink(session.page_arena, identity, kind, p, search) }); 251 for (data.names) |name| try items.append(allocator, .{ .text = name, .link = try refLink(session.page_arena, identity, kind, name) }); 252 if (data.next) |n| try items.append(allocator, .{ .text = "next →", .link = try windowLink(session.page_arena, identity, kind, n, search) }); 253 254 var scroll = blk: { 255 var flow = try ui.widget.WordFlow.init(allocator); 256 errdefer flow.deinit(allocator); 257 try flow.setItems(allocator, items.items); 258 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .word_flow = flow }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 259 }; 260 errdefer scroll.deinit(allocator); 261 262 // the column's focus path skips the label and points at the flow 263 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 264 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 265 } 266 267 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 268 } 269 270 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 271 self.box.deinit(allocator); 272 } 273 274 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 275 self.clearGrid(); 276 277 // split the available width evenly between the two columns when it's 278 // known; otherwise let them size to their content. 279 if (!self.data.isEmpty()) { 280 if (constraint.max_size.width) |w| { 281 const half = w / 2; 282 for (self.columns().children.values()) |*child| { 283 child.min_size = .{ .width = half, .height = null }; 284 child.max_size = .{ .width = half, .height = null }; 285 } 286 } else { 287 for (self.columns().children.values()) |*child| { 288 child.min_size = null; 289 child.max_size = null; 290 } 291 } 292 } 293 294 // clear the incoming min height; each column's Scroll fills the viewport 295 // height itself (via `fill`), keeping its bar pinned to the edge. 296 try self.box.build(allocator, .{ 297 .min_size = .{ .width = null, .height = null }, 298 .max_size = constraint.max_size, 299 }, root_focus); 300 } 301 302 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 303 if (self.headerActive()) { 304 const search_box = self.searchBox(); 305 if (inp.vertDirection(key) == .down) { 306 // the empty message has nothing to focus 307 if (!self.data.isEmpty()) root_focus.setFocus(self.columns().getFocus().id); 308 return; 309 } 310 if (key == .enter) { 311 const text = try search_box.text(allocator); 312 defer allocator.free(text); 313 return self.submit(text); 314 } 315 return search_box.input(allocator, key, root_focus); 316 } 317 // arrows and the scroll wheel move the selection within the active 318 // flow. left from a row's first name and right from its last cross to 319 // the other column's same row. up from a first row reaches the search 320 // box. 321 const index = self.activeIndex() orelse return; 322 const flow = self.flowAt(index); 323 const cur = flow.indexOfFocusId(flow.focus.child_id orelse return) orelse return; 324 // a flow last laid out at zero width has names without positions 325 if (cur >= flow.rects.items.len) return; 326 const count = flow.text_boxes.items.len; 327 const cur_y = flow.rects.items[cur].y; 328 switch (inp.vertDirection(key)) { 329 .up => { 330 if (flow.rowStep(cur, false)) |i| self.focusName(index, i, root_focus) else _ = self.focusHeader(root_focus); 331 return; 332 }, 333 .down => { 334 if (flow.rowStep(cur, true)) |i| self.focusName(index, i, root_focus); 335 return; 336 }, 337 .none => {}, 338 } 339 switch (key) { 340 .arrow_left => if (index == right_col and rowStart(flow, cur)) { 341 self.crossTo(left_col, cur_y, root_focus); 342 } else if (cur > 0) self.focusName(index, cur - 1, root_focus), 343 .arrow_right => if (index == left_col and rowEnd(flow, cur)) { 344 self.crossTo(right_col, cur_y, root_focus); 345 } else if (cur + 1 < count) self.focusName(index, cur + 1, root_focus), 346 .home => self.focusName(index, 0, root_focus), 347 .end => self.focusName(index, count - 1, root_focus), 348 else => {}, 349 } 350 } 351 352 // both lists narrowed to `text`, each from its first window. an empty box 353 // on an unsearched page has nothing to clear. 354 fn submit(self: *View, text: []const u8) !void { 355 if (text.len == 0 and self.data.search == null) return; 356 const route = ui.RoutablePage.repoRefsRoute(self.data.identity, .branch, "") orelse return; 357 try self.session.navigate(route.withSearch(text) orelse return); 358 } 359 360 fn searchBox(self: *View) *ui.widget.SearchBox { 361 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 362 } 363 364 fn headerActive(self: *View) bool { 365 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 366 } 367 368 fn columns(self: *View) *wgt.Box(ui.Widget) { 369 return &self.box.children.values()[columns_index].widget.box; 370 } 371 372 fn activeIndex(self: *View) ?usize { 373 const cols = self.columns(); 374 const id = cols.getFocus().child_id orelse return null; 375 return cols.children.getIndex(id); 376 } 377 378 // [0] = the fixed label, [1] = the scrolling flow 379 fn scrollAt(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 380 return &self.columns().children.values()[index].widget.box.children.values()[1].widget.scroll; 381 } 382 383 fn flowAt(self: *View, index: usize) *ui.widget.WordFlow { 384 return &self.scrollAt(index).child.word_flow; 385 } 386 387 fn rowStart(flow: *ui.widget.WordFlow, item: usize) bool { 388 const rects = flow.rects.items; 389 return item == 0 or rects[item - 1].y != rects[item].y; 390 } 391 392 fn rowEnd(flow: *ui.widget.WordFlow, item: usize) bool { 393 const rects = flow.rects.items; 394 return item + 1 == rects.len or rects[item + 1].y != rects[item].y; 395 } 396 397 // land on the other column's row at or above the current one: its first 398 // name when moving right, its last when moving left. names are laid out in 399 // order, so that row ends at the last name no lower than the current one. 400 fn crossTo(self: *View, target: usize, from_y: isize, root_focus: *Focus) void { 401 // the other column may have no names at all 402 const rects = self.flowAt(target).rects.items; 403 if (rects.len == 0) return; 404 var last: usize = 0; 405 for (rects, 0..) |rect, i| { 406 if (rect.y <= from_y) last = i; 407 } 408 var first = last; 409 while (first > 0 and rects[first - 1].y == rects[last].y) first -= 1; 410 self.focusName(target, if (target == right_col) first else last, root_focus); 411 } 412 413 fn focusName(self: *View, index: usize, item: usize, root_focus: *Focus) void { 414 const flow = self.flowAt(index); 415 root_focus.setFocus(flow.text_boxes.items[item].getFocus().id); 416 // the browser scrolls natively; the terminal brings the name into view 417 if (self.session.is_terminal and item < flow.rects.items.len) self.scrollAt(index).scrollToRect(flow.rects.items[item]); 418 } 419 420 pub fn clearGrid(self: *View) void { 421 self.box.clearGrid(); 422 } 423 424 pub fn getGrid(self: View) ?Grid { 425 return self.box.getGrid(); 426 } 427 428 pub fn getFocus(self: *View) *Focus { 429 return self.box.getFocus(); 430 } 431 432 // up leaves the tab from the search box 433 pub fn atTop(self: *View) bool { 434 return self.headerActive(); 435 } 436 437 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 438 root_focus.setFocus(self.searchBox().getFocus().id); 439 return true; 440 } 441 }; 442 443 // the "a:" link to `kind`'s column windowed from ref `name` (raw; "" = the 444 // first window) within `identity` ("owner:name"). the other column resets to 445 // its first window. 446 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8, search: []const u8) ![]const u8 { 447 const encoded = try ui.urlEncodeRef(page_arena.allocator(), name); 448 const window = ui.RoutablePage.repoRefsRoute(identity, kind, encoded) orelse return error.RouteTooLong; 449 const route = window.withSearch(search) orelse return error.RouteTooLong; 450 const url = try route.toUrl(page_arena); 451 return page_arena.allocator().print("a:{s}", .{url}); 452 } 453 454 // the "a:" link to the files tab at ref `name` (a branch or tag) within 455 // `identity` ("owner:name"), at its root directory. the name is percent-encoded 456 // since it can contain a '/'. 457 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 458 const aa = page_arena.allocator(); 459 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 460 .branch => .branch, 461 .tag => .tag, 462 }; 463 const value = try ui.urlEncodeRef(aa, name); 464 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 465 const url = try route.toUrl(page_arena); 466 return aa.print("a:{s}", .{url}); 467 } 468