╭ changed ────────────╮ │src/search_thread.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │41 try srch.replace(DB, index, allocator, key, old_text, new_text); │ │42 } │ │43 │ │44 -// the doc keys matching `query_text`, in key order, narrowed to each non-null│ │45 -// set in `filters`, which must be keyed the same way │ │44 +// the doc keys matching `query_text`, in key order │ │45 pub fn query( │ │46 comptime DB: type, │ │47 comptime hash_kind: hash.HashKind, │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 49 haxy_moment: DB.HashMap(.read_only), │ │ 50 aa: std.mem.Allocator, │ │ 51 query_text: []const u8, │ │ 53 - filters: []const ?DB.SortedSet(.read_only), │ │ 52 ) !srch.Query(DB) { │ │ 55 - // no index means no words match, which one empty term expresses │ │ 56 - var results: srch.Query(DB) = .{ .terms = .empty }; │ │ 57 - if (try haxy_moment.getCursor(hash.hashInt(hash_kind, indexKey(kind)))) |cursor| { │ │ 58 - results = try srch.Query(DB).init(try DB.SortedMap(.read_only).init(cursor), aa, query_text); │ │ 59 - } else { │ │ 53 + const cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, indexKey(kind))) orelse { │ │ 54 + // no index means no words match, which one empty term expresses │ │ 55 + var results: srch.Query(DB) = .{ .terms = .empty }; │ │ 56 try results.terms.append(aa, .{ .postings = &.{} }); │ │ 57 + return results; │ │ 58 + }; │ │ 59 + return srch.Query(DB).init(try DB.SortedMap(.read_only).init(cursor), aa, query_text); │ │ 60 +} │ │ 61 + │ │ 62 +pub const label_prefix = "label:"; │ │ 63 +pub const assignee_prefix = "assignee:"; │ │ 64 + │ │ 65 +// a thread search split into its filter terms and the words left for the index │ │ 66 +pub const Filters = struct { │ │ 67 + labels: []const []const u8, │ │ 68 + assignees: []const []const u8, │ │ 69 + // the remaining terms joined by single spaces, "" when none │ │ 70 + words: []const u8, │ │ 71 +}; │ │ 72 + │ │ 73 +// split `search` on whitespace into label and assignee terms and plain words. │ │ 74 +// a prefix with no value is dropped. │ │ 75 +pub fn parseFilters(aa: std.mem.Allocator, search: []const u8) !Filters { │ │ 76 + var labels: std.ArrayList([]const u8) = .empty; │ │ 77 + var assignees: std.ArrayList([]const u8) = .empty; │ │ 78 + var words: std.ArrayList([]const u8) = .empty; │ │ 79 + var iter = std.mem.tokenizeAny(u8, search, &std.ascii.whitespace); │ │ 80 + while (iter.next()) |term| { │ │ 81 + if (std.mem.startsWith(u8, term, label_prefix)) { │ │ 82 + if (term.len > label_prefix.len) try labels.append(aa, term[label_prefix.len..]); │ │ 83 + } else if (std.mem.startsWith(u8, term, assignee_prefix)) { │ │ 84 + if (term.len > assignee_prefix.len) try assignees.append(aa, term[assignee_prefix.len..]); │ │ 85 + } else try words.append(aa, term); │ │ 86 } │ │ 62 - for (filters) |filter| if (filter) |set| try results.require(aa, set); │ │ 63 - return results; │ │ 87 + return .{ .labels = labels.items, .assignees = assignees.items, .words = try std.mem.join(aa, " ", words.items) };│ │ 88 +} │ │ 89 + │ │ 90 +// `search` with its `prefix` terms replaced by one for `value`, put first │ │ 91 +pub fn withFilter(aa: std.mem.Allocator, search: []const u8, prefix: []const u8, value: []const u8) ![]const u8 { │ │ 92 + var terms: std.ArrayList([]const u8) = .empty; │ │ 93 + try terms.append(aa, try std.mem.concat(aa, u8, &.{ prefix, value })); │ │ 94 + var iter = std.mem.tokenizeAny(u8, search, &std.ascii.whitespace); │ │ 95 + while (iter.next()) |term| { │ │ 96 + if (!std.mem.startsWith(u8, term, prefix)) try terms.append(aa, term); │ │ 97 + } │ │ 98 + return std.mem.join(aa, " ", terms.items); │ │ 99 } │ │100 │ │101 // the text a thread is indexed under, none once it is removed: the title and │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/test/ui.zig│ ╰───────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │10 var arena = std.heap.ArenaAllocator.init(allocator); │ │11 defer arena.deinit(); │ │12 var session = ui.Session{ .arena = &arena, .page_arena = &arena, .is_terminal = true, .data = .{ .host_kind = .local } };│ │13 - session.data.current_page = ui.RoutablePage.repoIssuesRoute("", .open, "", "") orelse return error.BadRoute; │ │14 - const data = try ui.Repo.Issues.emptyResult(arena.allocator(), "", "", "", "", "", "", 0, "", .open); │ │13 + session.data.current_page = ui.RoutablePage.repoIssuesRoute("", .open, "") orelse return error.BadRoute; │ │14 + const data = try ui.Repo.Issues.emptyResult(arena.allocator(), "", "", "", "", 0, "", .open); │ │15 var root = ui.Widget{ .repo_issues = try ui.Repo.Issues.View.init(allocator, &data, &session) }; │ │16 defer root.deinit(allocator); │ │17 const focus = root.getFocus(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │22 try root.build(allocator, constraint, focus); │ │23 │ │24 // switch to the preloaded new tab without recreating the page (the │ │25 - // sub-header starts on the search box, left of the assignee box and the tabs) │ │26 - for ([_]xit.xitui.input.Key{ .arrow_right, .arrow_right, .arrow_right, .arrow_right, .arrow_right, .arrow_down }) |key| {│ │25 + // sub-header starts on the search box, left of the tabs) │ │26 + for ([_]xit.xitui.input.Key{ .arrow_right, .arrow_right, .arrow_right, .arrow_right, .arrow_down }) |key| { │ │27 try ui.inputKey(allocator, &root, key, &session); │ │28 try root.build(allocator, constraint, focus); │ │29 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │154 }, │ │155 repo_issues: struct { │ │156 name: Array(repo_route_max_len), │ │157 - // the label the list is filtered to, url-encoded ("" = unfiltered). │ │158 - label: Array(issue_label_route_max_len) = .{}, │ │159 - // the assignee the list is filtered to, a url-encoded name or email as│ │160 - // typed ("" = unfiltered). │ │161 - assignee: Array(assignee_route_max_len) = .{}, │ │157 // the query the list is filtered to, url-encoded ("" = unfiltered). │ │158 search: Array(search_route_max_len) = .{}, │ │159 // the issue the route names: the list's window root, or the issue the │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────╮ │172 }, │ │173 repo_patches: struct { │ │174 name: Array(repo_route_max_len), │ │180 - label: Array(patch_label_route_max_len) = .{},│ │175 search: Array(search_route_max_len) = .{}, │ │176 selected: Array(evt.event_id_size * 2) = .{}, │ │177 theirs: Array(theirs_route_max_len) = .{}, │ ╰───────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────╮ │181 }, │ │182 repo_discussions: struct { │ │183 name: Array(repo_route_max_len), │ │190 - label: Array(discussion_label_route_max_len) = .{},│ │184 search: Array(search_route_max_len) = .{}, │ │185 selected: Array(evt.event_id_size * 2) = .{}, │ │186 view: DiscussionsView = .recent, │ ╰────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │231 │ │232 // the "key:value" segments of a url tail, collected in any order │ │233 const Params = struct { │ │241 - const ParamKey = enum { start, line, branch, tag, label, assignee, object, base, patchrev, theirs, find, search, from, moment, merge, user };│ │234 + const ParamKey = enum { start, line, branch, tag, object, base, patchrev, theirs, find, search, from, moment, merge, user }; │ │235 │ │236 // a branch:/tag:/object: param; the value stays url-encoded │ │237 const RefParam = struct { kind: RefOrOid, value: []const u8 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │316 // ends a commits route naming its commit's full message │ │317 const message_seg = "message"; │ │318 // the `Params` key spellings the url emitters use │ │326 - const label_filter_seg = @tagName(Params.ParamKey.label) ++ ":";│ │327 - const assignee_seg = @tagName(Params.ParamKey.assignee) ++ ":"; │ │319 const start_seg = @tagName(Params.ParamKey.start) ++ ":"; │ │320 const line_seg = @tagName(Params.ParamKey.line) ++ ":"; │ │321 const find_seg = @tagName(Params.ParamKey.find) ++ ":"; │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │345 fn identityElided(s: []const u8) bool { │ │346 return s.len == 0 or s[0] == '/'; │ │347 } │ │357 - │ │358 - // a url-encoded label can grow to three bytes per source byte. │ │359 - pub const issue_label_route_max_len = evt.Issue.label_max_len * 3; │ │360 - pub const patch_label_route_max_len = evt.Patch.label_max_len * 3; │ │361 - pub const discussion_label_route_max_len = evt.Discussion.label_max_len * 3;│ │348 │ │363 - // a url-encoded email can grow to three bytes per source byte. │ │364 - pub const assignee_route_max_len = evt.Assignment.email_max_len * 3; │ │365 - │ │349 // caps the resolve view's theirs: field list (a longer one doesn't route). │ │350 pub const theirs_route_max_len = 512; │ │351 │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯