╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────╮ │12 const Grid = xitui.grid.Grid; │ │13 const Focus = xitui.focus.Focus; │ │14 const inp = @import("../input.zig"); │ │15 +const diff3 = @import("../../diff3.zig"); │ │16 │ │17 const wasm = builtin.target.cpu.arch == .wasm32;│ │18 │ ╰────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │33 issue: evt.Issue.Record, │ │34 // the creation commit's author │ │35 author: ui.Author = .unknown, │ │36 + // whether the issue has an unresolved merge conflict │ │37 + conflicted: bool = false, │ │38 +}; │ │39 + │ │40 +// one side of a conflicted field: its value and who set it │ │41 +pub const Side = struct { │ │42 + text: []const u8, │ │43 + author: ui.Author = .unknown, │ │44 +}; │ │45 + │ │46 +pub const FieldConflict = struct { │ │47 + ours: Side, │ │48 + theirs: Side, │ │49 +}; │ │50 + │ │51 +// the selected issue's conflict, read for the resolve view. ours is the live│ │52 +// record; the chunks split the description against the merge base. │ │53 +pub const Conflict = struct { │ │54 + title: ?FieldConflict = null, │ │55 + tags: ?FieldConflict = null, │ │56 + description: ?struct { │ │57 + chunks: []const diff3.Chunk, │ │58 + ours_author: ui.Author = .unknown, │ │59 + theirs_author: ui.Author = .unknown, │ │60 + } = null, │ │61 }; │ │62 │ │63 // one status's windowed listing. │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │84 selected_id: []const u8, │ │85 open: Window, │ │86 closed: Window, │ │87 +// the conflicted issues' listing; its count also gates the conflicts tab. │ │88 +conflicts: Window, │ │89 +// the selected issue's conflict, set when the page shows the resolve view. │ │90 +conflict: ?Conflict = null, │ │91 +// the resolve view's comma-separated fields prefilled from their side, │ │92 +// mirrored from the url. │ │93 +theirs_picks: []const u8 = "", │ │94 // the view the page shows initially (a selected issue's status overrides the route's)│ │95 view: ui.RoutablePage.IssuesView, │ │96 // the /description page: the detail pane shows the selected issue's whole │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │112 }; │ │113 } │ │114 │ │115 +// whether `field` (a field name or "d<n>" hunk name) is listed in the url's│ │116 +// theirs: picks. │ │117 +pub fn theirsPicked(self: *const Self, field: []const u8) bool { │ │118 + var it = std.mem.splitScalar(u8, self.theirs_picks, ','); │ │119 + while (it.next()) |pick| { │ │120 + if (std.mem.eql(u8, pick, field)) return true; │ │121 + } │ │122 + return false; │ │123 +} │ │124 + │ │125 // the issue `selected_id` names, in whichever window holds it. │ │126 pub fn selectedIssue(self: *const Self) ?*const IssueWithId { │ │127 if (self.selected_id.len == 0) return null; │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │134 } │ │135 │ │136 // an empty listing, for the wasm / no-repo paths. │ │ 94 -pub fn emptyResult(aa: std.mem.Allocator, identity: []const u8, tag: []const u8, selected_id: []const u8, view: ui.RoutablePage.IssuesView) !Self { │ │137 +pub fn emptyResult(aa: std.mem.Allocator, identity: []const u8, tag: []const u8, selected_id: []const u8, theirs_picks: []const u8, view: ui.RoutablePage.IssuesView) !Self {│ │138 return .{ │ │139 .identity = try aa.dupe(u8, identity), │ │140 .tag = try aa.dupe(u8, tag), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │141 .selected_id = try aa.dupe(u8, selected_id), │ │142 .open = .empty, │ │143 .closed = .empty, │ │101 - // a description url shows a status list; the issue's own status picks│ │102 - // it once the issue is read. │ │103 - .view = if (view == .description) .open else view, │ │144 + .conflicts = .empty, │ │145 + .theirs_picks = try aa.dupe(u8, theirs_picks), │ │146 + // a description url shows a status list, the issue's own status │ │147 + // picking it once the issue is read; conflicts and resolve urls also │ │148 + // start there, upgraded by init when the conflict data exists. │ │149 + .view = switch (view) { │ │150 + .description, .conflicts, .resolve => .open, │ │151 + else => view, │ │152 + }, │ │153 .description_page = view == .description, │ │154 .tags = &.{}, │ │155 }; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯