╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │120 return empty; │ │121 }; │ │122 │ │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); │ │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; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │144 }; │ │145 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ │146 │ │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: { │ │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); │ │139 - const order_key = evt.orderKey(issue_event.created_ts, &id_bytes); │ │159 + const order_key = try aa.dupe(u8, &evt.orderKey(issue_event.created_ts, &id_bytes)); │ │160 │ │141 - // the named issue must be in the windowed set (a tag url can name an │ │161 + // the named issue must be in its windowed set (a tag url can name an │ │162 // issue that doesn't carry the tag). │ │143 - if (!try issue_id_set.contains(&order_key)) return error.NotFound; │ │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. │ │146 - const rank = try issue_id_set.rank(&order_key); │ │256 + const rank = try set.rank(key); │ │257 if (rank > 0) { │ │258 const prev_rank = rank -| page_size; │ │149 - const kv = try issue_id_set.getIndexKeyValuePair(@intCast(prev_rank)) orelse return error.NotFound; │ │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 } │ │155 - │ │156 - break :blk try issue_id_set.iteratorFrom(&order_key); │ │157 - }; │ │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 │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │281 next_id = try aa.dupe(u8, &id_hex); │ │282 break; │ │283 } │ │175 - const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, order_key[@sizeOf(u64)..])) orelse continue;│ │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), │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │179 - .issue = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map), │ │288 + .issue = try evt.read(evt.Issue, DB, hash_kind, arena, issue_map), │ │289 }); │ │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 - } │ │290 } │ │291 │ │292 return .{ │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────╮ │197 - .identity = empty.identity, │ │198 - .tag = empty.tag, │ │199 - .selected_id = empty.selected_id,│ │293 .issues = issues.items, │ │294 .prev_id = prev_id, │ │295 .next_id = next_id, │ ╰──────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │203 - .count = @intCast(try issue_id_set.count()),│ │204 - .tags = tags.items, │ │296 + .count = @intCast(try set.count()), │ │297 }; │ │298 } │ │299 │ ╰─────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │300 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. │ │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, │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │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, │ │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; │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯