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