╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1010 var label_iter = Event.labelIterator(entry.record.event.labels); │ │1011 while (label_iter.next()) |label| { │ │1012 if (label.len == 0) continue; │ │ 944 - try items.append(allocator, .{ .text = label, .link = try labelLink(self.session.page_arena, self.data.identity, entryStatus(entry), label, self.data.search orelse "") }); │ │1013 + try items.append(allocator, .{ .text = label, .link = try labelLink(self.session.page_arena, self.data.identity, entryStatus(entry), label, self.data.search orelse "", assigneeFilter(self.data)) });│ │1014 } │ │1015 if (items.items.len > 0) { │ │1016 var labels = try WordFlow.init(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1021 } │ │1022 │ │1023 if (kind == .issue) { │ │1024 + const pa = self.session.page_arena.allocator(); │ │1025 const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │ 956 - try appendAssigneeRow(allocator, inner_box, self.session, try parent_route.toUrl(self.session.page_arena), entry.assignees, entry.can_modify); │ │1026 + const thread_url = try parent_route.toUrl(self.session.page_arena); │ │1027 + if (entry.can_modify) try appendAssigneeRow(allocator, inner_box, self.session, thread_url); │ │1028 + // every viewer gets each assignee as a link to the issues assigned to it │ │1029 + const links = try pa.alloc([]const u8, entry.assignees.len); │ │1030 + for (links, entry.assignees) |*link, assignee| link.* = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), self.data.label, "", self.data.search orelse "", try ui.urlEncodeRef(pa, assignee.display()));│ │1031 + try appendAssignedRows(allocator, inner_box, self.session, thread_url, entry.assignees, links, entry.can_modify); │ │1032 } │ │1033 │ │1034 const whole = entry.record.event.description; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1176 else => try copyable.input(allocator, key, root_focus), │ │1177 }, │ │1178 .box => |*row| if (isAssigneeRow(row)) { │ │1104 - try self.assigneeInput(allocator, child_index, row, key, root_focus); │ │1179 + try self.assigneeInput(allocator, row, key, root_focus); │ │1180 } else if (Attachment.removeId(row)) |attachment_id| { │ │1106 - const remove_id = row.children.keys()[1]; │ │1107 - switch (key) { │ │1108 - .arrow_left => if (!self.moveHorizontal(root_focus, false)) { │ │1109 - self.exit = .list; │ │1110 - }, │ │1111 - .arrow_right => _ = self.moveHorizontal(root_focus, true), │ │1112 - .arrow_up => _ = self.moveVertical(root_focus, false), │ │1113 - .arrow_down => _ = self.moveVertical(root_focus, true), │ │1114 - .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), │ │1115 - .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), │ │1116 - else => {}, │ │1117 - } │ │1181 + if (self.removableRowInput(row, key, root_focus)) try self.removeEvent(allocator, .attach, attachment_id); │ │1182 + } else if (unassignEmail(row)) |encoded| { │ │1183 + if (self.removableRowInput(row, key, root_focus)) try self.unassign(allocator, std.Uri.percentDecodeInPlace(try self.session.page_arena.allocator().dupe(u8, encoded)));│ │1184 } else self.commentInput(key, root_focus), │ │1185 else => self.commentInput(key, root_focus), │ │1186 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1372 } │ │1373 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus);│ │1374 }, │ │1375 + // a row enters on its first cell from either direction; the tool │ │1376 + // row keeps the cell it was on │ │1377 .box => |*box| if (box.children.count() > 0) { │ │1378 target = if (child_index == tool_row_index) │ │1379 box.getFocus().child_id orelse return │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │1380 else │ │1313 - box.children.keys()[if (last) box.children.count() - 1 else 0];│ │1381 + box.children.keys()[0]; │ │1382 root_focus.setFocus(target); │ │1383 }, │ │1384 else => root_focus.setFocus(target), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1452 return true; │ │1453 } │ │1454 │ │1387 - // the input takes typing and assigns on enter; the buttons after it │ │1388 - // unassign │ │1389 - fn assigneeInput(self: *This, allocator: std.mem.Allocator, child_index: usize, row: *wgt.Box(Widget), key: Key, root_focus: *Focus) !void { │ │1455 + // the input takes typing and assigns on enter │ │1456 + fn assigneeInput(self: *This, allocator: std.mem.Allocator, row: *wgt.Box(Widget), key: Key, root_focus: *Focus) !void { │ │1457 const text_input = &row.children.values()[0].widget.text_input; │ │1391 - const flow: ?*WordFlow = if (row.children.count() > 1) &row.children.values()[1].widget.word_flow else null; │ │1392 - if (root_focus.grandchild_id == text_input.getFocus().id) { │ │1393 - switch (key) { │ │1394 - .arrow_up => _ = self.moveVertical(root_focus, false), │ │1395 - .arrow_down => _ = self.moveVertical(root_focus, true), │ │1396 - .arrow_left => if (text_input.cursor == 0) { │ │1397 - self.exit = .list; │ │1398 - } else try text_input.input(allocator, key, root_focus), │ │1399 - .arrow_right => { │ │1400 - // right past the last character steps onto the first button │ │1401 - if (flow) |buttons| if (text_input.cursor == text_input.content.items.len) return self.focusLabel(child_index, buttons, root_focus, 0); │ │1402 - try text_input.input(allocator, key, root_focus); │ │1403 - }, │ │1404 - .enter => { │ │1405 - const text = try text_input.text(allocator); │ │1406 - defer allocator.free(text); │ │1407 - try self.assign(allocator, text); │ │1408 - }, │ │1409 - else => try text_input.input(allocator, key, root_focus), │ │1410 - } │ │1411 - return; │ │1458 + switch (key) { │ │1459 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │1460 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │1461 + .arrow_left => if (text_input.cursor == 0) { │ │1462 + self.exit = .list; │ │1463 + } else try text_input.input(allocator, key, root_focus), │ │1464 + .enter => { │ │1465 + const text = try text_input.text(allocator); │ │1466 + defer allocator.free(text); │ │1467 + try self.assign(allocator, text); │ │1468 + }, │ │1469 + else => try text_input.input(allocator, key, root_focus), │ │1470 } │ │1471 + } │ │1472 │ │1414 - const buttons = flow orelse return; │ │1415 - const cur = buttons.indexOfFocusId(root_focus.grandchild_id orelse return) orelse return; │ │1416 - const assignees = (self.entry orelse return).assignees; │ │1473 + // arrows move along a row that ends in a ✕. returns whether enter or a │ │1474 + // click hit the ✕. │ │1475 + fn removableRowInput(self: *This, row: *wgt.Box(Widget), key: Key, root_focus: *Focus) bool { │ │1476 + const remove_id = row.children.keys()[row.children.count() - 1]; │ │1477 switch (key) { │ │1418 - .arrow_left => if (cur > 0) self.focusLabel(child_index, buttons, root_focus, cur - 1) else root_focus.setFocus(text_input.getFocus().id), │ │1419 - .arrow_right => if (cur + 1 < buttons.text_boxes.items.len) self.focusLabel(child_index, buttons, root_focus, cur + 1), │ │1420 - .arrow_up => if (buttons.rowStep(cur, false)) |index| { │ │1421 - self.focusLabel(child_index, buttons, root_focus, index); │ │1422 - } else { │ │1423 - _ = self.moveVertical(root_focus, false); │ │1424 - }, │ │1425 - .arrow_down => if (buttons.rowStep(cur, true)) |index| { │ │1426 - self.focusLabel(child_index, buttons, root_focus, index); │ │1427 - } else { │ │1428 - _ = self.moveVertical(root_focus, true); │ │1478 + .arrow_left => if (!self.moveHorizontal(root_focus, false)) { │ │1479 + self.exit = .list; │ │1480 }, │ │1430 - .enter => try self.unassign(allocator, assignees[cur]), │ │1431 - .mouse => |mouse| if (inp.leftClickOn(root_focus, buttons.text_boxes.items[cur].getFocus().id, mouse)) try self.unassign(allocator, assignees[cur]),│ │1481 + .arrow_right => _ = self.moveHorizontal(root_focus, true), │ │1482 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │1483 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │1484 + .enter => return row.getFocus().child_id == remove_id, │ │1485 + .mouse => |mouse| return inp.leftClickOn(root_focus, remove_id, mouse), │ │1486 else => {}, │ │1487 } │ │1488 + return false; │ │1489 } │ │1490 │ │1491 fn labelsInput(self: *This, child_index: usize, labels: *WordFlow, key: Key, root_focus: *Focus) void { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1805 } │ │1806 │ │1807 // a list link keeps the filters the page carries: the label, and the │ │1753 - // search query when one is set │ │1754 - fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, id: []const u8, search: []const u8) ![]const u8 { │ │1808 + // search query and url-encoded assignee when set │ │1809 + fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, id: []const u8, search: []const u8, assignee: []const u8) ![]const u8 {│ │1810 var route = listRoute(identity, status, label, id) orelse return error.RouteTooLong; │ │1811 if (search.len != 0) route = route.withSearch(search) orelse return error.RouteTooLong; │ │1812 + if (assignee.len != 0) route = route.withEncodedAssignee(assignee) orelse return error.RouteTooLong; │ │1813 return page_arena.allocator().print("a:{s}", .{try route.toUrl(page_arena)}); │ │1814 } │ │1815 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1836 const route = draftsRoute(data.identity) orelse return error.RouteTooLong; │ │1837 return page_arena.allocator().print("a:{s}", .{try route.toUrl(page_arena)}); │ │1838 } │ │1783 - return listLink(page_arena, data.identity, status_maybe orelse @fromBackingInt(@intCast(0)), data.label, id, data.search orelse ""); │ │1839 + return listLink(page_arena, data.identity, status_maybe orelse @fromBackingInt(@intCast(0)), data.label, id, data.search orelse "", assigneeFilter(data)); │ │1840 } │ │1841 │ │1786 - fn labelLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, search: []const u8) ![]const u8 { │ │1842 + fn labelLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, search: []const u8, assignee: []const u8) ![]const u8 {│ │1843 const encoded = try ui.urlEncodeRef(page_arena.allocator(), label); │ │1788 - return listLink(page_arena, identity, status, encoded, "", search); │ │1844 + return listLink(page_arena, identity, status, encoded, "", search, assignee); │ │1845 } │ │1846 │ │1847 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !This { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1878 defer items.deinit(allocator); │ │1879 // when filtered, the first item clears the filter │ │1880 if (data.label.len != 0) │ │1825 - try items.append(allocator, .{ .text = "✕", .link = try listLink(session.page_arena, data.identity, @fromBackingInt(@intCast(0)), "", "", data.search orelse "") }); │ │1881 + try items.append(allocator, .{ .text = "✕", .link = try listLink(session.page_arena, data.identity, @fromBackingInt(@intCast(0)), "", "", data.search orelse "", assigneeFilter(data)) }); │ │1882 for (data.labels) |label| │ │1827 - try items.append(allocator, .{ .text = label, .link = try labelLink(session.page_arena, data.identity, @fromBackingInt(@intCast(0)), label, data.search orelse "") }); │ │1883 + try items.append(allocator, .{ .text = label, .link = try labelLink(session.page_arena, data.identity, @fromBackingInt(@intCast(0)), label, data.search orelse "", assigneeFilter(data)) });│ │1884 try tf.setItems(allocator, items.items); │ │1885 try stack.children.put(allocator, tf.getFocus().id, .{ .word_flow = tf }); │ │1886 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2657 } else false; │ │2658 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]);│ │2659 } else if (key == .enter) { │ │2604 - if (try self.header().submittedText(allocator)) |text| { │ │2605 - defer allocator.free(text); │ │2606 - try self.submitSearch(text); │ │2660 + if (try self.header().submittedText(allocator)) |submission| { │ │2661 + defer allocator.free(submission.text); │ │2662 + switch (submission.kind) { │ │2663 + .search => try self.submitSearch(submission.text), │ │2664 + .assignee => try self.submitAssignee(submission.text), │ │2665 + } │ │2666 } │ │2667 } else { │ │2668 try self.header().input(allocator, key, root_focus); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯