╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │293 if (conflicts_map) |map| { │ │294 if (try map.getCursor(order_key)) |conflict_cursor| { │ │295 const conflict_entry = try DB.HashMap(.read_only).init(conflict_cursor); │ │305 - conflict_data = try readConflict(repo_kind, repo_opts, arena, repo, io, admin_moment, haxy_moment, &id_bytes, issue_event, conflict_entry); │ │296 + conflict_data = try Threads.readConflict(Self, repo_kind, repo_opts, arena, repo, io, admin_moment, haxy_moment, &id_bytes, issue_event, conflict_entry);│ │297 resolved_view = .resolve; │ │298 } │ │299 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │303 } │ │304 │ │305 const thread_comments_start = if (empty.comment_id.len == 0) comments_start else 0; │ │315 - const open_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, open_set, open_root, conflict_set, empty.selected_id, thread_comments_start); │ │316 - const closed_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, closed_set, closed_root, conflict_set, empty.selected_id, thread_comments_start); │ │317 - const conflicts_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, conflict_set, conflicts_root, conflict_set, empty.selected_id, thread_comments_start); │ │306 + const open_window = try Threads.loadWindow(Self, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, open_set, open_root, conflict_set, empty.selected_id, thread_comments_start); │ │307 + const closed_window = try Threads.loadWindow(Self, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, closed_set, closed_root, conflict_set, empty.selected_id, thread_comments_start); │ │308 + const conflicts_window = try Threads.loadWindow(Self, repo_opts.hash, arena, admin_moment, haxy_moment, event_id_to_issue, conflict_set, conflicts_root, conflict_set, empty.selected_id, thread_comments_start);│ │309 if (view == .conflicts and conflicts_window.count > 0) resolved_view = .conflicts; │ │310 │ │311 const comment_page = if (empty.comment_id.len == 0) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │313 else │ │314 try Comment.init(repo_opts.hash, arena, admin_moment, haxy_moment, empty.selected_id, empty.comment_id, comments_start);│ │315 │ │325 - // every tag in the repo, in the tag map's sorted order. the keys are │ │326 - // "tag,status", so a tag's entries are adjacent and dedup by prefix. │ │327 - var tags: std.ArrayList([]const u8) = .empty; │ │328 - if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set"))) |tag_to_issues_cursor| { │ │329 - const tag_to_issues = try DB.SortedMap(.read_only).init(tag_to_issues_cursor); │ │330 - var tag_iter = try tag_to_issues.iterator(); │ │331 - while (try tag_iter.next()) |kv_pair_cursor| { │ │332 - if (tags.items.len == max_tags) break; │ │333 - var kv_cursor = kv_pair_cursor; │ │334 - const kv_pair = try kv_cursor.readKeyValuePair(); │ │335 - const key = try kv_pair.key_cursor.readBytesAlloc(aa, null); │ │336 - const space = std.mem.indexOfScalar(u8, key, ' ') orelse continue; │ │337 - const key_tag = key[0..space]; │ │338 - if (tags.getLastOrNull()) |last| { │ │339 - if (std.mem.eql(u8, last, key_tag)) continue; │ │340 - } │ │341 - try tags.append(aa, key_tag); │ │342 - } │ │343 - } │ │316 + const tags = try Threads.loadTags(Self, repo_opts.hash, arena, haxy_moment); │ │317 │ │318 return .{ │ │319 .identity = empty.identity, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │329 .theirs_picks = empty.theirs_picks, │ │330 .view = resolved_view, │ │331 .description_page = empty.description_page, │ │359 - .tags = tags.items, │ │332 + .tags = tags, │ │333 }; │ │361 -} │ │362 - │ │363 -// `status`'s sorted set within `statuses` (null when the status has no issues) │ │364 -fn statusSet( │ │365 - comptime DB: type, │ │366 - statuses: DB.SortedMap(.read_only), │ │367 - status: evt.Issue.Status, │ │368 -) !?DB.SortedSet(.read_only) { │ │369 - const cursor = (try statuses.getCursor(@tagName(status))) orelse return null; │ │370 - return try DB.SortedSet(.read_only).init(cursor); │ │371 -} │ │372 - │ │373 -// the set at `tag_to_issues`'s "tag,status" key (null when no issue carries │ │374 -// `tag` with `status`, or the tag is too long to exist) │ │375 -fn tagStatusSet( │ │376 - comptime DB: type, │ │377 - tag_to_issues: DB.SortedMap(.read_only), │ │378 - tag: []const u8, │ │379 - status: evt.Issue.Status, │ │380 -) !?DB.SortedSet(.read_only) { │ │381 - var key_buffer: evt.Issue.TagStatusKey = undefined; │ │382 - const key = evt.Issue.tagStatusKey(&key_buffer, tag, status) catch return null; │ │383 - const cursor = (try tag_to_issues.getCursor(key)) orelse return null; │ │384 - return try DB.SortedSet(.read_only).init(cursor); │ │385 -} │ │386 - │ │387 -// read one window of `set_maybe` (null = an empty listing) starting at │ │388 -// `root_key` (null = the beginning) │ │389 -fn loadWindow( │ │390 - comptime hash_kind: hash.HashKind, │ │391 - arena: *std.heap.ArenaAllocator, │ │392 - admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │393 - haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │394 - event_id_to_issue: evt.EventDB(hash_kind).HashMap(.read_only), │ │395 - set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │396 - root_key: ?[]const u8, │ │397 - conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │398 - selected_id: []const u8, │ │399 - comments_start: usize, │ │400 -) !Window { │ │401 - const set = set_maybe orelse return .empty; │ │402 - const DB = evt.EventDB(hash_kind); │ │403 - const aa = arena.allocator(); │ │404 - │ │405 - // seek once to the window start: the root key, or the set's first entry. │ │406 - var prev_id: ?[]const u8 = null; │ │407 - var iter = if (root_key) |key| blk: { │ │408 - // the previous window starts page_size ranks back ("" = the first │ │409 - // window, linked as the bare list route). │ │410 - const rank = try set.rank(key); │ │411 - if (rank > 0 and rank <= page_size) { │ │412 - prev_id = ""; │ │413 - } else if (rank > page_size) { │ │414 - const kv = try set.getIndexKeyValuePair(@intCast(rank - page_size)) orelse return error.NotFound; │ │415 - var prev_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │416 - _ = try kv.key_cursor.readBytes(&prev_key); │ │417 - const prev_hex = std.fmt.bytesToHex(prev_key[@sizeOf(u64)..].*, .lower); │ │418 - prev_id = try aa.dupe(u8, &prev_hex); │ │419 - } │ │420 - break :blk try set.iteratorFrom(key); │ │421 - } else try set.iteratorFromIndex(0); │ │422 - │ │423 - // collect this window's issues, plus a peek at the one after it (its id is │ │424 - // the next window's start). the trailing bytes of each set key are the │ │425 - // issue event id. │ │426 - var issues: std.ArrayList(IssueWithId) = .empty; │ │427 - var next_id: ?[]const u8 = null; │ │428 - while (try iter.next()) |id_cursor_val| { │ │429 - var id_cursor = id_cursor_val; │ │430 - const id_kv = try id_cursor.readKeyValuePair(); │ │431 - var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │432 - _ = try id_kv.key_cursor.readBytes(&order_key); │ │433 - const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); │ │434 - if (issues.items.len == page_size) { │ │435 - next_id = try aa.dupe(u8, &id_hex); │ │436 - break; │ │437 - } │ │438 - const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, order_key[@sizeOf(u64)..])) orelse continue; │ │439 - const issue_map = try DB.HashMap(.read_only).init(issue_cursor); │ │440 - const issue_event = try evt.read(evt.Issue.Record, DB, hash_kind, arena, issue_map); │ │441 - try issues.append(aa, .{ │ │442 - .id = try aa.dupe(u8, &id_hex), │ │443 - .record = issue_event, │ │444 - .author = try ui.Author.initFromEmail(admin_moment, arena, issue_event.author_email), │ │445 - .conflicted = if (conflict_set) |cs| try cs.contains(&order_key) else false, │ │446 - .comments = try Comment.loadWindow( │ │447 - hash_kind, │ │448 - arena, │ │449 - admin_moment, │ │450 - haxy_moment, │ │451 - evt.Comment.thread_id_to_comment_id_set_key, │ │452 - &id_hex, │ │453 - if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │454 - ), │ │455 - .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │456 - }); │ │457 - } │ │458 - │ │459 - return .{ │ │460 - .items = issues.items, │ │461 - .prev_id = prev_id, │ │462 - .next_id = next_id, │ │463 - .count = @intCast(try set.count()), │ │464 - }; │ │465 -} │ │466 - │ │467 -// the selected issue's conflict entry, shaped for the resolve view. `ours` is │ │468 -// the live record. │ │469 -fn readConflict( │ │470 - comptime repo_kind: rp.RepoKind, │ │471 - comptime repo_opts: rp.RepoOpts(repo_kind), │ │472 - arena: *std.heap.ArenaAllocator, │ │473 - repo: *rp.Repo(repo_kind, repo_opts), │ │474 - io: std.Io, │ │475 - admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │476 - haxy_moment: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │477 - id_bytes: *const [evt.event_id_size]u8, │ │478 - ours: evt.Issue.Record, │ │479 - conflict_entry: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │480 -) !Conflict { │ │481 - const DB = evt.EventDB(repo_opts.hash); │ │482 - const aa = arena.allocator(); │ │483 - const gpa = arena.child_allocator; │ │484 - │ │485 - const fields_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key))) orelse return error.NotFound; │ │486 - const conflicted_fields = try fields_cursor.readBytesAlloc(aa, null); │ │487 - │ │488 - const their_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key))) orelse return error.NotFound; │ │489 - const theirs = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(their_cursor)); │ │490 - │ │491 - // absent when both sides created the issue independently │ │492 - var base: ?evt.Issue.Record = null; │ │493 - if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key))) |base_cursor| { │ │494 - base = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(base_cursor)); │ │495 - } │ │496 - │ │497 - // each side's field->oid map, for attributing the versions; a field a │ │498 - // merge left unattributed reads as an unknown author │ │499 - var their_field_oids: ?DB.SortedMap(.read_only) = null; │ │500 - if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key))) |cursor| { │ │501 - their_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │502 - } │ │503 - var our_field_oids: ?DB.SortedMap(.read_only) = null; │ │504 - if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.id_to_field_to_oid_key))) |map_cursor| { │ │505 - const id_to_field_to_oid = try DB.HashMap(.read_only).init(map_cursor); │ │506 - if (try id_to_field_to_oid.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) |cursor| { │ │507 - our_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │508 - } │ │509 - } │ │510 - │ │511 - var conflict = Conflict{}; │ │512 - var field_iter = std.mem.splitScalar(u8, conflicted_fields, ' '); │ │513 - while (field_iter.next()) |field| { │ │514 - // status can't conflict (two changes of a two-value field agree) │ │515 - const known = std.meta.stringToEnum(enum { title, tags, description }, field) orelse continue; │ │516 - const our_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, our_field_oids, field)); │ │517 - const their_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, their_field_oids, field)); │ │518 - switch (known) { │ │519 - .title => conflict.title = .{ │ │520 - .ours = .{ .text = ours.event.title, .author = our_author }, │ │521 - .theirs = .{ .text = theirs.event.title, .author = their_author }, │ │522 - }, │ │523 - .tags => conflict.tags = .{ │ │524 - .ours = .{ .text = ours.event.tags, .author = our_author }, │ │525 - .theirs = .{ .text = theirs.event.tags, .author = their_author }, │ │526 - }, │ │527 - .description => conflict.description = .{ │ │528 - .chunks = try diff3.chunks(io, gpa, arena, if (base) |b| b.event.description else "", ours.event.description, theirs.event.description), │ │529 - .ours_author = our_author, │ │530 - .theirs_author = their_author, │ │531 - }, │ │532 - } │ │533 - } │ │534 - return conflict; │ │535 -} │ │536 - │ │537 -// the oid `field` maps to in a field->oid map, or null │ │538 -fn readFieldOid( │ │539 - comptime hash_kind: hash.HashKind, │ │540 - field_oids_maybe: ?evt.EventDB(hash_kind).SortedMap(.read_only), │ │541 - field: []const u8, │ │542 -) !?[hash.byteLen(hash_kind)]u8 { │ │543 - const field_oids = field_oids_maybe orelse return null; │ │544 - const cursor = (try field_oids.getCursor(field)) orelse return null; │ │545 - var oid: [hash.byteLen(hash_kind)]u8 = undefined; │ │546 - _ = try cursor.readBytes(&oid); │ │547 - return oid; │ │334 } │ │335 │ │550 -// the author of the event commit `oid_maybe` names │ │551 -fn oidAuthor( │ │552 - comptime repo_kind: rp.RepoKind, │ │553 - comptime repo_opts: rp.RepoOpts(repo_kind), │ │554 - arena: *std.heap.ArenaAllocator, │ │555 - repo: *rp.Repo(repo_kind, repo_opts), │ │556 - io: std.Io, │ │557 - admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │558 - oid_maybe: ?[hash.byteLen(repo_opts.hash)]u8, │ │559 -) !ui.Author { │ │560 - const oid = oid_maybe orelse return .unknown; │ │561 - const gpa = arena.child_allocator; │ │562 - var start_oids = [_][hash.hexLen(repo_opts.hash)]u8{std.fmt.bytesToHex(oid, .lower)}; │ │563 - var commit_iter = repo.log(io, gpa, start_oids[0..1]) catch return .unknown; │ │564 - defer commit_iter.deinit(); │ │565 - const commit_object = (commit_iter.next(gpa) catch return .unknown) orelse return .unknown; │ │566 - defer commit_object.deinit(); │ │567 - return try ui.Author.init(admin_moment, arena, commit_object.content.commit.metadata.author orelse ""); │ │568 -} │ │569 - │ │336 pub const View = Threads.View(.issue, Self); │ │337 │ │572 -// tabs switching between the issues page's views. │ │573 -pub const Header = struct { │ │574 - box: wgt.Box(ui.Widget), │ │575 - tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), │ │338 +pub const Header = Threads.Header; │ │339 │ │577 - pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { │ │578 - var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │579 - errdefer box.deinit(allocator); │ │580 - │ │581 - var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; │ │582 - errdefer tab_ids.deinit(allocator); │ │583 - │ │584 - const aa = session.page_arena.allocator(); │ │585 - │ │586 - // a list tab per status, labeled with its listing's issue count │ │587 - for ([_]evt.Issue.Status{ .open, .closed }) |status| { │ │588 - const route = ui.RoutablePage.repoIssuesRoute(data.identity, status, data.tag, "") orelse return error.RouteTooLong; │ │589 - const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │590 - var label_buf: [64]u8 = undefined; │ │591 - const label = try std.fmt.bufPrint(&label_buf, "{s} ({d})", .{ @tagName(status), data.window(status).count }); │ │592 - try addTab(allocator, &box, &tab_ids, label, link); │ │593 - } │ │594 - │ │595 - // tags tab, labeled with the active tag filter │ │596 - { │ │597 - const tags_route = ui.RoutablePage.repoThreadTagsRoute(.issue, data.identity, data.tag) orelse return error.RouteTooLong; │ │598 - const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); │ │599 - const label = if (data.tag.len == 0) "tags" else blk: { │ │600 - const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); │ │601 - break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); │ │602 - }; │ │603 - try addTab(allocator, &box, &tab_ids, label, tags_link); │ │604 - } │ │340 +// tabs switching between the issues page's views │ │341 +pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { │ │342 + var header = try Header.init(allocator); │ │343 + errdefer header.deinit(allocator); │ │344 + const aa = session.page_arena.allocator(); │ │345 │ │606 - // new-issue tab; an edit or resolve url shows its tab in this place │ │607 - { │ │608 - const route = switch (data.view) { │ │609 - .edit => ui.RoutablePage.repoThreadEditRoute(.issue, data.identity, data.selected_id) orelse return error.RouteTooLong, │ │610 - .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │611 - .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │612 - .remove => ui.RoutablePage.repoThreadRemoveRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │613 - .resolve => ui.RoutablePage.repoIssuesResolveRoute(data.identity, data.selected_id, data.theirs_picks) orelse return error.RouteTooLong, │ │614 - else => ui.RoutablePage.repoThreadNewRoute(.issue, data.identity) orelse return error.RouteTooLong, │ │615 - }; │ │616 - const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │617 - const label: []const u8 = switch (data.view) { │ │618 - .edit => "edit", │ │619 - .new_comment => "reply", │ │620 - .edit_comment => "edit", │ │621 - .remove => "remove", │ │622 - .resolve => "resolve", │ │623 - else => "new", │ │624 - }; │ │625 - try addTab(allocator, &box, &tab_ids, label, link); │ │626 - } │ │627 - │ │628 - // conflicts tab, labeled with the conflict count │ │629 - if (data.conflicts.count > 0) { │ │630 - const route = ui.RoutablePage.repoIssuesConflictsRoute(data.identity, "") orelse return error.RouteTooLong; │ │631 - const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │632 - var label_buf: [64]u8 = undefined; │ │633 - const label = try std.fmt.bufPrint(&label_buf, "conflicts ({d})", .{data.conflicts.count}); │ │634 - try addTab(allocator, &box, &tab_ids, label, link); │ │635 - } │ │636 - │ │637 - var self = Header{ .box = box, .tab_ids = tab_ids }; │ │638 - // the tab matching the page's view is selected initially. │ │639 - self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; │ │640 - return self; │ │641 - } │ │642 - │ │643 - fn addTab(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), tab_ids: *std.AutoArrayHashMapUnmanaged(usize, void), label: []const u8, link: []const u8) !void {│ │644 - var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │645 - errdefer text_box.deinit(allocator); │ │646 - text_box.getFocus().focusable = true; │ │647 - text_box.getFocus().kind = .{ .custom = link }; │ │648 - try tab_ids.put(allocator, text_box.getFocus().id, {}); │ │649 - try box.children.put(allocator, text_box.getFocus().id, .{ │ │650 - .widget = .{ .text_box = text_box }, │ │651 - .rect = null, │ │652 - .min_size = .{ .width = label.len + 2, .height = null }, │ │653 - }); │ │654 - } │ │655 - │ │656 - pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { │ │657 - self.box.deinit(allocator); │ │658 - self.tab_ids.deinit(allocator); │ │659 - } │ │660 - │ │661 - pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │662 - self.clearGrid(); │ │663 - // only the selected tab shows its border │ │664 - for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │665 - switch (child.widget) { │ │666 - .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, │ │667 - else => {}, │ │668 - } │ │669 - } │ │670 - try self.box.build(allocator, constraint, root_focus); │ │671 - } │ │672 - │ │673 - pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │674 - _ = allocator; │ │675 - const current_tab = self.currentTabIndex() orelse return; │ │676 - if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { │ │677 - root_focus.setFocus(self.tab_ids.keys()[new_tab]); │ │678 - } │ │346 + // a list tab per status, labeled with its listing's issue count │ │347 + for ([_]evt.Issue.Status{ .open, .closed }, 0..) |status, index| { │ │348 + const route = ui.RoutablePage.repoIssuesRoute(data.identity, status, data.tag, "") orelse return error.RouteTooLong; │ │349 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │350 + var label_buf: [64]u8 = undefined; │ │351 + const label = try std.fmt.bufPrint(&label_buf, "{s} ({d})", .{ @tagName(status), data.window(status).count }); │ │352 + try header.addTab(allocator, label, link, index); │ │353 } │ │354 │ │681 - pub fn clearGrid(self: *Header) void { │ │682 - self.box.clearGrid(); │ │355 + // tags tab, labeled with the active tag filter │ │356 + { │ │357 + const tags_route = ui.RoutablePage.repoThreadTagsRoute(.issue, data.identity, data.tag) orelse return error.RouteTooLong; │ │358 + const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); │ │359 + const label = if (data.tag.len == 0) "tags" else blk: { │ │360 + const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); │ │361 + break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); │ │362 + }; │ │363 + try header.addTab(allocator, label, tags_link, View.viewIndex(.tags)); │ │364 } │ │365 │ │685 - pub fn getGrid(self: Header) ?Grid { │ │686 - return self.box.getGrid(); │ │687 - } │ │688 - │ │689 - pub fn getFocus(self: *Header) *Focus { │ │690 - return self.box.getFocus(); │ │366 + // new-issue tab; an edit or resolve url shows its tab in this place │ │367 + { │ │368 + const route = switch (data.view) { │ │369 + .edit => ui.RoutablePage.repoThreadEditRoute(.issue, data.identity, data.selected_id) orelse return error.RouteTooLong, │ │370 + .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │371 + .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │372 + .remove => ui.RoutablePage.repoThreadRemoveRoute(.issue, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │373 + .resolve => ui.RoutablePage.repoIssuesResolveRoute(data.identity, data.selected_id, data.theirs_picks) orelse return error.RouteTooLong, │ │374 + else => ui.RoutablePage.repoThreadNewRoute(.issue, data.identity) orelse return error.RouteTooLong, │ │375 + }; │ │376 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │377 + const label: []const u8 = switch (data.view) { │ │378 + .edit => "edit", │ │379 + .new_comment => "reply", │ │380 + .edit_comment => "edit", │ │381 + .remove => "remove", │ │382 + .resolve => "resolve", │ │383 + else => "new", │ │384 + }; │ │385 + try header.addTab(allocator, label, link, View.viewIndex(.new)); │ │386 } │ │387 │ │693 - pub fn getSelectedIndex(self: Header) ?usize { │ │694 - return self.currentTabIndex(); │ │388 + // conflicts tab, labeled with the conflict count │ │389 + if (data.conflicts.count > 0) { │ │390 + const route = ui.RoutablePage.repoIssuesConflictsRoute(data.identity, "") orelse return error.RouteTooLong; │ │391 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │392 + var label_buf: [64]u8 = undefined; │ │393 + const label = try std.fmt.bufPrint(&label_buf, "conflicts ({d})", .{data.conflicts.count}); │ │394 + try header.addTab(allocator, label, link, View.viewIndex(.conflicts)); │ │395 } │ │396 │ │697 - fn currentTabIndex(self: Header) ?usize { │ │698 - const child_id = self.box.focus.child_id orelse return null; │ │699 - return self.tab_ids.getIndex(child_id); │ │700 - } │ │701 -}; │ │397 + header.select(View.viewIndex(data.view)); │ │398 + return header; │ │399 +} │ │400 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Threads.zig│ ╰───────────────────────╯ ╭───────────────────────────────────────╮ │ 4 const ui = @import("../../ui.zig");│ │ 5 const xit = @import("xit"); │ │ 6 const rp = xit.repo; │ │ 7 +const hash = xit.hash; │ │ 8 const xitui = xit.xitui; │ │ 9 const wgt = xitui.widget; │ │10 const layout = xitui.layout; │ ╰───────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 17 const Attachment = @import("Attachment.zig"); │ │ 18 │ │ 19 const wasm = builtin.target.cpu.arch == .wasm32; │ │ 20 + │ │ 21 +// read the selected thread's conflicted fields and attribute each side to its │ │ 22 +// event commit author │ │ 23 +pub fn readConflict( │ │ 24 + comptime Data: type, │ │ 25 + comptime repo_kind: rp.RepoKind, │ │ 26 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │ 27 + arena: *std.heap.ArenaAllocator, │ │ 28 + repo: *rp.Repo(repo_kind, repo_opts), │ │ 29 + io: std.Io, │ │ 30 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │ 31 + haxy_moment: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │ 32 + id_bytes: *const [evt.event_id_size]u8, │ │ 33 + ours: Data.Event.Record, │ │ 34 + conflict_entry: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │ 35 +) !Data.Conflict { │ │ 36 + const Event = Data.Event; │ │ 37 + const DB = evt.EventDB(repo_opts.hash); │ │ 38 + const aa = arena.allocator(); │ │ 39 + │ │ 40 + const fields_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key))) orelse return error.NotFound; │ │ 41 + const conflicted_fields = try fields_cursor.readBytesAlloc(aa, null); │ │ 42 + const their_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key))) orelse return error.NotFound; │ │ 43 + const theirs = try evt.read(Event.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(their_cursor)); │ │ 44 + │ │ 45 + // absent when both sides created the thread independently │ │ 46 + var base: ?Event.Record = null; │ │ 47 + if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key))) |base_cursor| { │ │ 48 + base = try evt.read(Event.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(base_cursor)); │ │ 49 + } │ │ 50 + │ │ 51 + var their_field_oids: ?DB.SortedMap(.read_only) = null; │ │ 52 + if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key))) |cursor| │ │ 53 + their_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │ 54 + var our_field_oids: ?DB.SortedMap(.read_only) = null; │ │ 55 + if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, Event.id_to_field_to_oid_key))) |map_cursor| { │ │ 56 + const id_to_field_to_oid = try DB.HashMap(.read_only).init(map_cursor); │ │ 57 + if (try id_to_field_to_oid.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) |cursor| │ │ 58 + our_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │ 59 + } │ │ 60 + │ │ 61 + var conflict = Data.Conflict{}; │ │ 62 + var field_iter = std.mem.splitScalar(u8, conflicted_fields, ' '); │ │ 63 + while (field_iter.next()) |field| { │ │ 64 + const our_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, our_field_oids, field)); │ │ 65 + const their_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, their_field_oids, field)); │ │ 66 + if (std.mem.eql(u8, field, "title")) { │ │ 67 + conflict.title = .{ │ │ 68 + .ours = .{ .text = ours.event.title, .author = our_author }, │ │ 69 + .theirs = .{ .text = theirs.event.title, .author = their_author }, │ │ 70 + }; │ │ 71 + } else if (std.mem.eql(u8, field, "tags")) { │ │ 72 + conflict.tags = .{ │ │ 73 + .ours = .{ .text = ours.event.tags, .author = our_author }, │ │ 74 + .theirs = .{ .text = theirs.event.tags, .author = their_author }, │ │ 75 + }; │ │ 76 + } else if (std.mem.eql(u8, field, "description")) { │ │ 77 + conflict.description = .{ │ │ 78 + .chunks = try diff3.chunks(io, arena.child_allocator, arena, if (base) |b| b.event.description else "", ours.event.description, theirs.event.description),│ │ 79 + .ours_author = our_author, │ │ 80 + .theirs_author = their_author, │ │ 81 + }; │ │ 82 + } else if (comptime @hasField(Data.Conflict, "status")) { │ │ 83 + if (std.mem.eql(u8, field, "status")) { │ │ 84 + conflict.status = .{ │ │ 85 + .ours = .{ .text = @tagName(ours.event.status), .author = our_author }, │ │ 86 + .theirs = .{ .text = @tagName(theirs.event.status), .author = their_author }, │ │ 87 + }; │ │ 88 + } else if (std.mem.eql(u8, field, "revision")) { │ │ 89 + conflict.revision = .{ │ │ 90 + .ours = .{ .text = try revisionSummary(aa, ours.event.revision), .author = our_author }, │ │ 91 + .theirs = .{ .text = try revisionSummary(aa, theirs.event.revision), .author = their_author }, │ │ 92 + }; │ │ 93 + } │ │ 94 + } │ │ 95 + } │ │ 96 + return conflict; │ │ 97 +} │ │ 98 + │ │ 99 +fn revisionSummary(allocator: std.mem.Allocator, revision_maybe: ?evt.Patch.Revision) ![]const u8 { │ │100 + const revision = revision_maybe orelse return "(none)"; │ │101 + return std.fmt.allocPrint(allocator, "{s}\nsquash {s}\nsource {s}", .{ revision.target_ref, revision.squash_oid, revision.source_oid }); │ │102 +} │ │103 + │ │104 +fn readFieldOid( │ │105 + comptime hash_kind: hash.HashKind, │ │106 + field_oids_maybe: ?evt.EventDB(hash_kind).SortedMap(.read_only), │ │107 + field: []const u8, │ │108 +) !?[hash.byteLen(hash_kind)]u8 { │ │109 + const field_oids = field_oids_maybe orelse return null; │ │110 + const cursor = (try field_oids.getCursor(field)) orelse return null; │ │111 + var oid: [hash.byteLen(hash_kind)]u8 = undefined; │ │112 + _ = try cursor.readBytes(&oid); │ │113 + return oid; │ │114 +} │ │115 + │ │116 +fn oidAuthor( │ │117 + comptime repo_kind: rp.RepoKind, │ │118 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │119 + arena: *std.heap.ArenaAllocator, │ │120 + repo: *rp.Repo(repo_kind, repo_opts), │ │121 + io: std.Io, │ │122 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │123 + oid_maybe: ?[hash.byteLen(repo_opts.hash)]u8, │ │124 +) !ui.Author { │ │125 + const oid = oid_maybe orelse return .unknown; │ │126 + const gpa = arena.child_allocator; │ │127 + var start_oids = [_][hash.hexLen(repo_opts.hash)]u8{std.fmt.bytesToHex(oid, .lower)}; │ │128 + var commit_iter = repo.log(io, gpa, start_oids[0..1]) catch return .unknown; │ │129 + defer commit_iter.deinit(); │ │130 + const commit_object = (commit_iter.next(gpa) catch return .unknown) orelse return .unknown; │ │131 + defer commit_object.deinit(); │ │132 + return try ui.Author.init(admin_moment, arena, commit_object.content.commit.metadata.author orelse ""); │ │133 +} │ │134 + │ │135 +pub fn statusSet( │ │136 + comptime Data: type, │ │137 + comptime DB: type, │ │138 + statuses: DB.SortedMap(.read_only), │ │139 + status: Data.Status, │ │140 +) !?DB.SortedSet(.read_only) { │ │141 + const cursor = (try statuses.getCursor(@tagName(status))) orelse return null; │ │142 + return try DB.SortedSet(.read_only).init(cursor); │ │143 +} │ │144 + │ │145 +pub fn tagStatusSet( │ │146 + comptime Data: type, │ │147 + comptime DB: type, │ │148 + tag_statuses: DB.SortedMap(.read_only), │ │149 + tag: []const u8, │ │150 + status: Data.Status, │ │151 +) !?DB.SortedSet(.read_only) { │ │152 + var key_buffer: Data.Event.TagStatusKey = undefined; │ │153 + const key = Data.Event.tagStatusKey(&key_buffer, tag, status) catch return null; │ │154 + const cursor = (try tag_statuses.getCursor(key)) orelse return null; │ │155 + return try DB.SortedSet(.read_only).init(cursor); │ │156 +} │ │157 + │ │158 +pub fn loadTags( │ │159 + comptime Data: type, │ │160 + comptime hash_kind: hash.HashKind, │ │161 + arena: *std.heap.ArenaAllocator, │ │162 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │163 +) ![]const []const u8 { │ │164 + const aa = arena.allocator(); │ │165 + const DB = evt.EventDB(hash_kind); │ │166 + const cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, Data.Event.tag_status_to_id_set_key)) orelse return &.{}; │ │167 + const tag_statuses = try DB.SortedMap(.read_only).init(cursor); │ │168 + var tags: std.ArrayList([]const u8) = .empty; │ │169 + var iter = try tag_statuses.iterator(); │ │170 + while (try iter.next()) |pair_cursor| { │ │171 + if (tags.items.len == Data.max_tags) break; │ │172 + var entry_cursor = pair_cursor; │ │173 + const pair = try entry_cursor.readKeyValuePair(); │ │174 + const key = try pair.key_cursor.readBytesAlloc(aa, null); │ │175 + const space = std.mem.indexOfScalar(u8, key, ' ') orelse continue; │ │176 + const tag = key[0..space]; │ │177 + if (tags.getLastOrNull()) |last| { │ │178 + if (std.mem.eql(u8, last, tag)) continue; │ │179 + } │ │180 + try tags.append(aa, tag); │ │181 + } │ │182 + return tags.items; │ │183 +} │ │184 │ │185 +pub fn loadWindow( │ │186 + comptime Data: type, │ │187 + comptime hash_kind: hash.HashKind, │ │188 + arena: *std.heap.ArenaAllocator, │ │189 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │190 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │191 + records: evt.EventDB(hash_kind).HashMap(.read_only), │ │192 + set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │193 + root_key: ?[]const u8, │ │194 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │195 + selected_id: []const u8, │ │196 + comments_start: usize, │ │197 +) !Data.Window { │ │198 + const set = set_maybe orelse return .empty; │ │199 + const DB = evt.EventDB(hash_kind); │ │200 + const aa = arena.allocator(); │ │201 + │ │202 + var prev_id: ?[]const u8 = null; │ │203 + var iter = if (root_key) |key| blk: { │ │204 + const rank = try set.rank(key); │ │205 + if (rank > 0 and rank <= Data.page_size) { │ │206 + prev_id = ""; │ │207 + } else if (rank > Data.page_size) { │ │208 + const pair = try set.getIndexKeyValuePair(@intCast(rank - Data.page_size)) orelse return error.NotFound; │ │209 + var previous: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │210 + _ = try pair.key_cursor.readBytes(&previous); │ │211 + const hex = std.fmt.bytesToHex(previous[@sizeOf(u64)..].*, .lower); │ │212 + prev_id = try aa.dupe(u8, &hex); │ │213 + } │ │214 + break :blk try set.iteratorFrom(key); │ │215 + } else try set.iteratorFromIndex(0); │ │216 + │ │217 + var items: std.ArrayList(Data.Entry) = .empty; │ │218 + var next_id: ?[]const u8 = null; │ │219 + while (try iter.next()) |cursor_value| { │ │220 + var cursor = cursor_value; │ │221 + const pair = try cursor.readKeyValuePair(); │ │222 + var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │223 + _ = try pair.key_cursor.readBytes(&order_key); │ │224 + const id = order_key[@sizeOf(u64)..]; │ │225 + const id_hex = std.fmt.bytesToHex(id.*, .lower); │ │226 + if (items.items.len == Data.page_size) { │ │227 + next_id = try aa.dupe(u8, &id_hex); │ │228 + break; │ │229 + } │ │230 + const record_cursor = try records.getCursor(hash.hashInt(hash_kind, id)) orelse continue; │ │231 + const record = try evt.read(Data.Event.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); │ │232 + try items.append(aa, .{ │ │233 + .id = try aa.dupe(u8, &id_hex), │ │234 + .record = record, │ │235 + .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), │ │236 + .conflicted = if (conflict_set) |conflicts| try conflicts.contains(&order_key) else false, │ │237 + .comments = try Comment.loadWindow( │ │238 + hash_kind, │ │239 + arena, │ │240 + admin_moment, │ │241 + haxy_moment, │ │242 + evt.Comment.thread_id_to_comment_id_set_key, │ │243 + &id_hex, │ │244 + if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │245 + ), │ │246 + .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │247 + }); │ │248 + } │ │249 + │ │250 + return .{ │ │251 + .items = items.items, │ │252 + .prev_id = prev_id, │ │253 + .next_id = next_id, │ │254 + .count = @intCast(try set.count()), │ │255 + }; │ │256 +} │ │257 + │ │258 +// the common tab strip used by each thread page's sub-header │ │259 +pub const Header = struct { │ │260 + box: wgt.Box(ui.Widget), │ │261 + // focus id -> semantic stack index; some pages omit unavailable tabs │ │262 + tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │263 + │ │264 + pub fn init(allocator: std.mem.Allocator) !Header { │ │265 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │266 + errdefer box.deinit(allocator); │ │267 + return .{ .box = box, .tab_ids = .empty }; │ │268 + } │ │269 + │ │270 + pub fn addTab(self: *Header, allocator: std.mem.Allocator, label: []const u8, link: []const u8, view_index: usize) !void { │ │271 + var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │272 + errdefer text_box.deinit(allocator); │ │273 + text_box.getFocus().focusable = true; │ │274 + text_box.getFocus().kind = .{ .custom = link }; │ │275 + try self.tab_ids.put(allocator, text_box.getFocus().id, view_index); │ │276 + try self.box.children.put(allocator, text_box.getFocus().id, .{ │ │277 + .widget = .{ .text_box = text_box }, │ │278 + .rect = null, │ │279 + .min_size = .{ .width = label.len + 2, .height = null }, │ │280 + }); │ │281 + } │ │282 + │ │283 + pub fn select(self: *Header, view_index: usize) void { │ │284 + for (self.tab_ids.keys(), self.tab_ids.values()) |id, index| { │ │285 + if (index == view_index) { │ │286 + self.getFocus().child_id = id; │ │287 + return; │ │288 + } │ │289 + } │ │290 + } │ │291 + │ │292 + pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { │ │293 + self.box.deinit(allocator); │ │294 + self.tab_ids.deinit(allocator); │ │295 + } │ │296 + │ │297 + pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │298 + self.clearGrid(); │ │299 + // only the selected tab shows its border │ │300 + for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │301 + switch (child.widget) { │ │302 + .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, │ │303 + else => {}, │ │304 + } │ │305 + } │ │306 + try self.box.build(allocator, constraint, root_focus); │ │307 + } │ │308 + │ │309 + pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │310 + _ = allocator; │ │311 + const current_tab = self.currentTabIndex() orelse return; │ │312 + if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { │ │313 + root_focus.setFocus(self.tab_ids.keys()[new_tab]); │ │314 + } │ │315 + } │ │316 + │ │317 + pub fn clearGrid(self: *Header) void { │ │318 + self.box.clearGrid(); │ │319 + } │ │320 + │ │321 + pub fn getGrid(self: Header) ?Grid { │ │322 + return self.box.getGrid(); │ │323 + } │ │324 + │ │325 + pub fn getFocus(self: *Header) *Focus { │ │326 + return self.box.getFocus(); │ │327 + } │ │328 + │ │329 + pub fn getSelectedIndex(self: Header) ?usize { │ │330 + const index = self.currentTabIndex() orelse return null; │ │331 + return self.tab_ids.values()[index]; │ │332 + } │ │333 + │ │334 + fn currentTabIndex(self: Header) ?usize { │ │335 + const child_id = self.box.focus.child_id orelse return null; │ │336 + return self.tab_ids.getIndex(child_id); │ │337 + } │ │338 +}; │ │339 + │ │340 pub fn View(comptime kind: evt.EventKind, comptime Data: type) type { │ │341 const Self = Data; │ │ 22 - const Header = Data.Header; │ │342 + const HeaderType = Data.Header; │ │343 const Window = Data.Window; │ │344 const Entry = Data.Entry; │ │345 const Event = Data.Event; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │346 const Status = Data.Status; │ │347 const has_status = @hasField(Event, "status"); │ │348 const has_conflicts = Event.merge_policy == .field_conflicts; │ │349 + const has_drafts = @hasField(Self, "drafts"); │ │350 const FieldConflict = if (has_conflicts) Data.FieldConflict else void;│ │351 const ViewKind = Data.ViewKind; │ │352 const thread_name = switch (kind) { │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────╮ │353 .issue => "issue", │ │354 + .patch => "patch", │ │355 .discuss => "discussion", │ │356 else => @compileError("unsupported thread event kind"),│ │357 }; │ ╰────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │359 return struct { │ │360 const This = @This(); │ │361 // a vertical box: the header tabs on top, then a stack holding a │ │ 40 - // master-detail split (issue list + description pane) per status list, │ │362 + // master-detail split (thread list + description pane) per status list, │ │363 // plus the tags view. │ │364 box: wgt.Box(ui.Widget), // vert: [header_index] = tabs, [stack_index] = stack│ │365 data: *const Self, │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │366 session: *ui.Session, │ │ 45 - // per-split state, indexed like the stack's split children: the issue the │ │ 46 - // pane shows, and its description, author and open/close button focus ids.│ │367 + // per-split state, indexed like the stack's split children: the thread the│ │368 + // pane shows and its focus ids. │ │369 detailed_index: [view_count]?usize, │ │370 description_id: [view_count]?usize, │ │371 author_id: [view_count]?usize, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯