╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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, │ │30 -// the url-encoded tag the list is filtered to ("" = unfiltered). │ │45 +// the url-encoded tag the lists are filtered to ("" = unfiltered). │ │46 tag: []const u8, │ │32 -// the hex event id of the issue this window is rooted at ("" = the first │ │33 -// window), mirrored into the url. │ │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, │ │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, │ │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. │ │49 -pub fn emptyResult(aa: std.mem.Allocator, identity: []const u8, tag: []const u8, selected_id: []const u8) !Self { │ │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),│ │54 - .issues = &.{}, │ │55 - .prev_id = null, │ │56 - .next_id = null, │ │57 - .count = 0, │ │73 + .open = .empty, │ │74 + .closed = .empty, │ │75 + .view = view, │ │76 .tags = &.{}, │ │77 }; │ │78 } │ ╰────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │79 │ │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. │ │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), │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │ 92 identity: []const u8, │ │ 93 tag: []const u8, │ │ 94 selected_id: []const u8, │ │ 95 + view: ui.RoutablePage.IssuesView, │ │ 96 ) !Self { │ │ 78 - const empty = try emptyResult(arena.allocator(), identity, tag, selected_id); │ │ 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); │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯