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