╭──────────╮ │← previous│ ╰──────────╯ ╭ 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(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 tabs) │ │26 - for ([_]xit.xitui.input.Key{ .arrow_right, .arrow_right, .arrow_right, .arrow_right, .arrow_down }) |key| { │ │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| {│ │27 try ui.inputKey(allocator, &root, key, &session); │ │28 try root.build(allocator, constraint, focus); │ │29 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │789 }; │ │790 } │ │791 try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &comment_events); │ │792 + │ │793 + // assign a few issues so the assignee filter has something to show: │ │794 + // alice and bobby share the newest issue, alice also holds the one │ │795 + // before it, and admin an older one │ │796 + const assignment_data = [_]struct { issue: usize, user: usize }{ │ │797 + .{ .issue = issue_data.len - 1, .user = 1 }, │ │798 + .{ .issue = issue_data.len - 1, .user = 2 }, │ │799 + .{ .issue = issue_data.len - 2, .user = 1 }, │ │800 + .{ .issue = issue_data.len - 5, .user = 0 }, │ │801 + }; │ │802 + var assignment_events: [assignment_data.len]evt.EventWithId = undefined; │ │803 + for (&assignment_events, assignment_data, 0..) |*event, assignment, i| { │ │804 + const thread_id = issue_events[assignment.issue].id; │ │805 + const email = user_data[assignment.user].email; │ │806 + event.* = .{ │ │807 + .id = std.fmt.bytesToHex(evt.Assignment.idOf(&try evt.parseEventId(&thread_id), email), .lower), │ │808 + .timestamp = seedTimestamp(now, i, assignment_events.len, 90 * std.time.s_per_min, 20 * std.time.s_per_min),│ │809 + .author = .{ .name = user_data[0].name, .email = user_data[0].email }, │ │810 + .event = .{ .assign = .{ .thread_id = thread_id, .email = email } }, │ │811 + }; │ │812 + } │ │813 + try evt.consume(.local, .repo, .xit, .{}, io, allocator, &template_repo, evt.events_ref, &assignment_events); │ │814 │ │815 const discussion_data = [_]struct { │ │816 title: []const u8, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │153 name: Array(repo_route_max_len), │ │154 // the label the list is filtered to, url-encoded ("" = unfiltered). │ │155 label: Array(issue_label_route_max_len) = .{}, │ │156 + // the assignee the list is filtered to, a url-encoded name or email as│ │157 + // typed ("" = unfiltered). │ │158 + assignee: Array(assignee_route_max_len) = .{}, │ │159 // the query the list is filtered to, url-encoded ("" = unfiltered). │ │160 search: Array(search_route_max_len) = .{}, │ │161 // the issue the route names: the list's window root, or the issue the │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │227 │ │228 // the "key:value" segments of a url tail, collected in any order │ │229 const Params = struct { │ │227 - const ParamKey = enum { start, line, branch, tag, label, object, base, patchrev, theirs, find, search, from, moment, merge, user }; │ │230 + const ParamKey = enum { start, line, branch, tag, label, assignee, object, base, patchrev, theirs, find, search, from, moment, merge, user };│ │231 │ │232 // a branch:/tag:/object: param; the value stays url-encoded │ │233 const RefParam = struct { kind: RefOrOid, value: []const u8 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │313 const message_seg = "message"; │ │314 // the `Params` key spellings the url emitters use │ │315 const label_filter_seg = @tagName(Params.ParamKey.label) ++ ":";│ │316 + const assignee_seg = @tagName(Params.ParamKey.assignee) ++ ":"; │ │317 const start_seg = @tagName(Params.ParamKey.start) ++ ":"; │ │318 const line_seg = @tagName(Params.ParamKey.line) ++ ":"; │ │319 const find_seg = @tagName(Params.ParamKey.find) ++ ":"; │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │348 pub const issue_label_route_max_len = evt.Issue.label_max_len * 3; │ │349 pub const patch_label_route_max_len = evt.Patch.label_max_len * 3; │ │350 pub const discussion_label_route_max_len = evt.Discussion.label_max_len * 3;│ │351 + │ │352 + // a url-encoded email can grow to three bytes per source byte. │ │353 + pub const assignee_route_max_len = evt.Assignment.email_max_len * 3; │ │354 │ │355 // caps the resolve view's theirs: field list (a longer one doesn't route). │ │356 pub const theirs_route_max_len = 512; │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │655 return withEncodedSearch(self, search.slice()); │ │656 } │ │657 │ │651 - // the label and search segments of a thread list url, either one optional │ │652 - fn writeListFilters(writer: *std.Io.Writer, label: []const u8, search: []const u8) !void { │ │658 + // the label, assignee and search segments of a thread list url, each optional │ │659 + fn writeListFilters(writer: *std.Io.Writer, label: []const u8, assignee: []const u8, search: []const u8) !void {│ │660 if (label.len != 0) try writer.print("/" ++ label_filter_seg ++ "{s}", .{label}); │ │661 + if (assignee.len != 0) try writer.print("/" ++ assignee_seg ++ "{s}", .{assignee}); │ │662 if (search.len != 0) try writer.print("/" ++ search_seg ++ "{s}", .{search}); │ │663 } │ │664 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │672 return route; │ │673 } │ │674 │ │675 + // carry an already-encoded assignee filter on an issues route │ │676 + pub fn withEncodedAssignee(self: ?RoutablePage, value: []const u8) ?RoutablePage { │ │677 + var route = self orelse return null; │ │678 + switch (route) { │ │679 + .repo_issues => |*i| i.assignee = Array(assignee_route_max_len).from(value) orelse return null,│ │680 + else => return null, │ │681 + } │ │682 + return route; │ │683 + } │ │684 + │ │685 // carry an already-encoded search param, as a url hands it over │ │686 fn withEncodedSearch(self: ?RoutablePage, value: []const u8) ?RoutablePage { │ │687 var route = self orelse return null; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1174 // and reloading keep the label and the query │ │1175 if (i.selected.len != 0) { │ │1176 try out.writer.print("{s}/" ++ issue_seg ++ "{s}", .{ prefix, i.selected.slice() }); │ │1159 - try writeListFilters(&out.writer, i.label.slice(), i.search.slice()); │ │1177 + try writeListFilters(&out.writer, i.label.slice(), i.assignee.slice(), i.search.slice()); │ │1178 if (i.comments_start != 0) try out.writer.print("/" ++ start_seg ++ "{d}", .{i.comments_start});│ │1179 break :blk out.written(); │ │1180 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯