╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────╮ │src/search.zig│ ╰──────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │93 // one indexed word's doc keys, positioned at the current one.│ │94 const Posting = struct { │ │95 set: DB.SortedSet(.read_only), │ │96 + iter: DB.Cursor(.read_only).Iter = undefined, │ │97 key: [max_doc_key_len]u8 = undefined, │ │98 key_len: usize = 0, │ │99 done: bool = false, │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │100 started: bool = false, │ │101 │ │101 - // move to the first key at or after `key`. seeking rather than │ │102 - // stepping keeps the cost proportional to the results. │ │102 + // move to the first key at or after `key`. one step reaches it in │ │103 + // a dense posting, and a seek skips the rest of a sparse one. │ │104 fn advance(self: *Posting, key: []const u8) !void { │ │105 if (self.done) return; │ │105 - if (self.started and std.mem.order(u8, self.key[0..self.key_len], key) != .lt) return;│ │106 - var iter = try self.set.iteratorFrom(key); │ │106 + if (self.started) { │ │107 + if (std.mem.order(u8, self.key[0..self.key_len], key) != .lt) return; │ │108 + try self.step(); │ │109 + if (self.done or std.mem.order(u8, self.key[0..self.key_len], key) != .lt) return;│ │110 + } │ │111 + self.iter = try self.set.iteratorFrom(key); │ │112 self.started = true; │ │108 - const cursor = (try iter.next()) orelse { │ │113 + try self.step(); │ │114 + } │ │115 + │ │116 + fn step(self: *Posting) !void { │ │117 + const cursor = (try self.iter.next()) orelse { │ │118 self.done = true; │ │119 return; │ │120 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │141 try terms.append(aa, .{ .postings = postings.items }); │ │142 } │ │143 return .{ .terms = terms.items }; │ │144 + } │ │145 + │ │146 + // narrow the results to the doc keys in `set`. it is one more term, │ │147 + // with that set as its only posting. a query nothing can match stays │ │148 + // empty, since requiring a set would turn it into every doc key in it. │ │149 + pub fn require(self: *Self, aa: std.mem.Allocator, set: DB.SortedSet(.read_only)) !void {│ │150 + if (self.terms.len == 0) return; │ │151 + var terms: std.ArrayList(Term) = .empty; │ │152 + try terms.appendSlice(aa, self.terms); │ │153 + const postings = try aa.alloc(Posting, 1); │ │154 + postings[0] = .{ .set = set }; │ │155 + try terms.append(aa, .{ .postings = postings }); │ │156 + self.terms = terms.items; │ │157 } │ │158 │ │159 // start the next result at `key` rather than the first one. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/test/ui.zig│ ╰───────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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); │ │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(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │21 }; │ │22 try root.build(allocator, constraint, focus); │ │23 │ │24 - // switch to the preloaded new tab without recreating the page │ │25 - for ([_]xit.xitui.input.Key{ .arrow_right, .arrow_right, .arrow_right, .arrow_down }) |key| { │ │24 + // switch to the preloaded new tab without recreating the page (the │ │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│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │132 name: Array(repo_route_max_len), │ │133 // the tag the list is filtered to, url-encoded ("" = unfiltered). │ │134 tag: Array(issue_tag_route_max_len) = .{}, │ │135 + // the query the list is filtered to, url-encoded ("" = unfiltered). │ │136 + search: Array(search_route_max_len) = .{}, │ │137 // the issue the route names: the list's window root, or the issue the│ │138 // edit/description/resolve view shows. │ │139 selected: Array(evt.event_id_size * 2) = .{}, │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │151 repo_patches: struct { │ │152 name: Array(repo_route_max_len), │ │153 tag: Array(patch_tag_route_max_len) = .{}, │ │154 + search: Array(search_route_max_len) = .{}, │ │155 selected: Array(evt.event_id_size * 2) = .{},│ │156 theirs: Array(theirs_route_max_len) = .{}, │ │157 view: PatchesView = .open, │ ╰──────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │161 repo_discussions: struct { │ │162 name: Array(repo_route_max_len), │ │163 tag: Array(discussion_tag_route_max_len) = .{},│ │164 + search: Array(search_route_max_len) = .{}, │ │165 selected: Array(evt.event_id_size * 2) = .{}, │ │166 view: DiscussionsView = .recent, │ │167 comments_start: usize = 0, │ ╰────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │555 return route; │ │556 } │ │557 │ │554 - // carry a search query on a commits route, url-encoding it into the route. │ │558 + // carry a search query on a commits or thread-list route, url-encoding it │ │559 + // into the route. │ │560 pub fn withSearch(self: RoutablePage, query: []const u8) ?RoutablePage { │ │561 const search = encodeParam(search_route_max_len, query) orelse return null; │ │557 - var route = self; │ │562 + return withEncodedSearch(self, search.slice()); │ │563 + } │ │564 + │ │565 + // the tag and search segments of a thread list url, either one optional │ │566 + fn writeListFilters(writer: *std.Io.Writer, tag: []const u8, search: []const u8) !void { │ │567 + if (tag.len != 0) try writer.print("/" ++ tag_filter_seg ++ "{s}", .{tag}); │ │568 + if (search.len != 0) try writer.print("/" ++ search_seg ++ "{s}", .{search}); │ │569 + } │ │570 + │ │571 + // carry the already-encoded list filters a selected thread's url names │ │572 + fn withEncodedFilters(self: ?RoutablePage, tag: []const u8, search: []const u8) ?RoutablePage { │ │573 + var route = withEncodedSearch(self, search) orelse return null; │ │574 + switch (route) { │ │575 + inline .repo_issues, .repo_patches, .repo_discussions => |*thread| thread.tag = @TypeOf(thread.tag).from(tag) orelse return null,│ │576 + else => return null, │ │577 + } │ │578 + return route; │ │579 + } │ │580 + │ │581 + // carry an already-encoded search param, as a url hands it over │ │582 + fn withEncodedSearch(self: ?RoutablePage, value: []const u8) ?RoutablePage { │ │583 + var route = self orelse return null; │ │584 + const search = Array(search_route_max_len).from(value) orelse return null; │ │585 switch (route) { │ │586 .repo_commits => |*c| c.search = search, │ │587 + .repo_issues => |*i| i.search = search, │ │588 + .repo_patches => |*p| p.search = search, │ │589 + .repo_discussions => |*d| d.search = search, │ │590 else => return null, │ │591 } │ │592 return route; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1054 try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/" ++ comment_seg ++ "{s}", .{ prefix, i.selected.slice(), i.comment.slice() }) │ │1055 else │ │1056 try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/" ++ comment_seg ++ "{s}/" ++ start_seg ++ "{d}", .{ prefix, i.selected.slice(), i.comment.slice(), i.comments_start });│ │1027 - if (i.selected.len != 0) break :blk if (i.comments_start == 0) │ │1028 - try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}", .{ prefix, i.selected.slice() }) │ │1029 - else │ │1030 - try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/" ++ start_seg ++ "{d}", .{ prefix, i.selected.slice(), i.comments_start }); │ │1031 - break :blk if (i.tag.len == 0) │ │1032 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, @tagName(i.view) }) │ │1033 - else │ │1034 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/" ++ tag_filter_seg ++ "{s}", .{ prefix, @tagName(i.view), i.tag.slice() }); │ │1057 + var out: std.Io.Writer.Allocating = .init(arena.allocator()); │ │1058 + // the list the thread is selected in stays filtered, so paging │ │1059 + // and reloading keep the tag and the query │ │1060 + if (i.selected.len != 0) { │ │1061 + try out.writer.print("{s}/" ++ issue_seg ++ "{s}", .{ prefix, i.selected.slice() }); │ │1062 + try writeListFilters(&out.writer, i.tag.slice(), i.search.slice()); │ │1063 + if (i.comments_start != 0) try out.writer.print("/" ++ start_seg ++ "{d}", .{i.comments_start}); │ │1064 + break :blk out.written(); │ │1065 + } │ │1066 + try out.writer.print("{s}/issues/{s}", .{ prefix, @tagName(i.view) }); │ │1067 + try writeListFilters(&out.writer, i.tag.slice(), i.search.slice()); │ │1068 + break :blk out.written(); │ │1069 }, │ │1070 .repo_patches => |p| blk: { │ │1071 const prefix = try repoUrlPrefix(arena, p.name.slice()); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯