╭ 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, with its hex event id 19 // (the id lives in the event envelope, not the payload). 20 pub const IssueWithId = struct { 21 id: []const u8, 22 issue: evt.Issue, 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 IssueWithId, 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(IssueWithId) = .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 try issues.append(aa, .{ 159 .id = try aa.dupe(u8, &id_hex), 160 .issue = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map), 161 }); 162 } 163 164 return .{ 165 .identity = empty.identity, 166 .selected_id = empty.selected_id, 167 .issues = issues.items, 168 .prev_id = prev_id, 169 .next_id = next_id, 170 }; 171 } 172 173 pub const View = struct { 174 // a horizontal split with the issue list on the left and a detail pane on 175 // the right showing the selected issue's description. 176 box: wgt.Box(ui.Widget), 177 data: *const Self, 178 session: *ui.Session, 179 // the issue whose description the pane currently shows (index into data.issues). 180 detailed_index: ?usize, 181 182 const list_index: usize = 0; 183 const detail_index: usize = 1; 184 const list_max_width: usize = 40; 185 const detail_min_width: usize = 40; 186 187 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 188 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 189 errdefer box.deinit(allocator); 190 191 // the issue list (one focusable row per title), plus a "next" link that 192 // reloads the page rooted at the following issue. 193 { 194 var list_scroll = blk: { 195 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 196 errdefer list_box.deinit(allocator); 197 if (data.prev_id) |prev| 198 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, prev)); 199 for (data.issues) |entry| 200 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id)); 201 if (data.next_id) |next| 202 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, next)); 203 // select the window's first issue (past a leading "previous" 204 // row) so its description shows on load. 205 if (data.issues.len > 0) 206 list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] 207 else if (list_box.children.count() > 0) 208 list_box.getFocus().child_id = list_box.children.keys()[0]; 209 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 210 }; 211 errdefer list_scroll.deinit(allocator); 212 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 } }); 213 } 214 215 // the detail pane — a frame around a scroll of the description 216 { 217 var detail_outer = blk: { 218 var detail_scroll = blk2: { 219 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 220 errdefer detail_inner.deinit(allocator); 221 // fill the pane (content top-left, scroll bar pinned to the 222 // edge) rather than shrinking to the description. 223 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 224 }; 225 errdefer detail_scroll.deinit(allocator); 226 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 227 errdefer frame.deinit(allocator); 228 // the frame's selected child is its scroll, so the focus chain 229 // reaches the description (populateDetail points the scroll's 230 // inner box at it), letting focus recovery descend into the pane. 231 frame.getFocus().child_id = detail_scroll.getFocus().id; 232 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 233 break :blk frame; 234 }; 235 errdefer detail_outer.deinit(allocator); 236 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 237 } 238 239 box.getFocus().child_id = box.children.keys()[list_index]; 240 241 return .{ 242 .box = box, 243 .data = data, 244 .session = session, 245 .detailed_index = null, 246 }; 247 } 248 249 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 250 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 251 errdefer row.deinit(allocator); 252 row.getFocus().focusable = true; 253 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 254 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 255 } 256 257 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 258 self.box.deinit(allocator); 259 } 260 261 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 262 return &self.box.children.values()[list_index].widget.scroll; 263 } 264 265 fn listBox(self: *View) *wgt.Box(ui.Widget) { 266 return &self.listScroll().child.box; 267 } 268 269 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 270 return &self.box.children.values()[detail_index].widget.box; 271 } 272 273 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 274 return &self.detailOuter().children.values()[0].widget.scroll; 275 } 276 277 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 278 return &self.detailScroll().child.box; 279 } 280 281 fn detailActive(self: *View) bool { 282 const cid = self.box.getFocus().child_id orelse return false; 283 return self.box.children.getIndex(cid) == detail_index; 284 } 285 286 // the selected issue's index, or null when a window-navigation row is 287 // selected (a leading "previous" row shifts the issue rows down by one). 288 fn selectedIssueIndex(self: *View) ?usize { 289 const lb = self.listBox(); 290 const cid = lb.getFocus().child_id orelse return null; 291 const idx = lb.children.getIndex(cid) orelse return null; 292 const lead: usize = if (self.data.prev_id != null) 1 else 0; 293 if (idx < lead or idx - lead >= self.data.issues.len) return null; 294 return idx - lead; 295 } 296 297 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 298 self.clearGrid(); 299 300 // swap the detail pane to the selected issue when it changes. 301 try self.refreshDetail(allocator); 302 303 // mirror the focused issue into the url so it updates as the selection 304 // moves, but only while focus is inside this view (the list or detail). 305 // when focus sits on the header tab, use the page's base /issues route. 306 if (root_focus.grandchild_id) |g| { 307 if (self.box.getFocus().children.contains(g)) { 308 if (self.selectedIssueIndex()) |sel| { 309 if (ui.RoutablePage.repoIssuesRoute(self.data.identity, self.data.issues[sel].id)) |route| 310 self.session.data.current_page = route; 311 } 312 } 313 } 314 315 // the selected list row shows a border (the focused TextBox upgrades it 316 // to a double border itself); the rest stay borderless. 317 const lb = self.listBox(); 318 for (lb.children.keys(), lb.children.values()) |id, *child| { 319 switch (child.widget) { 320 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 321 else => {}, 322 } 323 } 324 325 // the description shows a single border while selected (the focused 326 // TextBox upgrades it to a double border itself). 327 const inner = self.detailInner(); 328 for (inner.children.keys(), inner.children.values()) |id, *child| { 329 switch (child.widget) { 330 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 331 else => {}, 332 } 333 } 334 335 // cap the list at list_max_width only while the detail pane fits beside 336 // it. the box drops the detail when the width can't hold both minimums, 337 // so when it's that narrow we lift the cap and let the list fill the 338 // whole width. 339 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 340 self.box.children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 341 342 // stretch the detail pane across the rest of the width so it fills the 343 // area rather than shrinking to its content; its scroll fills the pane. 344 if (constraint.max_size.width) |w| { 345 self.box.children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 346 } else { 347 self.box.children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 348 } 349 350 try self.box.build(allocator, constraint, root_focus); 351 } 352 353 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 354 const sel = self.selectedIssueIndex() orelse return; 355 if (self.detailed_index) |d| if (d == sel) return; 356 try self.populateDetail(allocator, sel); 357 self.detailed_index = sel; 358 } 359 360 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 361 const entry = self.data.issues[sel]; 362 const inner = self.detailInner(); 363 364 for (inner.children.values()) |*child| child.widget.deinit(allocator); 365 inner.children.clearAndFree(allocator); 366 inner.getFocus().child_id = null; 367 368 // the description as a focusable word-wrapped text box. its hidden 369 // border reserves the space the border occupies when focused, so 370 // focusing doesn't shift layout. 371 { 372 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 373 errdefer tb.deinit(allocator); 374 tb.getFocus().focusable = true; 375 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 376 } 377 378 // point the pane at its row so focus recovery can land here. 379 inner.getFocus().child_id = inner.children.keys()[0]; 380 381 // reset the scroll to the top for the newly-shown issue: directly on the 382 // terminal (the wasm offset), and via a version bump on the web (so the 383 // renderer's scroll id changes and JS drops the preserved position). 384 const sc = self.detailScroll(); 385 sc.x = 0; 386 sc.y = 0; 387 sc.getFocus().version +%= 1; 388 } 389 390 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 391 _ = allocator; 392 if (self.detailActive()) { 393 try self.detailInput(key, root_focus); 394 } else { 395 try self.listInput(key, root_focus); 396 } 397 } 398 399 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 400 // up/down (and the scroll wheel) move the selection a row; page up/down 401 // jump a fixed amount. right/Enter cross into the detail pane. 402 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 403 ui.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 404 return; 405 } 406 switch (key) { 407 .enter, .arrow_right => try self.focusDetail(root_focus), 408 else => {}, 409 } 410 } 411 412 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 413 const sc = self.detailScroll(); 414 switch (key) { 415 .arrow_left => try self.focusList(root_focus), 416 .arrow_up => sc.y -= 1, 417 .arrow_down => sc.y += 1, 418 .page_up => sc.y -= 10, 419 .page_down => sc.y += 10, 420 .home => sc.y = 0, 421 .end => sc.y = std.math.maxInt(isize), 422 .mouse => |mouse| switch (mouse.action) { 423 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 424 else => {}, 425 }, 426 else => return, 427 } 428 sc.clampToContent(); 429 } 430 431 // enter the detail pane. an empty pane (no issues) can't be entered. 432 fn focusDetail(self: *View, root_focus: *Focus) !void { 433 if (self.detailInner().children.count() == 0) return; 434 root_focus.setFocus(self.detailOuter().getFocus().id); 435 } 436 437 // return to the list. 438 fn focusList(self: *View, root_focus: *Focus) !void { 439 root_focus.setFocus(self.listScroll().getFocus().id); 440 } 441 442 pub fn clearGrid(self: *View) void { 443 self.box.clearGrid(); 444 } 445 446 pub fn getGrid(self: View) ?Grid { 447 return self.box.getGrid(); 448 } 449 450 pub fn getFocus(self: *View) *Focus { 451 return self.box.getFocus(); 452 } 453 454 // for the parent's "scroll up at the top jumps to the header" check. in the 455 // detail pane that means it can't scroll up any further; otherwise up-arrow 456 // stays in the pane. when the list holds focus, report its selected row 457 // directly (window-navigation rows included). 458 pub fn getSelectedIndex(self: *View) ?usize { 459 if (self.detailActive()) { 460 return if (self.detailScroll().y == 0) 0 else 1; 461 } 462 const lb = self.listBox(); 463 const cid = lb.getFocus().child_id orelse return null; 464 return lb.children.getIndex(cid); 465 } 466 }; 467 468 // the "a:" navigation link for the issues page rooted at issue `id` within 469 // `identity` ("owner/name"). 470 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 471 const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; 472 const url = try route.urlAlloc(page_arena); 473 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 474 } 475 476 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 477 // href is only followed with js off. 478 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 479 const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; 480 const url = try route.urlAlloc(page_arena); 481 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 482 } 483