╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │29 conflicted: bool = false, │ │30 comments: Comment.Window = .empty, │ │31 attachments: []const Attachment.WithId = &.{}, │ │32 + assignees: []const []const u8 = &.{}, │ │33 + // whether the viewer may change the thread, computed here so the web ui has it│ │34 + can_modify: bool = false, │ │35 }; │ │36 │ │37 pub const Entry = IssueWithId; │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Patches.zig│ ╰───────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │147 conflicted: bool = false, │ │148 comments: Comment.Window = .empty, │ │149 attachments: []const Attachment.WithId = &.{}, │ │150 + assignees: []const []const u8 = &.{}, │ │151 + // whether the viewer may change the thread, computed here so the web ui has it│ │152 + can_modify: bool = false, │ │153 draft: bool = false, │ │154 revision_oid: []const u8 = "", │ │155 base_oid: []const u8 = "", │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │366 return std.mem.order(u8, &keys[keys.len - 1 - Data.page_size], root) != .lt;│ │367 } │ │368 │ │369 -// one list entry: the record `order_key` names, with its author, comments and │ │370 -// attachments. null when the record is gone. │ │369 +// one list entry: the record `order_key` names, with its author, comments, │ │370 +// attachments and assignees. null when the record is gone. │ │371 fn loadEntry( │ │372 comptime Data: type, │ │373 comptime hash_kind: hash.HashKind, │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │401 viewer, │ │402 ), │ │403 .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │404 + .assignees = try evt.Assignment.load(hash_kind, arena, haxy_moment, &id_hex), │ │405 + .can_modify = if (viewer) |v| v.role.canModify(v.email, record.author_email) else false, │ │406 }; │ │407 if (comptime @hasField(Data.Entry, "conflicted")) { │ │408 entry.conflicted = if (conflict_set) |conflicts| try conflicts.contains(order_key) else false;│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │559 }; │ │560 │ │561 // the selected thread's scrollable contents, shared by repo and fork views │ │562 +// the assignee row's form action, which also identifies the row │ │563 +const assign_suffix = "/assign"; │ │564 + │ │565 +// an input that assigns on enter, then one unassign button per assignee. a │ │566 +// viewer who may not change the thread only sees who is assigned. │ │567 +fn appendAssigneeRow( │ │568 + allocator: std.mem.Allocator, │ │569 + box: *wgt.Box(Widget), │ │570 + session: *ui.Session, │ │571 + thread_url: []const u8, │ │572 + assignees: []const []const u8, │ │573 + can_modify: bool, │ │574 +) !void { │ │575 + const pa = session.page_arena.allocator(); │ │576 + if (!can_modify) { │ │577 + if (assignees.len == 0) return; │ │578 + const joined = try std.mem.join(pa, " ", assignees); │ │579 + var assigned = try wgt.TextBox.init(allocator, joined, .{ .border = .single, .round_corners = true, .wrap_kind = .word, .top_label = .{ .text = " assigned " } });│ │580 + errdefer assigned.deinit(allocator); │ │581 + try box.children.put(allocator, assigned.getFocus().id, .{ .widget = .{ .text_box = assigned }, .rect = null, .min_size = null }); │ │582 + return; │ │583 + } │ │584 + var row = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │585 + errdefer row.deinit(allocator); │ │586 + row.getFocus().kind = .{ .custom = try pa.print("form:{s}" ++ assign_suffix, .{thread_url}) }; │ │587 + │ │588 + var text_input = try wgt.TextInput.init(allocator, .{ .name = "assignee", .visible_width = 30, .round_corners = true, .render_content = session.is_terminal }); │ │589 + errdefer text_input.deinit(allocator); │ │590 + text_input.getFocus().mode = .all; │ │591 + if (session.formFeedback(.assignment)) |saved| try text_input.setContent(allocator, saved.text); │ │592 + row.getFocus().child_id = text_input.getFocus().id; │ │593 + try row.children.put(allocator, text_input.getFocus().id, .{ .widget = .{ .text_input = text_input }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); │ │594 + │ │595 + if (assignees.len > 0) { │ │596 + const items = try pa.alloc(WordFlow.Item, assignees.len); │ │597 + for (items, assignees) |*item, email| item.* = .{ │ │598 + .text = try pa.print(" unassign {s} ", .{email}), │ │599 + .link = try pa.print("{s}{s}/assignee:{s}/unassign", .{ ui.submit_action_prefix, thread_url, try ui.urlEncodeRef(pa, email) }), │ │600 + }; │ │601 + var flow = try WordFlow.init(allocator); │ │602 + errdefer flow.deinit(allocator); │ │603 + try flow.setItems(allocator, items); │ │604 + // the buttons post their own forms, so enter in the input still submits │ │605 + // the assign form │ │606 + flow.getFocus().kind = .{ .custom = try pa.print("form:{s}", .{thread_url}) }; │ │607 + try row.children.put(allocator, flow.getFocus().id, .{ .widget = .{ .word_flow = flow }, .rect = null, .min_size = null }); │ │608 + } │ │609 + │ │610 + try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │611 +} │ │612 + │ │613 +// whether `row` is the assignee row │ │614 +fn isAssigneeRow(row: *wgt.Box(Widget)) bool { │ │615 + const action = switch (row.getFocus().kind) { │ │616 + .custom => |custom| custom, │ │617 + else => return false, │ │618 + }; │ │619 + return std.mem.startsWith(u8, action, "form:") and std.mem.endsWith(u8, action, assign_suffix); │ │620 +} │ │621 + │ │622 +// label the row's input for this frame and register it for the web form │ │623 +// handling │ │624 +fn buildAssigneeRow(row: *wgt.Box(Widget), session: *ui.Session, root_focus: *Focus) !void { │ │625 + const text_input = &row.children.values()[0].widget.text_input; │ │626 + const local = session.data.host_kind == .local; │ │627 + const failure = if (session.formFeedback(.assignment)) |saved| saved.failure else null; │ │628 + text_input.options.top_label.text = if (failure) |value| switch (value) { │ │629 + .not_found => " assign by name or email (not found) ", │ │630 + .not_an_email => if (local) " assign by email (not an email) " else " assign by name or email (not an email) ", │ │631 + .already_assigned => if (local) " assign by email (already assigned) " else " assign by name or email (already assigned) ", │ │632 + } else if (local) " assign by email " else " assign by name or email "; │ │633 + text_input.options.bottom_label.text = if (root_focus.grandchild_id == text_input.getFocus().id) " press enter " else ""; │ │634 + try session.text_inputs.put(session.arena.allocator(), text_input.getFocus().id, text_input); │ │635 +} │ │636 + │ │637 pub fn Detail(comptime kind: evt.EventKind, comptime Data: type) type { │ │638 const Entry = Data.Entry; │ │639 const Event = Data.Event; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │689 │ │690 pub fn build(self: *This, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │691 self.clearGrid(); │ │692 + if (kind == .issue) { │ │693 + for (self.inner().children.values()) |*child| switch (child.widget) { │ │694 + .box => |*row| if (isAssigneeRow(row)) try buildAssigneeRow(row, self.session, root_focus), │ │695 + else => {}, │ │696 + }; │ │697 + } │ │698 try self.scroll.build(allocator, constraint, root_focus); │ │699 } │ │700 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │940 } │ │941 } │ │942 │ │943 + if (kind == .issue) { │ │944 + const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │945 + try appendAssigneeRow(allocator, inner_box, self.session, try parent_route.toUrl(self.session.page_arena), entry.assignees, entry.can_modify);│ │946 + } │ │947 + │ │948 const whole = entry.record.event.description; │ │949 const preview_end = if (description_page) null else ui.detailPreviewEnd(whole); │ │950 const shown = if (preview_end) |end| │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │964 │ │965 if (!supports_drafts or !entryDraft(entry)) { │ │966 const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │879 - try Attachment.appendRows(allocator, inner_box, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments);│ │967 + const parent_url = try parent_route.toUrl(self.session.page_arena); │ │968 + try Attachment.appendRows(allocator, inner_box, self.session, self.data.identity, parent_url, entry.attachments); │ │969 │ │970 if (!description_page) { │ │971 if (self.data.viewer != null) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1003 (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) │ │1004 else │ │1005 raw_key; │ │1006 + │ │1007 + // a focused assignee input keeps its editing keys │ │1008 + if (key == .home or key == .end) if (self.focusedAssigneeInput(root_focus)) |text_input| {│ │1009 + try text_input.input(allocator, key, root_focus); │ │1010 + return self.exit; │ │1011 + }; │ │1012 │ │1013 switch (key) { │ │1014 .page_up => self.pageDetail(root_focus, -10), │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1032 }, │ │1033 } │ │1034 return self.exit; │ │1035 + } │ │1036 + │ │1037 + // the assignee row's input, when it holds focus │ │1038 + fn focusedAssigneeInput(self: *This, root_focus: *Focus) ?*wgt.TextInput { │ │1039 + const child_index = self.focusedChild(root_focus) orelse return null; │ │1040 + const child = &self.inner().children.values()[child_index].widget; │ │1041 + if (child.* != .box or !isAssigneeRow(&child.box)) return null; │ │1042 + const text_input = &child.box.children.values()[0].widget.text_input; │ │1043 + return if (root_focus.grandchild_id == text_input.getFocus().id) text_input else null; │ │1044 } │ │1045 │ │1046 fn childInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void {│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯