╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │268 admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │269 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │270 records: evt.EventDB(hash_kind).HashMap(.read_only), │ │234 - set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │271 + // the sets the listing intersects, all keyed alike; none lists nothing│ │272 + sets: []const evt.EventDB(hash_kind).SortedSet(.read_only), │ │273 root_key: ?[]const u8, │ │274 conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │275 selected_id: []const u8, │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │276 comments_start: usize, │ │239 - // the search the set is narrowed to, or null to list it whole │ │240 - query: ?[]const u8, │ │241 - // a second set the listing is narrowed to, keyed like `set_maybe` │ │242 - extra_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │277 + // the words the listing is narrowed to, "" for none │ │278 + words: []const u8, │ │279 viewer: ?ui.Viewer, │ │280 ) !Data.Window { │ │245 - if (query != null or extra_set != null) return loadSearchWindow(Data, kind, hash_kind, arena, admin_moment, haxy_moment, records, set_maybe, root_key, conflict_set, selected_id, comments_start, query orelse "", extra_set, viewer);│ │246 - const set = set_maybe orelse return .empty; │ │281 + if (sets.len == 0) return .empty; │ │282 + if (sets.len > 1 or words.len != 0) return loadSearchWindow(Data, kind, hash_kind, arena, admin_moment, haxy_moment, records, sets, root_key, conflict_set, selected_id, comments_start, words, viewer); │ │283 + const set = sets[0]; │ │284 const aa = arena.allocator(); │ │285 │ │286 var prev_id: ?[]const u8 = null; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │323 // how many matches one search counts before the count is marked as capped │ │324 const max_search_results = 1000; │ │325 │ │289 -// `set`'s entries matching `query_text` (any when empty) and held by │ │290 -// `extra_set` when set, windowed like `loadWindow`. one pass │ │291 -// yields the window, its neighbours' roots and the match count, which stops at│ │292 -// the cap. │ │326 +// the intersection of `sets` narrowed to `words`, windowed like `loadWindow`. │ │327 +// one pass yields the window, its neighbours' roots and the match count, which│ │328 +// stops at the cap. │ │329 fn loadSearchWindow( │ │330 comptime Data: type, │ │331 comptime kind: evt.EventKind, │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │334 admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │335 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │336 records: evt.EventDB(hash_kind).HashMap(.read_only), │ │301 - set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │337 + sets: []const evt.EventDB(hash_kind).SortedSet(.read_only), │ │338 root_key: ?[]const u8, │ │339 conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only),│ │340 selected_id: []const u8, │ ╰─────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │341 comments_start: usize, │ │306 - query_text: []const u8, │ │307 - extra_set: ?evt.EventDB(hash_kind).SortedSet(.read_only),│ │342 + words: []const u8, │ │343 viewer: ?ui.Viewer, │ │344 ) !Data.Window { │ │310 - const set = set_maybe orelse return .empty; │ │345 const DB = evt.EventDB(hash_kind); │ │346 const aa = arena.allocator(); │ │347 const root = root_key orelse ""; │ ╰──────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │352 var keys: std.ArrayList(OrderKey) = .empty; │ │353 if (comptime kind == .discuss) { │ │354 // discussions list by activity but are indexed by creation, so the │ │321 - // matching ids are gathered and the listed set is walked in its order │ │322 - // until every one has been found │ │323 - var matches: std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = .empty; │ │324 - var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, &.{extra_set}); │ │325 - while (try results.next()) |key| try matches.put(aa, (try resultKey(key))[@sizeOf(u64)..].*, {}); │ │355 + // word matches are gathered by id and the listed sets' intersection is │ │356 + // walked in its order until every one has been found │ │357 + // words too short to index narrow nothing, as on the other tabs │ │358 + var matches: ?std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = null; │ │359 + if (words.len != 0) { │ │360 + var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, words); │ │361 + if (results.terms.items.len != 0) { │ │362 + var found: std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = .empty; │ │363 + while (try results.next()) |key| try found.put(aa, (try resultKey(key))[@sizeOf(u64)..].*, {});│ │364 + matches = found; │ │365 + } │ │366 + } │ │367 │ │327 - var iter = try set.iteratorFromIndex(0); │ │328 - while (matches.count() > 0) { │ │329 - const pair = try ((try iter.next()) orelse break).readKeyValuePair(); │ │330 - var order_key: OrderKey = undefined; │ │331 - _ = try pair.key_cursor.readBytes(&order_key); │ │332 - if (!matches.remove(order_key[@sizeOf(u64)..].*)) continue; │ │368 + var listed: srch.Query(DB) = .{ .terms = .empty }; │ │369 + for (sets) |set| try listed.require(aa, set); │ │370 + while (true) { │ │371 + if (matches) |found| if (found.count() == 0) break; │ │372 + const order_key = try resultKey((try listed.next()) orelse break); │ │373 + if (matches) |*found| if (!found.remove(order_key[@sizeOf(u64)..].*)) continue; │ │374 try keys.append(aa, order_key); │ │375 if (windowKnown(Data, keys.items, root)) break; │ │376 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │377 } else { │ │337 - // issues and patches list in the index's own order, so the listed set │ │338 - // narrows the query and the results stream out already sorted │ │339 - var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, &.{ set, extra_set }); │ │378 + // issues and patches list in the index's own order, so the listed sets │ │379 + // narrow the query and the results stream out already sorted │ │380 + var results: srch.Query(DB) = if (words.len == 0) .{ .terms = .empty } else try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, words);│ │381 + for (sets) |set| try results.require(aa, set); │ │382 while (try results.next()) |key| { │ │383 try keys.append(aa, try resultKey(key)); │ │384 if (windowKnown(Data, keys.items, root)) break; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │472 assignee.* = .{ .email = email, .name = if (user) |u| u.name else null }; │ │473 } │ │474 return assignees; │ │433 -} │ │434 - │ │435 -// the url-encoded assignee filter a thread page carries, "" for kinds without one│ │436 -fn assigneeFilter(data: anytype) []const u8 { │ │437 - return if (@hasField(@TypeOf(data.*), "assignee")) data.assignee else ""; │ │475 } │ │476 │ │477 // a tab link's route, carrying the page's query when it has one │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │486 return std.fmt.bufPrint(buffer, "{s} ({d}{s})", .{ name, win.count, if (win.more) "+" else "" });│ │487 } │ │488 │ │452 -// the common sub-header used by each thread page: the search box, the │ │453 -// assignee box on the issues page, then the tab strip │ │489 +// the common sub-header used by each thread page: the search box, then the │ │490 +// tab strip │ │491 pub const Header = struct { │ │492 box: wgt.Box(Widget), │ │493 // focus id -> semantic stack index; some pages omit unavailable tabs │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │494 tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │458 - // the tab whose view the stack shows. focus lands on the boxes too, │ │495 + // the tab whose view the stack shows. focus lands on the search box too, │ │496 // so the selection is remembered rather than read off the focus chain. │ │497 selected_tab: ?usize = null, │ │461 - // how many text boxes come before the tabs │ │462 - box_count: usize, │ │498 │ │464 - // which box enter submitted from │ │465 - pub const Submission = struct { kind: enum { search, assignee }, text: []u8 }; │ │499 + // the search box's place in the box, ahead of the tabs │ │500 + const search_index: usize = 0; │ │501 │ │467 - // the assignee box's label and prefilled text │ │468 - pub const AssigneeBox = struct { label: []const u8, text: []const u8 }; │ │469 - │ │470 - // a null `assignee` leaves out the assignee box │ │471 - pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8, assignee: ?AssigneeBox) !Header { │ │502 + pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8, search_label: []const u8) !Header {│ │503 var box = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │504 errdefer box.deinit(allocator); │ │505 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯