╭ 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 url-encoded tag the list is filtered to ("" = unfiltered). 28 tag: []const u8, 29 // the hex event id of the issue this window is rooted at ("" = the first 30 // window), mirrored into the url. 31 selected_id: []const u8, 32 issues: []const IssueWithId, 33 // the id of the previous window's first issue ("" = the bare first window), or 34 // null when this window is already the first. 35 prev_id: ?[]const u8, 36 // the id of the next window's first issue, or null when this is the last window. 37 next_id: ?[]const u8, 38 39 const Self = @This(); 40 41 pub fn init( 42 arena: *std.heap.ArenaAllocator, 43 session: *ui.Session, 44 source_maybe: ?ui.RepoSource, 45 identity: []const u8, 46 tag: []const u8, 47 selected_id: []const u8, 48 ) !Self { 49 const aa = arena.allocator(); 50 const empty: Self = .{ 51 .identity = try aa.dupe(u8, identity), 52 .tag = try aa.dupe(u8, tag), 53 .selected_id = try aa.dupe(u8, selected_id), 54 .issues = &.{}, 55 .prev_id = null, 56 .next_id = null, 57 }; 58 59 // no filesystem (wasm) or nowhere to look: empty listing. the wasm path 60 // never calls init anyway — it rebuilds from the serialized snapshot. 61 const io = session.io orelse return empty; 62 const source = source_maybe orelse return empty; 63 64 // issues live in the repo's own xit db, so a git-backed repo has none. 65 if (source.repo_kind == .git) return empty; 66 67 // open with the arena's backing allocator (transient; the issue strings are 68 // duped into the page arena so they outlive the repo handle). 69 const gpa = arena.child_allocator; 70 var any_repo = rp.AnyRepo(.xit, .{}).open(io, gpa, .{ .path = source.path }) catch return empty; 71 defer any_repo.deinit(io, gpa); 72 73 return switch (any_repo) { 74 inline else => |*repo| collect(repo.self_repo_opts, arena, repo, empty), 75 }; 76 } 77 78 // read one window of the repo's issues (filtered to empty.tag when set), 79 // ordered by creation time (oldest first), starting at the issue 80 // empty.selected_id names ("" = the beginning). 81 fn collect( 82 comptime repo_opts: rp.RepoOpts(.xit), 83 arena: *std.heap.ArenaAllocator, 84 repo: *rp.Repo(.xit, repo_opts), 85 empty: Self, 86 ) !Self { 87 const aa = arena.allocator(); 88 const DB = rp.Repo(.xit, repo_opts).DB; 89 const rooted = empty.selected_id.len != 0; 90 const tagged = empty.tag.len != 0; 91 // an explicitly named issue or tag that doesn't exist is a bad url 92 // (NotFound -> 404); the bare route falls through to an empty listing. 93 const strict = rooted or tagged; 94 95 // a repo with no consumed events has no moment yet. 96 const haxy_moment = evt.currentMoment(repo_opts, repo) catch { 97 if (strict) return error.NotFound; 98 return empty; 99 }; 100 101 // the ordered set to window: the tag's issue set when filtered, else the 102 // full issue set. 103 const issue_id_set_cursor = blk: { 104 if (tagged) { 105 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag->issue-id-set")) orelse return error.NotFound; 106 const tag_to_issues = try DB.SortedMap(.read_only).init(tag_to_issues_cursor); 107 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.tag)); 108 break :blk (try tag_to_issues.getCursor(decoded)) orelse return error.NotFound; 109 } 110 break :blk (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "issue-id-set"))) orelse { 111 if (strict) return error.NotFound; 112 return empty; 113 }; 114 }; 115 const issue_id_set = try DB.SortedSet(.read_only).init(issue_id_set_cursor); 116 117 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse { 118 if (strict) return error.NotFound; 119 return empty; 120 }; 121 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 122 123 // seek once to the window start: the named issue's order key (its recorded 124 // creation timestamp plus its id, so the seek lands on it), or the set's 125 // first entry. 126 var prev_id: ?[]const u8 = null; 127 var iter = if (!rooted) 128 try issue_id_set.iteratorFromIndex(0) 129 else blk: { 130 if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound; 131 var id_bytes: [evt.event_id_size]u8 = undefined; 132 _ = std.fmt.hexToBytes(&id_bytes, empty.selected_id) catch return error.NotFound; 133 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &id_bytes)) orelse return error.NotFound; 134 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 135 const issue_event = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map); 136 const order_key = evt.orderKey(issue_event.created_ts, &id_bytes); 137 138 // the named issue must be in the windowed set (a tag url can name an 139 // issue that doesn't carry the tag). 140 if (!try issue_id_set.contains(&order_key)) return error.NotFound; 141 142 // the previous window starts page_size ranks back. 143 const rank = try issue_id_set.rank(&order_key); 144 if (rank > 0) { 145 const prev_rank = rank -| page_size; 146 if (prev_rank == 0) { 147 prev_id = ""; 148 } else { 149 const kv = try issue_id_set.getIndexKeyValuePair(@intCast(prev_rank)) orelse return error.NotFound; 150 var prev_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 151 _ = try kv.key_cursor.readBytes(&prev_key); 152 const prev_hex = std.fmt.bytesToHex(prev_key[@sizeOf(u64)..].*, .lower); 153 prev_id = try aa.dupe(u8, &prev_hex); 154 } 155 } 156 157 break :blk try issue_id_set.iteratorFrom(&order_key); 158 }; 159 160 // collect this window's issues, plus a peek at the one after it (its id is 161 // the next window's start). the set's keys are orderKey 162 // ([timestamp][event-id]); the trailing bytes of each key are the issue 163 // event id. 164 var issues: std.ArrayList(IssueWithId) = .empty; 165 var next_id: ?[]const u8 = null; 166 while (try iter.next()) |id_cursor_val| { 167 var id_cursor = id_cursor_val; 168 const id_kv = try id_cursor.readKeyValuePair(); 169 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 170 _ = try id_kv.key_cursor.readBytes(&order_key); 171 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 172 if (issues.items.len == page_size) { 173 next_id = try aa.dupe(u8, &id_hex); 174 break; 175 } 176 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, order_key[@sizeOf(u64)..])) orelse continue; 177 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 178 try issues.append(aa, .{ 179 .id = try aa.dupe(u8, &id_hex), 180 .issue = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map), 181 }); 182 } 183 184 return .{ 185 .identity = empty.identity, 186 .tag = empty.tag, 187 .selected_id = empty.selected_id, 188 .issues = issues.items, 189 .prev_id = prev_id, 190 .next_id = next_id, 191 }; 192 } 193 194 pub const View = struct { 195 // a horizontal split with the issue list on the left and a detail pane on 196 // the right showing the selected issue's description. 197 box: wgt.Box(ui.Widget), 198 data: *const Self, 199 session: *ui.Session, 200 // the issue whose description the pane currently shows (index into data.issues). 201 detailed_index: ?usize, 202 203 const list_index: usize = 0; 204 const detail_index: usize = 1; 205 const list_max_width: usize = 40; 206 const detail_min_width: usize = 40; 207 208 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 209 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 210 errdefer box.deinit(allocator); 211 212 // the issue list (one focusable row per title), plus a "next" link that 213 // reloads the page rooted at the following issue. 214 { 215 var list_scroll = blk: { 216 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 217 errdefer list_box.deinit(allocator); 218 if (data.prev_id) |prev| 219 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); 220 for (data.issues) |entry| 221 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id)); 222 if (data.next_id) |next| 223 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next)); 224 // select the window's first issue (past a leading "previous" 225 // row) so its description shows on load. 226 if (data.issues.len > 0) 227 list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] 228 else if (list_box.children.count() > 0) 229 list_box.getFocus().child_id = list_box.children.keys()[0]; 230 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 231 }; 232 errdefer list_scroll.deinit(allocator); 233 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 } }); 234 } 235 236 // the detail pane — a frame around a scroll of the description 237 { 238 var detail_outer = blk: { 239 var detail_scroll = blk2: { 240 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 241 errdefer detail_inner.deinit(allocator); 242 // fill the pane (content top-left, scroll bar pinned to the 243 // edge) rather than shrinking to the description. 244 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 245 }; 246 errdefer detail_scroll.deinit(allocator); 247 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 248 errdefer frame.deinit(allocator); 249 // the frame's selected child is its scroll, so the focus chain 250 // reaches the description (populateDetail points the scroll's 251 // inner box at it), letting focus recovery descend into the pane. 252 frame.getFocus().child_id = detail_scroll.getFocus().id; 253 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 254 break :blk frame; 255 }; 256 errdefer detail_outer.deinit(allocator); 257 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 258 } 259 260 box.getFocus().child_id = box.children.keys()[list_index]; 261 262 return .{ 263 .box = box, 264 .data = data, 265 .session = session, 266 .detailed_index = null, 267 }; 268 } 269 270 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 271 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 272 errdefer row.deinit(allocator); 273 row.getFocus().focusable = true; 274 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 275 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 276 } 277 278 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 279 self.box.deinit(allocator); 280 } 281 282 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 283 return &self.box.children.values()[list_index].widget.scroll; 284 } 285 286 fn listBox(self: *View) *wgt.Box(ui.Widget) { 287 return &self.listScroll().child.box; 288 } 289 290 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 291 return &self.box.children.values()[detail_index].widget.box; 292 } 293 294 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 295 return &self.detailOuter().children.values()[0].widget.scroll; 296 } 297 298 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 299 return &self.detailScroll().child.box; 300 } 301 302 fn detailActive(self: *View) bool { 303 const cid = self.box.getFocus().child_id orelse return false; 304 return self.box.children.getIndex(cid) == detail_index; 305 } 306 307 // the selected issue's index, or null when a window-navigation row is 308 // selected (a leading "previous" row shifts the issue rows down by one). 309 fn selectedIssueIndex(self: *View) ?usize { 310 const lb = self.listBox(); 311 const cid = lb.getFocus().child_id orelse return null; 312 const idx = lb.children.getIndex(cid) orelse return null; 313 const lead: usize = if (self.data.prev_id != null) 1 else 0; 314 if (idx < lead or idx - lead >= self.data.issues.len) return null; 315 return idx - lead; 316 } 317 318 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 319 self.clearGrid(); 320 321 // swap the detail pane to the selected issue when it changes. 322 try self.refreshDetail(allocator); 323 324 // mirror the focused issue into the url so it updates as the selection 325 // moves, but only while focus is inside this view (the list or detail). 326 // when focus sits on the header tab, use the page's base /issues route. 327 // an issue's url is the same whether or not the list is filtered, so 328 // the mirror drops the tag. 329 if (root_focus.grandchild_id) |g| { 330 if (self.box.getFocus().children.contains(g)) { 331 if (self.selectedIssueIndex()) |sel| { 332 if (ui.RoutablePage.repoIssuesRoute(self.data.identity, "", self.data.issues[sel].id)) |route| 333 self.session.data.current_page = route; 334 } 335 } 336 } 337 338 // the selected list row shows a border (the focused TextBox upgrades it 339 // to a double border itself); the rest stay borderless. 340 const lb = self.listBox(); 341 for (lb.children.keys(), lb.children.values()) |id, *child| { 342 switch (child.widget) { 343 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 344 else => {}, 345 } 346 } 347 348 // the description shows a single border while selected (the focused 349 // TextBox upgrades it to a double border itself). 350 const inner = self.detailInner(); 351 for (inner.children.keys(), inner.children.values()) |id, *child| { 352 switch (child.widget) { 353 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 354 else => {}, 355 } 356 } 357 358 // cap the list at list_max_width only while the detail pane fits beside 359 // it. the box drops the detail when the width can't hold both minimums, 360 // so when it's that narrow we lift the cap and let the list fill the 361 // whole width. 362 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 363 self.box.children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 364 365 // stretch the detail pane across the rest of the width so it fills the 366 // area rather than shrinking to its content; its scroll fills the pane. 367 if (constraint.max_size.width) |w| { 368 self.box.children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 369 } else { 370 self.box.children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 371 } 372 373 try self.box.build(allocator, constraint, root_focus); 374 } 375 376 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 377 const sel = self.selectedIssueIndex() orelse return; 378 if (self.detailed_index) |d| if (d == sel) return; 379 try self.populateDetail(allocator, sel); 380 self.detailed_index = sel; 381 } 382 383 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 384 const entry = self.data.issues[sel]; 385 const inner = self.detailInner(); 386 387 for (inner.children.values()) |*child| child.widget.deinit(allocator); 388 inner.children.clearAndFree(allocator); 389 inner.getFocus().child_id = null; 390 391 // the issue's tags, each linking to the list filtered to that tag. 392 { 393 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 394 defer items.deinit(allocator); 395 var tag_iter = evt.Issue.tagIterator(entry.issue.tags); 396 while (tag_iter.next()) |tag| { 397 if (tag.len == 0) continue; 398 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, tag) }); 399 } 400 if (items.items.len > 0) { 401 var tf = try ui.TagFlow.init(allocator); 402 errdefer tf.deinit(allocator); 403 try tf.setItems(allocator, items.items); 404 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 405 } 406 } 407 408 // the description as a focusable word-wrapped text box. its hidden 409 // border reserves the space the border occupies when focused, so 410 // focusing doesn't shift layout. 411 { 412 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 413 errdefer tb.deinit(allocator); 414 tb.getFocus().focusable = true; 415 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 416 } 417 418 // point the pane at its first row so focus recovery can land here. 419 inner.getFocus().child_id = inner.children.keys()[0]; 420 421 // reset the scroll to the top for the newly-shown issue: directly on the 422 // terminal (the wasm offset), and via a version bump on the web (so the 423 // renderer's scroll id changes and JS drops the preserved position). 424 const sc = self.detailScroll(); 425 sc.x = 0; 426 sc.y = 0; 427 sc.getFocus().version +%= 1; 428 } 429 430 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 431 _ = allocator; 432 if (self.detailActive()) { 433 try self.detailInput(key, root_focus); 434 } else { 435 try self.listInput(key, root_focus); 436 } 437 } 438 439 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 440 // up/down (and the scroll wheel) move the selection a row; page up/down 441 // jump a fixed amount. right/Enter cross into the detail pane. 442 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 443 ui.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 444 return; 445 } 446 switch (key) { 447 .enter, .arrow_right => try self.focusDetail(root_focus), 448 else => {}, 449 } 450 } 451 452 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 453 if (self.tagsFocused()) { 454 try self.tagsInput(key, root_focus); 455 } else { 456 try self.descriptionInput(key, root_focus); 457 } 458 } 459 460 fn descriptionInput(self: *View, key: Key, root_focus: *Focus) !void { 461 const sc = self.detailScroll(); 462 switch (key) { 463 .arrow_left => return self.focusList(root_focus), 464 // once the scroll can't move further, cross into the tags. 465 .arrow_up => { 466 const before = sc.y; 467 sc.y -= 1; 468 sc.clampToContent(); 469 if (sc.y == before) try self.focusTags(root_focus); 470 return; 471 }, 472 .arrow_down => sc.y += 1, 473 .page_up => sc.y -= 10, 474 .page_down => sc.y += 10, 475 .home => sc.y = 0, 476 .end => sc.y = std.math.maxInt(isize), 477 .mouse => |mouse| switch (mouse.action) { 478 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 479 else => {}, 480 }, 481 else => return, 482 } 483 sc.clampToContent(); 484 } 485 486 // arrow keys move the tag selection; at the flow's edges focus crosses to 487 // the neighboring widgets. 488 fn tagsInput(self: *View, key: Key, root_focus: *Focus) !void { 489 const tf = self.tagFlow() orelse return; 490 const cid = tf.focus.child_id orelse return; 491 const cur = tf.indexOfFocusId(cid) orelse return; 492 const count = tf.text_boxes.items.len; 493 const sc = self.detailScroll(); 494 switch (key) { 495 .arrow_left => if (cur > 0) self.focusTag(tf, root_focus, cur - 1) else try self.focusList(root_focus), 496 .arrow_right => if (cur + 1 < count) self.focusTag(tf, root_focus, cur + 1), 497 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(tf, root_focus, i), 498 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(tf, root_focus, i) else try self.focusDescription(root_focus), 499 .home => self.focusTag(tf, root_focus, 0), 500 .end => self.focusTag(tf, root_focus, count - 1), 501 .mouse => |mouse| switch (mouse.action) { 502 .scroll => |dir| { 503 sc.y += if (dir == .up) @as(isize, -1) else 1; 504 sc.clampToContent(); 505 }, 506 else => {}, 507 }, 508 else => {}, 509 } 510 } 511 512 const tags_child_index: usize = 0; 513 514 fn tagFlow(self: *View) ?*ui.TagFlow { 515 const inner = self.detailInner(); 516 if (inner.children.count() == 0) return null; 517 return switch (inner.children.values()[tags_child_index].widget) { 518 .tag_flow => |*tf| tf, 519 else => null, 520 }; 521 } 522 523 fn tagsFocused(self: *View) bool { 524 const inner = self.detailInner(); 525 const cid = inner.getFocus().child_id orelse return false; 526 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow() != null; 527 } 528 529 fn focusTag(self: *View, tf: *ui.TagFlow, root_focus: *Focus, index: usize) void { 530 root_focus.setFocus(tf.text_boxes.items[index].getFocus().id); 531 // keep the tag visible on the terminal: its rect offset by the flow's 532 // position in the pane. 533 if (self.session.is_terminal and index < tf.rects.items.len) { 534 if (self.detailInner().children.values()[tags_child_index].rect) |flow_rect| { 535 var rect = tf.rects.items[index]; 536 rect.x += flow_rect.x; 537 rect.y += flow_rect.y; 538 self.detailScroll().scrollToRect(rect); 539 } 540 } 541 } 542 543 fn focusTags(self: *View, root_focus: *Focus) !void { 544 const tf = self.tagFlow() orelse return; 545 if (tf.text_boxes.items.len == 0) return; 546 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 547 const index = tf.indexOfFocusId(cid) orelse 0; 548 self.focusTag(tf, root_focus, index); 549 } 550 551 fn focusDescription(self: *View, root_focus: *Focus) !void { 552 const inner = self.detailInner(); 553 const index: usize = if (self.tagFlow() != null) 1 else 0; 554 if (inner.children.count() <= index) return; 555 root_focus.setFocus(inner.children.keys()[index]); 556 } 557 558 // enter the detail pane. an empty pane (no issues) can't be entered. 559 fn focusDetail(self: *View, root_focus: *Focus) !void { 560 if (self.detailInner().children.count() == 0) return; 561 root_focus.setFocus(self.detailOuter().getFocus().id); 562 } 563 564 // return to the list. 565 fn focusList(self: *View, root_focus: *Focus) !void { 566 root_focus.setFocus(self.listScroll().getFocus().id); 567 } 568 569 pub fn clearGrid(self: *View) void { 570 self.box.clearGrid(); 571 } 572 573 pub fn getGrid(self: View) ?Grid { 574 return self.box.getGrid(); 575 } 576 577 pub fn getFocus(self: *View) *Focus { 578 return self.box.getFocus(); 579 } 580 581 // for the parent's "scroll up at the top jumps to the header" check. when 582 // the list holds focus, report its selected row directly. 583 pub fn getSelectedIndex(self: *View) ?usize { 584 if (self.detailActive()) { 585 return if (self.detailScroll().y == 0 and self.paneTopFocused()) 0 else 1; 586 } 587 const lb = self.listBox(); 588 const cid = lb.getFocus().child_id orelse return null; 589 return lb.children.getIndex(cid); 590 } 591 592 // whether the focused element is the detail pane's topmost one, so up has 593 // nowhere further to go within the pane. 594 fn paneTopFocused(self: *View) bool { 595 const tf = self.tagFlow() orelse return true; 596 if (!self.tagsFocused()) return false; 597 const cid = tf.focus.child_id orelse return true; 598 const cur = tf.indexOfFocusId(cid) orelse return true; 599 return tf.rowStep(cur, false) == null; 600 } 601 }; 602 603 // the "a:" navigation link for the issues page filtered to the url-encoded 604 // `tag` and rooted at issue `id` within `identity` ("owner/name"). 605 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 { 606 const route = ui.RoutablePage.repoIssuesRoute(identity, tag, id) orelse return error.RouteTooLong; 607 const url = try route.urlAlloc(page_arena); 608 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 609 } 610 611 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 612 // href is only followed with js off. 613 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 614 const route = ui.RoutablePage.repoIssuesRoute(identity, "", id) orelse return error.RouteTooLong; 615 const url = try route.urlAlloc(page_arena); 616 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 617 } 618 619 // the "a:" link to the issues list filtered to `tag` (raw; encoded here). 620 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8) ![]const u8 { 621 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 622 const route = ui.RoutablePage.repoIssuesRoute(identity, encoded, "") orelse return error.RouteTooLong; 623 const url = try route.urlAlloc(page_arena); 624 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 625 } 626