╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────╮ │14 const Grid = xitui.grid.Grid; │ │15 const Focus = xitui.focus.Focus; │ │16 const inp = @import("../input.zig"); │ │17 +const srch = @import("../../search_thread.zig"); │ │18 const diff3 = @import("../../diff3.zig"); │ │19 const Comment = @import("../Repo/Comment.zig"); │ │20 const Attachment = @import("../Repo/Attachment.zig");│ ╰─────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │203 try tags.append(aa, tag); │ │204 } │ │205 return tags.items; │ │206 +} │ │207 + │ │208 +// the key a list set (and the search index) stores a thread under │ │209 +const OrderKey = [@sizeOf(u64) + evt.event_id_size]u8; │ │210 + │ │211 +// a search result's bytes as an order key │ │212 +fn resultKey(key: []const u8) !OrderKey { │ │213 + if (key.len != @sizeOf(u64) + evt.event_id_size) return error.InvalidSearchIndex;│ │214 + var order_key: OrderKey = undefined; │ │215 + @memcpy(&order_key, key); │ │216 + return order_key; │ │217 +} │ │218 + │ │219 +// the hex event id an order key ends with │ │220 +fn keyIdHex(order_key: *const OrderKey) [evt.event_id_size * 2]u8 { │ │221 + return std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); │ │222 } │ │223 │ │224 pub fn loadWindow( │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │225 comptime Data: type, │ │226 + comptime kind: evt.EventKind, │ │227 comptime hash_kind: hash.HashKind, │ │228 arena: *std.heap.ArenaAllocator, │ │229 admin_moment: ?evt.AdminDB.HashMap(.read_only),│ ╰────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │234 conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │235 selected_id: []const u8, │ │236 comments_start: usize, │ │237 + // the search the set is narrowed to, or null to list it whole │ │238 + query: ?[]const u8, │ │239 ) !Data.Window { │ │240 + if (query) |text| return loadSearchWindow(Data, kind, hash_kind, arena, admin_moment, haxy_moment, records, set_maybe, root_key, conflict_set, selected_id, comments_start, text);│ │241 const set = set_maybe orelse return .empty; │ │221 - const DB = evt.EventDB(hash_kind); │ │242 const aa = arena.allocator(); │ │243 │ │244 var prev_id: ?[]const u8 = null; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │248 prev_id = ""; │ │249 } else if (rank > Data.page_size) { │ │250 const pair = try set.getIndexKeyValuePair(@intCast(rank - Data.page_size)) orelse return error.NotFound;│ │231 - var previous: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │251 + var previous: OrderKey = undefined; │ │252 _ = try pair.key_cursor.readBytes(&previous); │ │233 - const hex = std.fmt.bytesToHex(previous[@sizeOf(u64)..].*, .lower); │ │234 - prev_id = try aa.dupe(u8, &hex); │ │253 + prev_id = try aa.dupe(u8, &keyIdHex(&previous)); │ │254 } │ │255 break :blk try set.iteratorFrom(key); │ │256 } else try set.iteratorFromIndex(0); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │260 while (try iter.next()) |cursor_value| { │ │261 var cursor = cursor_value; │ │262 const pair = try cursor.readKeyValuePair(); │ │244 - var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │263 + var order_key: OrderKey = undefined; │ │264 _ = try pair.key_cursor.readBytes(&order_key); │ │246 - const id = order_key[@sizeOf(u64)..]; │ │247 - const id_hex = std.fmt.bytesToHex(id.*, .lower); │ │265 if (items.items.len == Data.page_size) { │ │249 - next_id = try aa.dupe(u8, &id_hex); │ │266 + next_id = try aa.dupe(u8, &keyIdHex(&order_key)); │ │267 break; │ │268 } │ │252 - const record_cursor = try records.getCursor(hash.hashInt(hash_kind, id)) orelse continue; │ │253 - const record = try evt.read(Data.Event.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); │ │254 - try items.append(aa, .{ │ │255 - .id = try aa.dupe(u8, &id_hex), │ │256 - .record = record, │ │257 - .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), │ │258 - .conflicted = if (conflict_set) |conflicts| try conflicts.contains(&order_key) else false, │ │259 - .comments = try Comment.loadWindow( │ │260 - hash_kind, │ │261 - arena, │ │262 - admin_moment, │ │263 - haxy_moment, │ │264 - evt.Comment.thread_id_to_comment_id_set_key, │ │265 - &id_hex, │ │266 - if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │267 - ), │ │268 - .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │269 - }); │ │269 + const entry = (try loadEntry(Data, hash_kind, arena, admin_moment, haxy_moment, records, &order_key, conflict_set, selected_id, comments_start)) orelse continue;│ │270 + try items.append(aa, entry); │ │271 } │ │272 │ │273 return .{ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │278 }; │ │279 } │ │280 │ │280 -// the common tab strip used by each thread page's sub-header │ │281 +// how many matches one search counts before the count is marked as capped │ │282 +const max_search_results = 1000; │ │283 + │ │284 +// `set`'s entries matching `query_text`, windowed like `loadWindow`. one pass │ │285 +// yields the window, its neighbours' roots and the match count, which stops at │ │286 +// the cap. │ │287 +fn loadSearchWindow( │ │288 + comptime Data: type, │ │289 + comptime kind: evt.EventKind, │ │290 + comptime hash_kind: hash.HashKind, │ │291 + arena: *std.heap.ArenaAllocator, │ │292 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │293 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │294 + records: evt.EventDB(hash_kind).HashMap(.read_only), │ │295 + set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │296 + root_key: ?[]const u8, │ │297 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │298 + selected_id: []const u8, │ │299 + comments_start: usize, │ │300 + query_text: []const u8, │ │301 +) !Data.Window { │ │302 + const set = set_maybe orelse return .empty; │ │303 + const DB = evt.EventDB(hash_kind); │ │304 + const aa = arena.allocator(); │ │305 + const root = root_key orelse ""; │ │306 + │ │307 + // the matching list keys, in list order. the cap bounds the count, not the │ │308 + // paging: past it a pass goes on only until the window and its "next" link │ │309 + // are known. │ │310 + var keys: std.ArrayList(OrderKey) = .empty; │ │311 + if (comptime kind == .discuss) { │ │312 + // discussions list by activity but are indexed by creation, so the │ │313 + // matching ids are gathered and the listed set is walked in its order │ │314 + var matches: std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = .empty; │ │315 + var results = try srch.query(DB, hash_kind, kind, haxy_moment, aa, query_text, null); │ │316 + while (try results.next()) |key| try matches.put(aa, (try resultKey(key))[@sizeOf(u64)..].*, {}); │ │317 + │ │318 + var iter = try set.iteratorFromIndex(0); │ │319 + while (matches.count() > 0) { │ │320 + const pair = try ((try iter.next()) orelse break).readKeyValuePair(); │ │321 + var order_key: OrderKey = undefined; │ │322 + _ = try pair.key_cursor.readBytes(&order_key); │ │323 + if (!matches.contains(order_key[@sizeOf(u64)..].*)) continue; │ │324 + try keys.append(aa, order_key); │ │325 + if (windowKnown(Data, keys.items, root)) break; │ │326 + } │ │327 + } else { │ │328 + // issues and patches list in the index's own order, so the listed set │ │329 + // narrows the query and the results stream out already sorted │ │330 + var results = try srch.query(DB, hash_kind, kind, haxy_moment, aa, query_text, set); │ │331 + while (try results.next()) |key| { │ │332 + try keys.append(aa, try resultKey(key)); │ │333 + if (windowKnown(Data, keys.items, root)) break; │ │334 + } │ │335 + } │ │336 + │ │337 + // the window starts at the first key at or after the root │ │338 + var first: usize = 0; │ │339 + while (first < keys.items.len and std.mem.order(u8, &keys.items[first], root) == .lt) first += 1; │ │340 + │ │341 + var items: std.ArrayList(Data.Entry) = .empty; │ │342 + const last = @min(first + Data.page_size, keys.items.len); │ │343 + for (keys.items[first..last]) |*order_key| { │ │344 + const entry = (try loadEntry(Data, hash_kind, arena, admin_moment, haxy_moment, records, order_key, conflict_set, selected_id, comments_start)) orelse continue;│ │345 + try items.append(aa, entry); │ │346 + } │ │347 + │ │348 + return .{ │ │349 + .items = items.items, │ │350 + // the page a window back, which is the first one when it holds the root │ │351 + .prev_id = if (first == 0) null else if (first <= Data.page_size) "" else try aa.dupe(u8, &keyIdHex(&keys.items[first - Data.page_size])), │ │352 + .next_id = if (last < keys.items.len) try aa.dupe(u8, &keyIdHex(&keys.items[last])) else null, │ │353 + .count = @min(keys.items.len, max_search_results), │ │354 + .more = keys.items.len > max_search_results, │ │355 + }; │ │356 +} │ │357 + │ │358 +// whether a search pass can stop: the count is capped, and the window rooted at │ │359 +// `root` and the key after it have been collected │ │360 +fn windowKnown(comptime Data: type, keys: []const OrderKey, root: []const u8) bool { │ │361 + if (keys.len <= max_search_results) return false; │ │362 + return std.mem.order(u8, &keys[keys.len - 1 - Data.page_size], root) != .lt; │ │363 +} │ │364 + │ │365 +// one list entry: the record `order_key` names, with its author, comments and │ │366 +// attachments. null when the record is gone. │ │367 +fn loadEntry( │ │368 + comptime Data: type, │ │369 + comptime hash_kind: hash.HashKind, │ │370 + arena: *std.heap.ArenaAllocator, │ │371 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │372 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │373 + records: evt.EventDB(hash_kind).HashMap(.read_only), │ │374 + order_key: *const OrderKey, │ │375 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │376 + selected_id: []const u8, │ │377 + comments_start: usize, │ │378 +) !?Data.Entry { │ │379 + const DB = evt.EventDB(hash_kind); │ │380 + const aa = arena.allocator(); │ │381 + const id_hex = keyIdHex(order_key); │ │382 + const record_cursor = try records.getCursor(hash.hashInt(hash_kind, order_key[@sizeOf(u64)..])) orelse return null; │ │383 + const record = try evt.read(Data.Event.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); │ │384 + var entry: Data.Entry = .{ │ │385 + .id = try aa.dupe(u8, &id_hex), │ │386 + .record = record, │ │387 + .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), │ │388 + .comments = try Comment.loadWindow( │ │389 + hash_kind, │ │390 + arena, │ │391 + admin_moment, │ │392 + haxy_moment, │ │393 + evt.Comment.thread_id_to_comment_id_set_key, │ │394 + &id_hex, │ │395 + if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │396 + ), │ │397 + .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │398 + }; │ │399 + if (comptime @hasField(Data.Entry, "conflicted")) { │ │400 + entry.conflicted = if (conflict_set) |conflicts| try conflicts.contains(order_key) else false; │ │401 + } │ │402 + return entry; │ │403 +} │ │404 + │ │405 +// a tab link's route, carrying the page's query when it has one │ │406 +pub fn searchRoute(route_maybe: ?ui.RoutablePage, search: ?[]const u8) !ui.RoutablePage { │ │407 + const route = route_maybe orelse return error.RouteTooLong; │ │408 + const query = search orelse return route; │ │409 + return route.withSearch(query) orelse error.RouteTooLong; │ │410 +} │ │411 + │ │412 +// a status tab's label: its listing's count, marked when a search capped it │ │413 +pub fn countLabel(buffer: []u8, name: []const u8, win: anytype) ![]const u8 { │ │414 + return std.fmt.bufPrint(buffer, "{s} ({d}{s})", .{ name, win.count, if (win.more) "+" else "" }); │ │415 +} │ │416 + │ │417 +// the common sub-header used by each thread page: the search box, then the │ │418 +// tab strip │ │419 pub const Header = struct { │ │420 box: wgt.Box(Widget), │ │421 // focus id -> semantic stack index; some pages omit unavailable tabs │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │422 tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │423 + // the tab whose view the stack shows. focus lands on the search box too, │ │424 + // so the selection is remembered rather than read off the focus chain. │ │425 + selected_tab: ?usize = null, │ │426 │ │286 - pub fn init(allocator: std.mem.Allocator) !Header { │ │427 + // the search box's place in the box, ahead of the tabs │ │428 + const search_index: usize = 0; │ │429 + │ │430 + pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8) !Header { │ │431 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │432 errdefer box.deinit(allocator); │ │433 + │ │434 + { │ │435 + var search_box = try widget.SearchBox.init(allocator, session, " search ", "search", search); │ │436 + errdefer search_box.deinit(allocator); │ │437 + box.getFocus().child_id = search_box.getFocus().id; │ │438 + try box.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = widget.SearchBox.min_size });│ │439 + } │ │440 + │ │441 return .{ .box = box, .tab_ids = .empty }; │ │442 } │ │443 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │455 } │ │456 │ │457 pub fn select(self: *Header, view_index: usize) void { │ │306 - for (self.tab_ids.keys(), self.tab_ids.values()) |id, index| {│ │458 + for (self.tab_ids.values(), 0..) |index, tab| { │ │459 if (index == view_index) { │ │308 - self.getFocus().child_id = id; │ │460 + self.selected_tab = tab; │ │461 return; │ │462 } │ │463 } │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │470 │ │471 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │472 self.clearGrid(); │ │473 + // the search box takes focus without changing the view, so the tab it │ │474 + // was last on stays selected │ │475 + if (self.currentTabIndex()) |tab| self.selected_tab = tab; │ │476 // only the selected tab shows its border │ │477 + const selected_id = if (self.selectedTab()) |tab| self.tab_ids.keys()[tab] else null; │ │478 for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │479 switch (child.widget) { │ │324 - .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, │ │480 + .text_box => |*tb| tb.options.border_style = if (selected_id == id) .single else .hidden, │ │481 else => {}, │ │482 } │ │483 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯