╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │237 comments_start: usize, │ │238 // the search the set is narrowed to, or null to list it whole │ │239 query: ?[]const u8, │ │240 + // a second set the listing is narrowed to, keyed like `set_maybe` │ │241 + extra_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │242 viewer: ?ui.Viewer, │ │243 ) !Data.Window { │ │242 - 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, viewer); │ │244 + 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);│ │245 const set = set_maybe orelse return .empty; │ │246 const aa = arena.allocator(); │ │247 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │285 // how many matches one search counts before the count is marked as capped │ │286 const max_search_results = 1000; │ │287 │ │286 -// `set`'s entries matching `query_text`, windowed like `loadWindow`. one pass │ │288 +// `set`'s entries matching `query_text` (any when empty) and held by │ │289 +// `extra_set` when set, windowed like `loadWindow`. one pass │ │290 // yields the window, its neighbours' roots and the match count, which stops at│ │291 // the cap. │ │292 fn loadSearchWindow( │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────╮ │303 selected_id: []const u8, │ │304 comments_start: usize, │ │305 query_text: []const u8, │ │306 + extra_set: ?evt.EventDB(hash_kind).SortedSet(.read_only),│ │307 viewer: ?ui.Viewer, │ │308 ) !Data.Window { │ │309 const set = set_maybe orelse return .empty; │ ╰──────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │320 // matching ids are gathered and the listed set is walked in its order │ │321 // until every one has been found │ │322 var matches: std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = .empty; │ │319 - var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, null); │ │323 + var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, &.{extra_set});│ │324 while (try results.next()) |key| try matches.put(aa, (try resultKey(key))[@sizeOf(u64)..].*, {}); │ │325 │ │326 var iter = try set.iteratorFromIndex(0); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │335 } else { │ │336 // issues and patches list in the index's own order, so the listed set │ │337 // narrows the query and the results stream out already sorted │ │334 - var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, set); │ │338 + var results = try srch_thrd.query(DB, hash_kind, kind, haxy_moment, aa, query_text, &.{ set, extra_set });│ │339 while (try results.next()) |key| { │ │340 try keys.append(aa, try resultKey(key)); │ │341 if (windowKnown(Data, keys.items, root)) break; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │405 viewer, │ │406 ), │ │407 .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │404 - .assignees = try evt.Assignment.load(hash_kind, arena, haxy_moment, &id_hex), │ │408 + .assignees = try loadAssignees(hash_kind, arena, admin_moment, haxy_moment, &id_hex), │ │409 .can_modify = if (viewer) |v| v.role.canModify(v.email, record.author_email) else false,│ │410 }; │ │411 if (comptime @hasField(Data.Entry, "conflicted")) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │412 entry.conflicted = if (conflict_set) |conflicts| try conflicts.contains(order_key) else false; │ │413 } │ │414 return entry; │ │415 +} │ │416 + │ │417 +// a thread's assignees, each named after the user holding its email when one does │ │418 +fn loadAssignees( │ │419 + comptime hash_kind: hash.HashKind, │ │420 + arena: *std.heap.ArenaAllocator, │ │421 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │422 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │423 + thread_id: []const u8, │ │424 +) ![]const ui.Assignee { │ │425 + const emails = try evt.Assignment.load(hash_kind, arena, haxy_moment, thread_id); │ │426 + const assignees = try arena.allocator().alloc(ui.Assignee, emails.len); │ │427 + for (assignees, emails) |*assignee, email| { │ │428 + const user = if (admin_moment) |moment| try evt.User.readByEmail(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, email) else null;│ │429 + assignee.* = .{ .email = email, .name = if (user) |u| u.name else null }; │ │430 + } │ │431 + return assignees; │ │432 } │ │433 │ │434 +// the url-encoded assignee filter a thread page carries, "" for kinds without one │ │435 +fn assigneeFilter(data: anytype) []const u8 { │ │436 + return if (@hasField(@TypeOf(data.*), "assignee")) data.assignee else ""; │ │437 +} │ │438 + │ │439 // a tab link's route, carrying the page's query when it has one │ │440 pub fn searchRoute(route_maybe: ?ui.RoutablePage, search: ?[]const u8) !ui.RoutablePage { │ │441 const route = route_maybe orelse return error.RouteTooLong; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │448 return std.fmt.bufPrint(buffer, "{s} ({d}{s})", .{ name, win.count, if (win.more) "+" else "" });│ │449 } │ │450 │ │425 -// the common sub-header used by each thread page: the search box, then the │ │426 -// tab strip │ │451 +// the common sub-header used by each thread page: the search box, the │ │452 +// assignee box on the issues page, then the tab strip │ │453 pub const Header = struct { │ │454 box: wgt.Box(Widget), │ │455 // focus id -> semantic stack index; some pages omit unavailable tabs │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │456 tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │431 - // the tab whose view the stack shows. focus lands on the search box too, │ │457 + // the tab whose view the stack shows. focus lands on the boxes too, │ │458 // so the selection is remembered rather than read off the focus chain. │ │459 selected_tab: ?usize = null, │ │460 + // how many text boxes come before the tabs │ │461 + box_count: usize, │ │462 + │ │463 + // which box enter submitted from │ │464 + pub const Submission = struct { kind: enum { search, assignee }, text: []u8 }; │ │465 │ │435 - // the search box's place in the box, ahead of the tabs │ │436 - const search_index: usize = 0; │ │466 + // the assignee box's label and prefilled text │ │467 + pub const AssigneeBox = struct { label: []const u8, text: []const u8 }; │ │468 │ │438 - pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8) !Header { │ │469 + // a null `assignee` leaves out the assignee box │ │470 + pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8, assignee: ?AssigneeBox) !Header {│ │471 var box = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │472 errdefer box.deinit(allocator); │ │473 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │478 try box.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = widget.SearchBox.min_size }); │ │479 } │ │480 │ │449 - return .{ .box = box, .tab_ids = .empty }; │ │481 + if (assignee) |value| { │ │482 + var assignee_box = try widget.SearchBox.init(allocator, session, value.label, "assignee", value.text); │ │483 + errdefer assignee_box.deinit(allocator); │ │484 + try box.children.put(allocator, assignee_box.getFocus().id, .{ .widget = .{ .search_box = assignee_box }, .rect = null, .min_size = widget.SearchBox.min_size });│ │485 + } │ │486 + │ │487 + return .{ .box = box, .tab_ids = .empty, .box_count = box.children.count() }; │ │488 } │ │489 │ │490 pub fn addTab(self: *Header, allocator: std.mem.Allocator, label: []const u8, link: []const u8, view_index: usize) !void { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯