╭ Issues.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 hash = xit.hash; 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const layout = xitui.layout; 10 const Key = xitui.input.Key; 11 const Grid = xitui.grid.Grid; 12 const Focus = xitui.focus.Focus; 13 const inp = @import("../input.zig"); 14 15 // how many issues one window shows before a "next" link appears. 16 pub const page_size = 20; 17 18 // one issue from the repo's consumed event database. 19 pub const Issue = struct { 20 id: []const u8, // hex event id 21 title: []const u8, 22 description: []const u8, 23 }; 24 25 // "owner/name", so the view can build /repo/owner/name/issues/... links. 26 identity: []const u8, 27 // the hex event id of the issue this window is rooted at ("" = the first 28 // window), mirrored into the url. 29 selected_id: []const u8, 30 issues: []const Issue, 31 // the id of the previous window's first issue ("" = the bare first window), or 32 // null when this window is already the first. 33 prev_id: ?[]const u8, 34 // the id of the next window's first issue, or null when this is the last window. 35 next_id: ?[]const u8, 36 37 const Self = @This(); 38 39 pub fn init( 40 arena: *std.heap.ArenaAllocator, 41 session: *ui.Session, 42 event_id: *const [evt.event_id_size]u8, 43 identity: []const u8, 44 selected_id: []const u8, 45 ) !Self { 46 const aa = arena.allocator(); 47 const empty: Self = .{ 48 .identity = try aa.dupe(u8, identity), 49 .selected_id = try aa.dupe(u8, selected_id), 50 .issues = &.{}, 51 .prev_id = null, 52 .next_id = null, 53 }; 54 55 // no filesystem (wasm) or nowhere to look: empty listing. the wasm path 56 // never 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 issue strings are 65 // duped into the page arena so they outlive the repo handle). 66 const gpa = arena.child_allocator; 67 var any_repo = rp.AnyRepo(.xit, .{}).open(io, gpa, .{ .path = repo_path }) catch return empty; 68 defer any_repo.deinit(io, gpa); 69 70 return switch (any_repo) { 71 inline else => |*repo| collect(repo.self_repo_opts, arena, repo, empty), 72 }; 73 } 74 75 // read one window of the repo's issues, ordered by creation time (oldest 76 // first), starting at the issue empty.selected_id names ("" = the beginning). 77 fn collect( 78 comptime repo_opts: rp.RepoOpts(.xit), 79 arena: *std.heap.ArenaAllocator, 80 repo: *rp.Repo(.xit, repo_opts), 81 empty: Self, 82 ) !Self { 83 const aa = arena.allocator(); 84 const DB = rp.Repo(.xit, repo_opts).DB; 85 // an explicitly named issue that doesn't exist is a bad url (NotFound -> 86 // 404); the bare route falls through to an empty listing. 87 const rooted = empty.selected_id.len != 0; 88 89 // a repo with no consumed events has no moment yet. 90 const haxy_moment = evt.currentMoment(repo_opts, repo) catch { 91 if (rooted) return error.NotFound; 92 return empty; 93 }; 94 95 const issue_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "issue-id-set")) orelse { 96 if (rooted) return error.NotFound; 97 return empty; 98 }; 99 const issue_id_set = try DB.SortedSet(.read_only).init(issue_id_set_cursor); 100 101 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse { 102 if (rooted) return error.NotFound; 103 return empty; 104 }; 105 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 106 107 // seek once to the window start: the named issue's order key (its recorded 108 // creation timestamp plus its id, so the seek lands on it), or the set's 109 // first entry. 110 var prev_id: ?[]const u8 = null; 111 var iter = if (!rooted) 112 try issue_id_set.iteratorFromIndex(0) 113 else blk: { 114 if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound; 115 var id_bytes: [evt.event_id_size]u8 = undefined; 116 _ = std.fmt.hexToBytes(&id_bytes, empty.selected_id) catch return error.NotFound; 117 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &id_bytes)) orelse return error.NotFound; 118 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 119 const issue_event = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map); 120 const order_key = evt.orderKey(issue_event.created_ts, &id_bytes); 121 122 // the previous window starts page_size ranks back. 123 const rank = try issue_id_set.rank(&order_key); 124 if (rank > 0) { 125 const prev_rank = rank -| page_size; 126 if (prev_rank == 0) { 127 prev_id = ""; 128 } else { 129 const kv = try issue_id_set.getIndexKeyValuePair(@intCast(prev_rank)) orelse return error.NotFound; 130 var prev_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 131 _ = try kv.key_cursor.readBytes(&prev_key); 132 const prev_hex = std.fmt.bytesToHex(prev_key[@sizeOf(u64)..].*, .lower); 133 prev_id = try aa.dupe(u8, &prev_hex); 134 } 135 } 136 137 break :blk try issue_id_set.iteratorFrom(&order_key); 138 }; 139 140 // collect this window's issues, plus a peek at the one after it (its id is 141 // the next window's start). the set's keys are orderKey 142 // ([timestamp][event-id]); the trailing bytes of each key are the issue 143 // event id. 144 var issues: std.ArrayList(Issue) = .empty; 145 var next_id: ?[]const u8 = null; 146 while (try iter.next()) |id_cursor_val| { 147 var id_cursor = id_cursor_val; 148 const id_kv = try id_cursor.readKeyValuePair(); 149 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 150 _ = try id_kv.key_cursor.readBytes(&order_key); 151 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 152 if (issues.items.len == page_size) { 153 next_id = try aa.dupe(u8, &id_hex); 154 break; 155 } 156 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, order_key[@sizeOf(u64)..])) orelse continue; 157 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 158 const issue_event = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map); 159 try issues.append(aa, .{ 160 .id = try aa.dupe(u8, &id_hex), 161 .title = issue_event.title, 162 .description = issue_event.description, 163 }); 164 } 165 166 return .{ 167 .identity = empty.identity, 168 .selected_id = empty.selected_id, 169 .issues = issues.items, 170 .prev_id = prev_id, 171 .next_id = next_id, 172 }; 173 } 174 175 pub const View = struct { 176 // a horizontal split with the issue list on the left and a detail pane on 177 // the right showing the selected issue's description. 178 box: wgt.Box(ui.Widget), 179 data: *const Self, 180 session: *ui.Session, 181 // the issue whose description the pane currently shows (index into data.issues). 182 detailed_index: ?usize, 183 184 const list_index: usize = 0; 185 const detail_index: usize = 1; 186 const list_max_width: usize = 40; 187 const detail_min_width: usize = 40; 188 189 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 190 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 191 errdefer box.deinit(allocator); 192 193 // the issue list (one focusable row per title), plus a "next" link that 194 // reloads the page rooted at the following issue. 195 { 196 var list_scroll = blk: { 197 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 198 errdefer list_box.deinit(allocator); 199 if (data.prev_id) |prev| 200 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, prev)); 201 for (data.issues) |issue| 202 try addRow(allocator, &list_box, issue.title, try issueRowLink(session.page_arena, data.identity, issue.id)); 203 if (data.next_id) |next| 204 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, next)); 205 // select the window's first issue (past a leading "previous" 206 // row) so its description shows on load. 207 if (data.issues.len > 0) 208 list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] 209 else if (list_box.children.count() > 0) 210 list_box.getFocus().child_id = list_box.children.keys()[0]; 211 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 212 }; 213 errdefer list_scroll.deinit(allocator); 214 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 215 } 216 217 // the detail pane — a frame around a scroll of the description 218 { 219 var detail_outer = blk: { 220 var detail_scroll = blk2: { 221 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 222 errdefer detail_inner.deinit(allocator); 223 // fill the pane (content top-left, scroll bar pinned to the 224 // edge) rather than shrinking to the description. 225 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 226 }; 227 errdefer detail_scroll.deinit(allocator); 228 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 229 errdefer frame.deinit(allocator); 230 // the frame's selected child is its scroll, so the focus chain 231 // reaches the description (populateDetail points the scroll's 232 // inner box at it), letting focus recovery descend into the pane. 233 frame.getFocus().child_id = detail_scroll.getFocus().id; 234 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 235 break :blk frame; 236 }; 237 errdefer detail_outer.deinit(allocator); 238 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 239 } 240 241 box.getFocus().child_id = box.children.keys()[list_index]; 242 243 return .{ 244 .box = box, 245 .data = data, 246 .session = session, 247 .detailed_index = null, 248 }; 249 } 250 251 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 252 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 253 errdefer row.deinit(allocator); 254 row.getFocus().focusable = true; 255 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 256 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 257 } 258 259 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 260 self.box.deinit(allocator); 261 } 262 263 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 264 return &self.box.children.values()[list_index].widget.scroll; 265 } 266 267 fn listBox(self: *View) *wgt.Box(ui.Widget) { 268 return &self.listScroll().child.box; 269 } 270 271 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 272 return &self.box.children.values()[detail_index].widget.box; 273 } 274 275 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 276 return &self.detailOuter().children.values()[0].widget.scroll; 277 } 278 279 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 280 return &self.detailScroll().child.box; 281 } 282 283 fn detailActive(self: *View) bool { 284 const cid = self.box.getFocus().child_id orelse return false; 285 return self.box.children.getIndex(cid) == detail_index; 286 } 287 288 // the selected issue's index, or null when a window-navigation row is 289 // selected (a leading "previous" row shifts the issue rows down by one). 290 fn selectedIssueIndex(self: *View) ?usize { 291 const lb = self.listBox(); 292 const cid = lb.getFocus().child_id orelse return null; 293 const idx = lb.children.getIndex(cid) orelse return null; 294 const lead: usize = if (self.data.prev_id != null) 1 else 0; 295 if (idx < lead or idx - lead >= self.data.issues.len) return null; 296 return idx - lead; 297 } 298 299 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 300 self.clearGrid(); 301 302 // swap the detail pane to the selected issue when it changes. 303 try self.refreshDetail(allocator); 304 305 // mirror the focused issue into the url so it updates as the selection 306 // moves, but only while focus is inside this view (the list or detail). 307 // when focus sits on the header tab, use the page's base /issues route. 308 if (root_focus.grandchild_id) |g| { 309 if (self.box.getFocus().children.contains(g)) { 310 if (self.selectedIssueIndex()) |sel| { 311 if (ui.RoutablePage.repoIssuesRoute(self.data.identity, self.data.issues[sel].id)) |route| 312 self.session.data.current_page = route; 313 } 314 } 315 } 316 317 // the selected list row shows a border (the focused TextBox upgrades it 318 // to a double border itself); the rest stay borderless. 319 const lb = self.listBox(); 320 for (lb.children.keys(), lb.children.values()) |id, *child| { 321 switch (child.widget) { 322 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 323 else => {}, 324 } 325 } 326 327 // the description shows a single border while selected (the focused 328 // TextBox upgrades it to a double border itself). 329 const inner = self.detailInner(); 330 for (inner.children.keys(), inner.children.values()) |id, *child| { 331 switch (child.widget) { 332 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 333 else => {}, 334 } 335 } 336 337 // cap the list at list_max_width only while the detail pane fits beside 338 // it. the box drops the detail when the width can't hold both minimums, 339 // so when it's that narrow we lift the cap and let the list fill the 340 // whole width. 341 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 342 self.box.children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 343 344 // stretch the detail pane across the rest of the width so it fills the 345 // area rather than shrinking to its content; its scroll fills the pane. 346 if (constraint.max_size.width) |w| { 347 self.box.children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 348 } else { 349 self.box.children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 350 } 351 352 try self.box.build(allocator, constraint, root_focus); 353 } 354 355 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 356 const sel = self.selectedIssueIndex() orelse return; 357 if (self.detailed_index) |d| if (d == sel) return; 358 try self.populateDetail(allocator, sel); 359 self.detailed_index = sel; 360 } 361 362 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 363 const issue = self.data.issues[sel]; 364 const inner = self.detailInner(); 365 366 for (inner.children.values()) |*child| child.widget.deinit(allocator); 367 inner.children.clearAndFree(allocator); 368 inner.getFocus().child_id = null; 369 370 // the description as a focusable word-wrapped text box. its hidden 371 // border reserves the space the border occupies when focused, so 372 // focusing doesn't shift layout. 373 { 374 var tb = try wgt.TextBox(ui.Widget).init(allocator, issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 375 errdefer tb.deinit(allocator); 376 tb.getFocus().focusable = true; 377 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 378 } 379 380 // point the pane at its row so focus recovery can land here. 381 inner.getFocus().child_id = inner.children.keys()[0]; 382 383 // reset the scroll to the top for the newly-shown issue: directly on the 384 // terminal (the wasm offset), and via a version bump on the web (so the 385 // renderer's scroll id changes and JS drops the preserved position). 386 const sc = self.detailScroll(); 387 sc.x = 0; 388 sc.y = 0; 389 sc.getFocus().version +%= 1; 390 } 391 392 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 393 _ = allocator; 394 if (self.detailActive()) { 395 try self.detailInput(key, root_focus); 396 } else { 397 try self.listInput(key, root_focus); 398 } 399 } 400 401 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 402 // up/down (and the scroll wheel) move the selection a row; page up/down 403 // jump a fixed amount. right/Enter cross into the detail pane. 404 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 405 ui.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 406 return; 407 } 408 switch (key) { 409 .enter, .arrow_right => try self.focusDetail(root_focus), 410 else => {}, 411 } 412 } 413 414 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 415 const sc = self.detailScroll(); 416 switch (key) { 417 .arrow_left => try self.focusList(root_focus), 418 .arrow_up => sc.y -= 1, 419 .arrow_down => sc.y += 1, 420 .page_up => sc.y -= 10, 421 .page_down => sc.y += 10, 422 .home => sc.y = 0, 423 .end => sc.y = std.math.maxInt(isize), 424 .mouse => |mouse| switch (mouse.action) { 425 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 426 else => {}, 427 }, 428 else => return, 429 } 430 sc.clampToContent(); 431 } 432 433 // enter the detail pane. an empty pane (no issues) can't be entered. 434 fn focusDetail(self: *View, root_focus: *Focus) !void { 435 if (self.detailInner().children.count() == 0) return; 436 root_focus.setFocus(self.detailOuter().getFocus().id); 437 } 438 439 // return to the list. 440 fn focusList(self: *View, root_focus: *Focus) !void { 441 root_focus.setFocus(self.listScroll().getFocus().id); 442 } 443 444 pub fn clearGrid(self: *View) void { 445 self.box.clearGrid(); 446 } 447 448 pub fn getGrid(self: View) ?Grid { 449 return self.box.getGrid(); 450 } 451 452 pub fn getFocus(self: *View) *Focus { 453 return self.box.getFocus(); 454 } 455 456 // for the parent's "scroll up at the top jumps to the header" check. in the 457 // detail pane that means it can't scroll up any further; otherwise up-arrow 458 // stays in the pane. when the list holds focus, report its selected row 459 // directly (window-navigation rows included). 460 pub fn getSelectedIndex(self: *View) ?usize { 461 if (self.detailActive()) { 462 return if (self.detailScroll().y == 0) 0 else 1; 463 } 464 const lb = self.listBox(); 465 const cid = lb.getFocus().child_id orelse return null; 466 return lb.children.getIndex(cid); 467 } 468 }; 469 470 // the "a:" navigation link for the issues page rooted at issue `id` within 471 // `identity` ("owner/name"). 472 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 473 const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; 474 const url = try route.urlAlloc(page_arena); 475 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 476 } 477 478 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 479 // href is only followed with js off. 480 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 481 const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; 482 const url = try route.urlAlloc(page_arena); 483 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 484 } 485