╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────╮ │ 9 const Comment = @import("Comment.zig"); │ │10 const Attachment = @import("Attachment.zig"); │ │11 const thread = ui.widget.thread; │ │12 +const srch_thrd = @import("../../search_thread.zig");│ │13 │ │14 const wasm = builtin.target.cpu.arch == .wasm32; │ │15 │ ╰─────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │81 │ │82 // "owner:name", so the view can build /owner:name/issues/... links. │ │83 identity: []const u8, │ │83 -// the url-encoded label the lists are filtered to ("" = unfiltered). │ │84 -label: []const u8, │ │85 -// the url-encoded name or email the lists are filtered to, as typed ("" = unfiltered).│ │86 -assignee: []const u8, │ │87 -// false when the assignee names no user or no assigned issue, which lists nothing │ │88 -assignee_found: bool = true, │ │84 // the decoded query the lists are filtered to, or null when unfiltered. │ │85 search: ?[]const u8 = null, │ │86 +// whether an assignee term names no user, which lists nothing │ │87 +assignee_unknown: bool = false, │ │88 // the hex event id of the issue its status's window is rooted at ("" = the │ │89 // first window), mirrored into the url. │ │90 selected_id: []const u8, │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │128 return event.status; │ │129 } │ │130 │ │134 -pub fn listRoute(identity: []const u8, status: Status, label: []const u8, selected: []const u8) ?ui.RoutablePage {│ │135 - return ui.RoutablePage.repoIssuesRoute(identity, status, label, selected); │ │131 +pub fn listRoute(identity: []const u8, status: Status, selected: []const u8) ?ui.RoutablePage { │ │132 + return ui.RoutablePage.repoIssuesRoute(identity, status, selected); │ │133 } │ │134 │ │135 pub fn conflictsRoute(identity: []const u8, selected: []const u8) ?ui.RoutablePage { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │170 } │ │171 │ │172 // an empty listing, for the wasm / no-repo paths. │ │176 -pub fn emptyResult(aa: std.mem.Allocator, identity: []const u8, label: []const u8, assignee: []const u8, search: []const u8, selected_id: []const u8, comment_id: []const u8, comments_start: usize, theirs_picks: []const u8, view: ui.RoutablePage.IssuesView) !Self {│ │173 +pub fn emptyResult(aa: std.mem.Allocator, identity: []const u8, search: []const u8, selected_id: []const u8, comment_id: []const u8, comments_start: usize, theirs_picks: []const u8, view: ui.RoutablePage.IssuesView) !Self { │ │174 return .{ │ │175 .identity = try aa.dupe(u8, identity), │ │179 - .label = try aa.dupe(u8, label), │ │180 - .assignee = try aa.dupe(u8, assignee), │ │176 .search = if (search.len == 0) null else std.Uri.percentDecodeInPlace(try aa.dupe(u8, search)), │ │177 .selected_id = try aa.dupe(u8, selected_id), │ │178 .comment_id = try aa.dupe(u8, comment_id), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │193 }; │ │194 } │ │195 │ │201 -// read one window per status of an opened repo's issues (filtered to `label` │ │202 -// and `assignee` when set), ordered by creation (newest first). the window of │ │196 +// read one window per status of an opened repo's issues (filtered to the │ │197 +// search when set), ordered by creation (newest first). the window of │ │198 // the issue `selected_id` names starts at it ("" = the beginning). a git repo reads the│ │199 // event db next to it (synced from the events branch on each page build); a │ │200 // xit repo reads its own db. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │208 // in local mode, which has no users) │ │209 admin_moment: ?evt.AdminDB.HashMap(.read_only),│ │210 identity: []const u8, │ │216 - label: []const u8, │ │217 - assignee: []const u8, │ │211 search: []const u8, │ │212 selected_id: []const u8, │ │213 comment_id: []const u8, │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │216 view: ui.RoutablePage.IssuesView, │ │217 viewer: ?ui.Viewer, │ │218 ) !Self { │ │226 - var empty = try emptyResult(arena.allocator(), identity, label, assignee, search, selected_id, comment_id, comments_start, theirs_picks, view);│ │219 + var empty = try emptyResult(arena.allocator(), identity, search, selected_id, comment_id, comments_start, theirs_picks, view); │ │220 // the early returns below still let the viewer write │ │221 empty.viewer = viewer; │ │222 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │223 const aa = arena.allocator(); │ │224 const DB = evt.EventDB(repo_opts.hash); │ │225 + // an explicitly named issue that doesn't exist is a bad url (NotFound ->│ │226 + // 404); the bare route falls through to an empty listing. │ │227 const rooted = empty.selected_id.len != 0; │ │233 - const labeled = empty.label.len != 0; │ │234 - // an explicitly named issue or label that doesn't exist is a bad url │ │235 - // (NotFound -> 404); the bare route falls through to an empty listing. │ │236 - const strict = rooted or labeled; │ │228 │ │229 // a repo with no consumed events has no moment yet. │ │230 const gpa = arena.child_allocator; │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │236 return empty │ │237 else │ │238 evt.currentMoment(repo_opts, repo)) catch {│ │248 - if (strict) return error.NotFound; │ │239 + if (rooted) return error.NotFound; │ │240 return empty; │ │241 }; │ │242 │ ╰────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │252 - // the sorted sets to window, per status: the label's sets when filtered, │ │253 - // else the top-level per-status sets. a missing set is an empty window. │ │254 - var open_set: ?DB.SortedSet(.read_only) = null; │ │255 - var closed_set: ?DB.SortedSet(.read_only) = null; │ │256 - if (labeled) { │ │257 - const label_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.label_status_to_id_set_key)) orelse return error.NotFound; │ │258 - const label_to_issues = try DB.SortedMap(.read_only).init(label_to_issues_cursor); │ │259 - const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.label)); │ │260 - open_set = try thread.labelStatusSet(Self, DB, label_to_issues, decoded, .open); │ │261 - closed_set = try thread.labelStatusSet(Self, DB, label_to_issues, decoded, .closed); │ │262 - // a label no issue carries is a bad url │ │263 - if (open_set == null and closed_set == null) return error.NotFound; │ │264 - } else if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.status_to_id_set_key))) |status_to_issues_cursor| { │ │265 - const status_to_issues = try DB.SortedMap(.read_only).init(status_to_issues_cursor); │ │266 - open_set = try thread.statusSet(Self, DB, status_to_issues, .open); │ │267 - closed_set = try thread.statusSet(Self, DB, status_to_issues, .closed); │ │268 - } else if (rooted) return error.NotFound; │ │243 + const filters = try srch_thrd.parseFilters(aa, empty.search orelse ""); │ │244 │ │270 - // the assignee's issues, intersected with each status's set when windowed. │ │245 // a name resolves to its user's email. an unknown name or an email no │ │272 - // issue carries lists nothing and marks the box, like the assign input. │ │273 - var assignee_set: ?DB.SortedSet(.read_only) = null; │ │274 - const assignee_found = empty.assignee.len == 0 or found: { │ │275 - const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.assignee)); │ │276 - const email = switch (try ui.assigneeEmail(admin_moment, arena, decoded)) { │ │277 - .email => |email| email, │ │278 - .failure => break :found false, │ │279 - }; │ │280 - const by_assignee_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Assignment.assignee_to_issue_id_set_key)) orelse break :found false; │ │281 - const by_assignee = try DB.HashMap(.read_only).init(by_assignee_cursor); │ │282 - const set_cursor = try by_assignee.getCursor(hash.hashInt(repo_opts.hash, email)) orelse break :found false; │ │283 - assignee_set = try DB.SortedSet(.read_only).init(set_cursor); │ │284 - break :found true; │ │285 - }; │ │286 - if (!assignee_found) { │ │287 - open_set = null; │ │288 - closed_set = null; │ │246 + // issue carries lists nothing. │ │247 + var assignee_sets: ?[]const DB.SortedSet(.read_only) = &.{}; │ │248 + if (filters.assignees.len != 0) { │ │249 + const by_assignee: ?DB.HashMap(.read_only) = if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Assignment.assignee_to_issue_id_set_key))) |cursor| try DB.HashMap(.read_only).init(cursor) else null;│ │250 + var sets: std.ArrayList(DB.SortedSet(.read_only)) = .empty; │ │251 + for (filters.assignees) |assignee| { │ │252 + const email = switch (try ui.assigneeEmail(admin_moment, arena, assignee)) { │ │253 + .email => |email| email, │ │254 + .failure => { │ │255 + empty.assignee_unknown = true; │ │256 + continue; │ │257 + }, │ │258 + }; │ │259 + const set_cursor = (if (by_assignee) |map| try map.getCursor(hash.hashInt(repo_opts.hash, email)) else null) orelse continue; │ │260 + try sets.append(aa, try DB.SortedSet(.read_only).init(set_cursor)); │ │261 + } │ │262 + assignee_sets = if (sets.items.len == filters.assignees.len) sets.items else null; │ │263 } │ │264 + │ │265 + // each status's listing intersects every filter's set │ │266 + const open_sets = try thread.statusSets(Self, repo_opts.hash, aa, haxy_moment, filters.labels, assignee_sets, .open); │ │267 + const closed_sets = try thread.statusSets(Self, repo_opts.hash, aa, haxy_moment, filters.labels, assignee_sets, .closed); │ │268 │ │269 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.record_map_key)) orelse { │ │292 - if (strict) return error.NotFound; │ │270 + if (rooted) return error.NotFound; │ │271 return empty; │ │272 }; │ │273 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯