╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭────────────────────────────────────────────────────────╮ │321 .selected_id = empty.selected_id, │ │322 .open = open_window, │ │323 .closed = closed_window, │ │324 + .conflicts = conflicts_window, │ │325 + .conflict = conflict_data, │ │326 + .theirs_picks = empty.theirs_picks, │ │327 .view = resolved_view, │ │328 .description_page = empty.description_page,│ │329 .tags = tags.items, │ ╰────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────╮ │363 event_id_to_issue: evt.EventDB(hash_kind).HashMap(.read_only),│ │364 set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │365 root_key: ?[]const u8, │ │366 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │367 ) !Window { │ │368 const set = set_maybe orelse return .empty; │ │369 const DB = evt.EventDB(hash_kind); │ ╰───────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │409 .id = try aa.dupe(u8, &id_hex), │ │410 .issue = issue_event, │ │411 .author = try ui.Author.initFromEmail(admin_moment, arena, issue_event.author_email),│ │412 + .conflicted = if (conflict_set) |cs| try cs.contains(&order_key) else false, │ │413 }); │ │414 } │ │415 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │419 .next_id = next_id, │ │420 .count = @intCast(try set.count()), │ │421 }; │ │422 +} │ │423 + │ │424 +// the selected issue's conflict entry, shaped for the resolve view. `ours` is │ │425 +// the live record. │ │426 +fn readConflict( │ │427 + comptime repo_kind: rp.RepoKind, │ │428 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │429 + arena: *std.heap.ArenaAllocator, │ │430 + repo: *rp.Repo(repo_kind, repo_opts), │ │431 + io: std.Io, │ │432 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │433 + haxy_moment: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │434 + id_bytes: *const [evt.event_id_size]u8, │ │435 + ours: evt.Issue.Record, │ │436 + conflict_entry: evt.EventDB(repo_opts.hash).HashMap(.read_only), │ │437 +) !Conflict { │ │438 + const DB = evt.EventDB(repo_opts.hash); │ │439 + const aa = arena.allocator(); │ │440 + const gpa = arena.child_allocator; │ │441 + │ │442 + const fields_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key))) orelse return error.NotFound; │ │443 + const conflicted_fields = try fields_cursor.readBytesAlloc(aa, null); │ │444 + │ │445 + const their_cursor = (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key))) orelse return error.NotFound; │ │446 + const theirs = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(their_cursor)); │ │447 + │ │448 + // absent when both sides created the issue independently │ │449 + var base: ?evt.Issue.Record = null; │ │450 + if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key))) |base_cursor| { │ │451 + base = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(base_cursor)); │ │452 + } │ │453 + │ │454 + // each side's field->oid map, for attributing the versions; a field a │ │455 + // merge left unattributed reads as an unknown author │ │456 + var their_field_oids: ?DB.SortedMap(.read_only) = null; │ │457 + if (try conflict_entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key))) |cursor| { │ │458 + their_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │459 + } │ │460 + var our_field_oids: ?DB.SortedMap(.read_only) = null; │ │461 + if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.id_to_field_to_oid_key))) |map_cursor| { │ │462 + const id_to_field_to_oid = try DB.HashMap(.read_only).init(map_cursor); │ │463 + if (try id_to_field_to_oid.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) |cursor| { │ │464 + our_field_oids = try DB.SortedMap(.read_only).init(cursor); │ │465 + } │ │466 + } │ │467 + │ │468 + var conflict = Conflict{}; │ │469 + var field_iter = std.mem.splitScalar(u8, conflicted_fields, ' '); │ │470 + while (field_iter.next()) |field| { │ │471 + // status can't conflict (two changes of a two-value field agree) │ │472 + const known = std.meta.stringToEnum(enum { title, tags, description }, field) orelse continue; │ │473 + const our_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, our_field_oids, field)); │ │474 + const their_author = try oidAuthor(repo_kind, repo_opts, arena, repo, io, admin_moment, try readFieldOid(repo_opts.hash, their_field_oids, field));│ │475 + switch (known) { │ │476 + .title => conflict.title = .{ │ │477 + .ours = .{ .text = ours.event.title, .author = our_author }, │ │478 + .theirs = .{ .text = theirs.event.title, .author = their_author }, │ │479 + }, │ │480 + .tags => conflict.tags = .{ │ │481 + .ours = .{ .text = ours.event.tags, .author = our_author }, │ │482 + .theirs = .{ .text = theirs.event.tags, .author = their_author }, │ │483 + }, │ │484 + .description => conflict.description = .{ │ │485 + .chunks = try diff3.chunks(io, gpa, arena, if (base) |b| b.event.description else "", ours.event.description, theirs.event.description), │ │486 + .ours_author = our_author, │ │487 + .theirs_author = their_author, │ │488 + }, │ │489 + } │ │490 + } │ │491 + return conflict; │ │492 +} │ │493 + │ │494 +// the oid `field` maps to in a field->oid map, or null │ │495 +fn readFieldOid( │ │496 + comptime hash_kind: hash.HashKind, │ │497 + field_oids_maybe: ?evt.EventDB(hash_kind).SortedMap(.read_only), │ │498 + field: []const u8, │ │499 +) !?[hash.byteLen(hash_kind)]u8 { │ │500 + const field_oids = field_oids_maybe orelse return null; │ │501 + const cursor = (try field_oids.getCursor(field)) orelse return null; │ │502 + var oid: [hash.byteLen(hash_kind)]u8 = undefined; │ │503 + _ = try cursor.readBytes(&oid); │ │504 + return oid; │ │505 +} │ │506 + │ │507 +// the author of the event commit `oid_maybe` names │ │508 +fn oidAuthor( │ │509 + comptime repo_kind: rp.RepoKind, │ │510 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │511 + arena: *std.heap.ArenaAllocator, │ │512 + repo: *rp.Repo(repo_kind, repo_opts), │ │513 + io: std.Io, │ │514 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │515 + oid_maybe: ?[hash.byteLen(repo_opts.hash)]u8, │ │516 +) !ui.Author { │ │517 + const oid = oid_maybe orelse return .unknown; │ │518 + const gpa = arena.child_allocator; │ │519 + var start_oids = [_][hash.hexLen(repo_opts.hash)]u8{std.fmt.bytesToHex(oid, .lower)}; │ │520 + var commit_iter = repo.log(io, gpa, start_oids[0..1]) catch return .unknown; │ │521 + defer commit_iter.deinit(); │ │522 + const commit_object = (commit_iter.next(gpa) catch return .unknown) orelse return .unknown; │ │523 + defer commit_object.deinit(); │ │524 + return try ui.Author.init(admin_moment, arena, commit_object.content.commit.metadata.author orelse ""); │ │525 } │ │526 │ │527 pub const View = struct { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │533 session: *ui.Session, │ │534 // per-split state, indexed like the stack's split children: the issue the│ │535 // pane shows, and its description and author text boxes' focus ids. │ │342 - detailed_index: [split_count]?usize, │ │343 - description_id: [split_count]?usize, │ │344 - author_id: [split_count]?usize, │ │536 + detailed_index: [stack_child_max]?usize, │ │537 + description_id: [stack_child_max]?usize, │ │538 + author_id: [stack_child_max]?usize, │ │539 │ │540 const header_index: usize = 0; │ │541 const stack_index: usize = 1; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │543 const open_view_index: usize = 0; │ │544 const closed_view_index: usize = 1; │ │545 const tags_view_index: usize = 2; │ │352 - // the new-issue form, or the edit form when the page was loaded at an │ │353 - // edit url. │ │546 + // the new-issue form, or the edit or resolve form when the page was │ │547 + // loaded at one of their urls. │ │548 const form_view_index: usize = 3; │ │549 + // the conflicts split; the tab and stack child exist only when the repo │ │550 + // has conflicted issues. │ │551 + const conflicts_view_index: usize = 4; │ │552 + const stack_child_max: usize = conflicts_view_index + 1; │ │553 + // the leading stack children that are status splits; the conflicts split│ │554 + // sits apart at conflicts_view_index. │ │555 const split_count: usize = 2; │ │556 // indices within a split (the horizontal box inside the stack). │ │557 const list_index: usize = 0; │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │569 .open => open_view_index, │ │570 .closed => closed_view_index, │ │571 .tags => tags_view_index, │ │372 - .new, .edit => form_view_index, │ │572 + .new, .edit, .resolve => form_view_index, │ │573 + .conflicts => conflicts_view_index, │ │574 // init resolves a description url to its issue's status list│ │575 .description => unreachable, │ │576 }; │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │577 } │ │578 │ │579 + // the status a status split lists; the conflicts split has none (its│ │580 + // issues carry their own). │ │581 fn splitStatus(index: usize) evt.Issue.Status { │ │582 return if (index == open_view_index) .open else .closed; │ │583 } │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │624 try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); │ │625 } │ │626 │ │424 - // the new-issue form, or — on an edit url — the edit form in its │ │425 - // place, prefilled with the selected issue's content. a logged-out │ │426 - // session can't create events, so the unauthorized view stands in. │ │627 + // the new-issue form, or — on an edit or resolve url — that form in │ │628 + // its place, prefilled with the selected issue's content. a │ │629 + // logged-out session can't create events, so the unauthorized view │ │630 + // stands in. │ │631 if (session.data.is_local or session.data.user_id != null) { │ │428 - const aa = session.page_arena.allocator(); │ │429 - const action = if (data.view == .edit) │ │430 - (if (data.identity.len == 0) │ │431 - try std.fmt.allocPrint(aa, "form:/issues/{s}/edit", .{data.selected_id}) │ │632 + if (data.view == .resolve) { │ │633 + // on the web the page grows to the form's height and the │ │634 + // browser scrolls it; the terminal scrolls the form itself │ │635 + var form_widget: ui.Widget = blk: { │ │636 + var form = try initResolveForm(allocator, session, data); │ │637 + errdefer form.deinit(allocator); │ │638 + break :blk if (session.is_terminal) │ │639 + .{ .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = form }, .{ .direction = .vert }) }│ │640 + else │ │641 + .{ .box = form }; │ │642 + }; │ │643 + errdefer form_widget.deinit(allocator); │ │644 + try stack.children.put(allocator, form_widget.getFocus().id, form_widget); │ │645 + } else { │ │646 + const aa = session.page_arena.allocator(); │ │647 + const action = if (data.view == .edit) │ │648 + (if (data.identity.len == 0) │ │649 + try std.fmt.allocPrint(aa, "form:/issues/{s}/edit", .{data.selected_id}) │ │650 + else │ │651 + try std.fmt.allocPrint(aa, "form:/repo/{s}/issues/{s}/edit", .{ data.identity, data.selected_id }))│ │652 + else if (data.identity.len == 0) │ │653 + "form:/issue" │ │654 else │ │433 - try std.fmt.allocPrint(aa, "form:/repo/{s}/issues/{s}/edit", .{ data.identity, data.selected_id })) │ │434 - else if (data.identity.len == 0) │ │435 - "form:/issue" │ │436 - else │ │437 - try std.fmt.allocPrint(aa, "form:/repo/{s}/issue", .{data.identity}); │ │438 - const issue = if (data.view == .edit) │ │439 - (if (data.selectedIssue()) |entry| &entry.issue else null) │ │440 - else │ │441 - null; │ │442 - var form = try initIssueForm(allocator, session, action, issue); │ │443 - errdefer form.deinit(allocator); │ │444 - try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); │ │655 + try std.fmt.allocPrint(aa, "form:/repo/{s}/issue", .{data.identity}); │ │656 + const issue = if (data.view == .edit) │ │657 + (if (data.selectedIssue()) |entry| &entry.issue else null) │ │658 + else │ │659 + null; │ │660 + var form = try initIssueForm(allocator, session, action, issue); │ │661 + errdefer form.deinit(allocator); │ │662 + try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); │ │663 + } │ │664 } else { │ │665 var message = try ui.Unauthorized.View.init(allocator); │ │666 errdefer message.deinit(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │667 try stack.children.put(allocator, message.getFocus().id, .{ .unauthorized = message });│ │668 } │ │669 │ │670 + // the conflicts split; its tab exists only when the repo has │ │671 + // conflicts, so skip the child too to keep the stack 1:1 with the │ │672 + // tabs. │ │673 + if (data.conflicts.count > 0) { │ │674 + var split = try initSplit(allocator, session, data, null); │ │675 + errdefer split.deinit(allocator); │ │676 + try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); │ │677 + } │ │678 + │ │679 // the stack starts on the page's view. │ │680 stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; │ │681 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯