╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │301 if (!try set.contains(order_key)) return error.NotFound; │ │302 conflicts_root = order_key; │ │303 } else { │ │326 - // the named issue must be in its windowed set (a label url can name │ │327 - // an issue that doesn't carry the label). │ │328 - const set = (switch (issue_event.event.status) { │ │329 - .open => open_set, │ │330 - .closed => closed_set, │ │331 - }) orelse return error.NotFound; │ │332 - if (!try set.contains(order_key)) return error.NotFound; │ │333 - if (assignee_set) |assignee_issues| if (!try assignee_issues.contains(order_key)) return error.NotFound;│ │304 + // the named issue must be in every set its window intersects (a │ │305 + // filtered url can name an issue the filters leave out). │ │306 + const sets = switch (issue_event.event.status) { │ │307 + .open => open_sets, │ │308 + .closed => closed_sets, │ │309 + }; │ │310 + if (!try thread.inEverySet(DB, sets, order_key)) return error.NotFound; │ │311 │ │312 switch (issue_event.event.status) { │ │313 .open => open_root = order_key, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │338 } │ │339 │ │340 const thread_comments_start = if (empty.comment_id.len == 0) comments_start else 0; │ │364 - const open_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, open_set, open_root, conflict_set, empty.selected_id, thread_comments_start, empty.search, assignee_set, viewer); │ │365 - const closed_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, closed_set, closed_root, conflict_set, empty.selected_id, thread_comments_start, empty.search, assignee_set, viewer); │ │366 - const conflicts_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, conflict_set, conflicts_root, conflict_set, empty.selected_id, thread_comments_start, null, null, viewer); │ │341 + const open_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, open_sets, open_root, conflict_set, empty.selected_id, thread_comments_start, filters.words, viewer); │ │342 + const closed_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, closed_sets, closed_root, conflict_set, empty.selected_id, thread_comments_start, filters.words, viewer); │ │343 + const conflicts_window = try thread.loadWindow(Self, .issue, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, if (conflict_set) |*set| set[0..1] else &.{}, conflicts_root, conflict_set, empty.selected_id, thread_comments_start, "", viewer);│ │344 if (view == .conflicts and conflicts_window.count > 0) resolved_view = .conflicts; │ │345 │ │346 const comment_page = if (empty.comment_id.len == 0) │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │352 │ │353 return .{ │ │354 .identity = empty.identity, │ │378 - .label = empty.label, │ │379 - .assignee = empty.assignee, │ │380 - .assignee_found = assignee_found, │ │355 .search = empty.search, │ │356 + .assignee_unknown = empty.assignee_unknown,│ │357 .selected_id = empty.selected_id, │ │358 .comment_id = empty.comment_id, │ │359 .comments_start = comments_start, │ ╰────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │388 │ │389 // tabs switching between the issues page's views │ │390 pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { │ │416 - var header = try Header.init(allocator, session, data.search, .{ │ │417 - .label = if (data.assignee_found) " assignee " else " assignee (not found) ", │ │418 - .text = std.Uri.percentDecodeInPlace(try session.page_arena.allocator().dupe(u8, data.assignee)), │ │419 - }); │ │391 + var header = try Header.init(allocator, session, data.search, if (data.assignee_unknown) " search (assignee not found) " else " search ");│ │392 errdefer header.deinit(allocator); │ │393 const selected_index = View.viewIndex(data.view); │ │394 const page_selected = std.meta.activeTag(session.data.current_page) == .repo_issues; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │396 // a list tab per status, labeled with its listing's issue count │ │397 const status_labels = [_][]const u8{ open_tab_label, closed_tab_label }; │ │398 for ([_]evt.Issue.Status{ .open, .closed }, status_labels, 0..) |status, status_label, index| { │ │427 - const route = try thread.searchRoute(ui.RoutablePage.withEncodedAssignee(ui.RoutablePage.repoIssuesRoute(data.identity, status, data.label, ""), data.assignee), data.search);│ │399 + const route = try thread.searchRoute(ui.RoutablePage.repoIssuesRoute(data.identity, status, ""), data.search); │ │400 const link = try ui.inPageTabLink(session, route, page_selected and selected_index == index); │ │401 var label_buf: [64]u8 = undefined; │ │402 const label = try thread.countLabel(&label_buf, status_label, data.window(status).*); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │405 │ │406 // labels tab │ │407 { │ │436 - const labels_route = try thread.searchRoute(ui.RoutablePage.withEncodedAssignee(ui.RoutablePage.repoThreadLabelsRoute(.issue, data.identity, data.label), data.assignee), data.search);│ │408 + const labels_route = try thread.searchRoute(ui.RoutablePage.repoThreadLabelsRoute(.issue, data.identity), data.search); │ │409 const labels_link = try ui.inPageTabLink(session, labels_route, page_selected and selected_index == View.viewIndex(.labels)); │ │410 try header.addTab(allocator, labels_tab_label, labels_link, View.viewIndex(.labels)); │ │411 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Patches.zig│ ╰───────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │11 const Comment = @import("Comment.zig"); │ │12 const Attachment = @import("Attachment.zig"); │ │13 const thread = ui.widget.thread; │ │14 +const srch_thrd = @import("../../search_thread.zig");│ │15 const fork = @import("../../fork.zig"); │ │16 const pch = @import("../../patch.zig"); │ │17 │ ╰─────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │212 // "owner:name", so the view can build /owner:name/patches/... links. │ │213 identity: []const u8, │ │214 default_target_branch: []const u8 = "", │ │214 -// the url-encoded label the lists are filtered to ("" = unfiltered). │ │215 -label: []const u8, │ │215 // the decoded query the lists are filtered to, or null when unfiltered. │ │216 search: ?[]const u8 = null, │ │217 // the hex event id of the patch its status's window is rooted at ("" = the│ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │260 return event.status.kind(); │ │261 } │ │262 │ │264 -pub fn listRoute(identity: []const u8, status: Status, label: []const u8, selected: []const u8) ?ui.RoutablePage {│ │265 - return ui.RoutablePage.repoPatchesRoute(identity, status, label, selected); │ │263 +pub fn listRoute(identity: []const u8, status: Status, selected: []const u8) ?ui.RoutablePage { │ │264 + return ui.RoutablePage.repoPatchesRoute(identity, status, selected); │ │265 } │ │266 │ │267 // a fork route's identity names the forker and the target repo │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯