╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │172 identity: []const u8, │ │173 tag: []const u8, │ │174 selected_id: []const u8, │ │175 + theirs_picks: []const u8, │ │176 view: ui.RoutablePage.IssuesView, │ │177 ) !Self { │ │128 - const empty = try emptyResult(arena.allocator(), identity, tag, selected_id, view); │ │178 + const empty = try emptyResult(arena.allocator(), identity, tag, selected_id, theirs_picks, view);│ │179 │ │180 const aa = arena.allocator(); │ │181 const DB = evt.EventDB(repo_opts.hash); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │223 }; │ │224 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ │225 │ │176 - // a named issue roots its own status's window at itself; the other window │ │177 - // starts at the beginning. │ │226 + // the conflicted issues' container: a set view for membership and │ │227 + // windowing, a map view for the resolve entry, over the same cursor. │ │228 + var conflict_set: ?DB.SortedSet(.read_only) = null; │ │229 + var conflicts_map: ?DB.SortedMap(.read_only) = null; │ │230 + if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key))) |cursor| {│ │231 + conflict_set = try DB.SortedSet(.read_only).init(cursor); │ │232 + conflicts_map = try DB.SortedMap(.read_only).init(cursor); │ │233 + } │ │234 + │ │235 + // a named issue roots its own status's window at itself (the conflicts │ │236 + // view's window instead); the other windows start at the beginning. │ │237 var resolved_view = empty.view; │ │238 var open_root: ?[]const u8 = null; │ │239 var closed_root: ?[]const u8 = null; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │240 + var conflicts_root: ?[]const u8 = null; │ │241 + var conflict_data: ?Conflict = null; │ │242 if (rooted) { │ │243 if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound;│ │244 var id_bytes: [evt.event_id_size]u8 = undefined; │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │248 const issue_event = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, issue_map); │ │249 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(issue_event.created_ts, &id_bytes)); │ │250 │ │190 - // the named issue must be in its windowed set (a tag url can name an │ │191 - // issue that doesn't carry the tag). │ │192 - const set = (switch (issue_event.event.status) { │ │193 - .open => open_set, │ │194 - .closed => closed_set, │ │195 - }) orelse return error.NotFound; │ │196 - if (!try set.contains(order_key)) return error.NotFound; │ │251 + if (view == .conflicts) { │ │252 + // the id roots the conflicts window, so it must be conflicted │ │253 + const set = conflict_set orelse return error.NotFound; │ │254 + if (!try set.contains(order_key)) return error.NotFound; │ │255 + conflicts_root = order_key; │ │256 + } else { │ │257 + // the named issue must be in its windowed set (a tag url can name │ │258 + // an issue that doesn't carry the tag). │ │259 + const set = (switch (issue_event.event.status) { │ │260 + .open => open_set, │ │261 + .closed => closed_set, │ │262 + }) orelse return error.NotFound; │ │263 + if (!try set.contains(order_key)) return error.NotFound; │ │264 │ │198 - switch (issue_event.event.status) { │ │199 - .open => open_root = order_key, │ │200 - .closed => closed_root = order_key, │ │265 + switch (issue_event.event.status) { │ │266 + .open => open_root = order_key, │ │267 + .closed => closed_root = order_key, │ │268 + } │ │269 + // an edit url keeps its view; otherwise the issue's status picks it. │ │270 + if (view != .edit) resolved_view = switch (issue_event.event.status) { │ │271 + .open => .open, │ │272 + .closed => .closed, │ │273 + }; │ │274 + │ │275 + // a resolve url shows the resolve view only while the issue still │ │276 + // has a conflict entry; otherwise the status list stands. the │ │277 + // wasm client renders from the snapshot, so the diff machinery │ │278 + // this pulls in is gated out of its build. │ │279 + if (comptime !wasm) { │ │280 + if (view == .resolve) { │ │281 + if (conflicts_map) |map| { │ │282 + if (try map.getCursor(order_key)) |conflict_cursor| { │ │283 + const conflict_entry = try DB.HashMap(.read_only).init(conflict_cursor); │ │284 + conflict_data = try readConflict(repo_kind, repo_opts, arena, repo, io, admin_moment, haxy_moment, &id_bytes, issue_event, conflict_entry);│ │285 + resolved_view = .resolve; │ │286 + } │ │287 + } │ │288 + } │ │289 + } │ │290 } │ │202 - // an edit url keeps its view; otherwise the issue's status picks it. │ │203 - if (empty.view != .edit) resolved_view = switch (issue_event.event.status) { │ │204 - .open => .open, │ │205 - .closed => .closed, │ │206 - }; │ │291 } │ │292 │ │209 - const open_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, open_set, open_root); │ │210 - const closed_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, closed_set, closed_root); │ │293 + const open_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, open_set, open_root, conflict_set); │ │294 + const closed_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, closed_set, closed_root, conflict_set); │ │295 + const conflicts_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, conflict_set, conflicts_root, conflict_set); │ │296 + if (view == .conflicts and conflicts_window.count > 0) resolved_view = .conflicts; │ │297 │ │298 // every tag in the repo, in the tag map's sorted order. the keys are │ │299 // "tag,status", so a tag's entries are adjacent and dedup by prefix. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │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; │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯