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