╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭─────────────────────────────────────────╮ │1 const std = @import("std"); │ │2 const evt = @import("../event.zig"); │ │3 +const diff3 = @import("../diff3.zig");│ │4 const xit = @import("xit"); │ │5 const rp = xit.repo; │ │6 const hash = xit.hash; │ ╰─────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │177 return DB.SortedSet(.read_write).init(cursor); │ │178 } │ │179 │ │179 -// what an update replaces on an issue: its status, or its content │ │180 +// the resolve form's inputs: null fields keep the live values, `hunks` holds │ │181 +// the description conflict hunks' resolutions in chunk order (absent │ │182 +// trailing ones keep our side) │ │183 +pub const Resolve = struct { │ │184 + title: ?[]const u8 = null, │ │185 + tags: ?[]const u8 = null, │ │186 + hunks: []const []const u8 = &.{}, │ │187 +}; │ │188 + │ │189 +// what an update replaces on an issue: its status, its content, or — for a │ │190 +// conflicted issue — the resolution of its conflict │ │191 pub const Update = union(enum) { │ │192 status: Status, │ │193 fields: struct { title: []const u8, tags: []const u8, description: []const u8 },│ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │194 + resolve: Resolve, │ │195 }; │ │196 │ │197 // re-emit the issue `id_bytes` names with `change` applied, preserving the│ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │186 -// rest of its current state. error.NotFound when the id names no issue. │ │187 -// `repo` must be writable. │ │198 +// rest of its current state. error.NotFound when the id names no issue, │ │199 +// error.InvalidFields when a resolve composes invalid fields. `repo` must be│ │200 +// writable. │ │201 pub fn update( │ │202 comptime repo_kind: rp.RepoKind, │ │203 comptime repo_opts: rp.RepoOpts(repo_kind), │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │223 updated.title = fields.title; │ │224 updated.tags = fields.tags; │ │225 updated.description = fields.description; │ │226 + }, │ │227 + .resolve => |resolve| { │ │228 + updated = try resolveFields(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes, issue, resolve);│ │229 + if (!fieldsValid(updated.title, updated.tags)) return error.InvalidFields; │ │230 }, │ │231 } │ │232 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │236 .author_email = author_email, │ │237 .event = .{ .issue = updated }, │ │238 }}); │ │239 +} │ │240 + │ │241 +// the live fields with `resolve` applied: posted title/tags stand in for the │ │242 +// live ones, and a conflicted description is reassembled from the hunk │ │243 +// resolutions against the conflict entry. the chunking is deterministic: the │ │244 +// entry holds immutable slots and any event since would have removed it, so │ │245 +// the hunks match what the resolve form was built from. a settled or │ │246 +// unreadable entry keeps the live description. │ │247 +fn resolveFields( │ │248 + comptime repo_kind: rp.RepoKind, │ │249 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │250 + io: std.Io, │ │251 + allocator: std.mem.Allocator, │ │252 + arena: *std.heap.ArenaAllocator, │ │253 + repo: *rp.Repo(repo_kind, repo_opts), │ │254 + id_bytes: *const [evt.event_id_size]u8, │ │255 + live: Record, │ │256 + resolve: Resolve, │ │257 +) !Self { │ │258 + const DB = evt.EventDB(repo_opts.hash); │ │259 + const aa = arena.allocator(); │ │260 + │ │261 + var updated = live.event; │ │262 + if (resolve.title) |title| updated.title = title; │ │263 + if (resolve.tags) |tags| updated.tags = tags; │ │264 + │ │265 + var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null;│ │266 + defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); │ │267 + const moment = (if (event_db_maybe) |*event_db| │ │268 + evt.currentMomentFromDb(repo_opts.hash, event_db.db) │ │269 + else if (repo_kind == .git) │ │270 + return updated │ │271 + else │ │272 + evt.currentMoment(repo_opts, repo)) catch return updated; │ │273 + │ │274 + const conflicts_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, conflicts_key))) orelse return updated; │ │275 + const conflicts = try DB.SortedMap(.read_only).init(conflicts_cursor); │ │276 + const order_key = evt.orderKeyDesc(live.created_ts, id_bytes); │ │277 + const conflict_cursor = (try conflicts.getCursor(&order_key)) orelse return updated; │ │278 + const entry = try DB.HashMap(.read_only).init(conflict_cursor); │ │279 + │ │280 + const fields_cursor = (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key))) orelse return updated; │ │281 + const conflicted_fields = try fields_cursor.readBytesAlloc(aa, null); │ │282 + if (std.mem.indexOf(u8, conflicted_fields, "description") == null) return updated; │ │283 + │ │284 + const their_cursor = (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key))) orelse return updated; │ │285 + const theirs = try evt.read(Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(their_cursor)); │ │286 + │ │287 + // absent when both sides created the issue independently │ │288 + var base_description: []const u8 = ""; │ │289 + if (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key))) |base_cursor| { │ │290 + const base = try evt.read(Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(base_cursor)); │ │291 + base_description = base.event.description; │ │292 + } │ │293 + │ │294 + const chunks = try diff3.chunks(io, allocator, arena, base_description, live.event.description, theirs.event.description); │ │295 + var resolutions: std.ArrayList([]const u8) = .empty; │ │296 + var hunk_index: usize = 0; │ │297 + for (chunks) |chunk| { │ │298 + if (chunk != .conflict) continue; │ │299 + try resolutions.append(aa, if (hunk_index < resolve.hunks.len) resolve.hunks[hunk_index] else (chunk.conflict.ours orelse "")); │ │300 + hunk_index += 1; │ │301 + } │ │302 + updated.description = try diff3.assemble(aa, chunks, resolutions.items); │ │303 + return updated; │ │304 } │ │305 │ │306 // the issue `id_bytes` names in the repo's consumed event db (the repo's own │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │53 // in-memory only; logged-in web persistence goes through the /ansi POST. │ │54 session.applyPending(); │ │55 │ │56 + // bind the UI to the browser viewport (cols x rows) like the terminal: │ │57 + // min == max height fills it exactly, and each Scroll clips to its │ │58 + // viewport while handing its full content to a native-scrollable element. │ │59 + // a full-height page instead grows to its content and the browser scrolls │ │60 + // the whole page, which the overlay's absolute positions track. │ │61 + const max_height: ?u32 = if (session.data.current_page.fullHeight()) null else min_height;│ │62 try root_ptr.build(allocator, .{ │ │57 - // bind the UI to the browser viewport (cols x rows) like the terminal: │ │58 - // min == max height fills it exactly, and each Scroll clips to its │ │59 - // viewport while handing its full content to a native-scrollable element. │ │63 .min_size = .{ .width = null, .height = min_height }, │ │61 - .max_size = .{ .width = max_width, .height = min_height }, │ │64 + .max_size = .{ .width = max_width, .height = max_height }, │ │65 }, root_ptr.getFocus()); │ │66 │ │67 // mirror the page Header settled on into the browser URL. these are │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │381 } │ │382 } │ │383 │ │384 + // the timestamps issue's description, in paragraphs, so the │ │385 + // seeded conflict below can insert and delete among them │ │386 + const tz_p1 = "All timestamps in the activity feed render in UTC regardless of the system timezone."; │ │387 + const tz_p2 = "The tooltip on each entry repeats the same UTC value, so there is no way to see the local time without converting by hand.";│ │388 + const tz_p3 = "A quick survey of other clients shows every one of them rendering local time by default."; │ │389 + const tz_p4 = "Convert to local time and include the offset in tooltips."; │ │390 + │ │391 // seed issues so every repo's issue tracker has content │ │392 const issue_data = [_]struct { │ │393 title: []const u8, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │515 }, │ │516 .{ │ │517 .title = "Timestamps display in UTC instead of local time", │ │511 - .description = "All timestamps in the activity feed render in UTC regardless of the system timezone. Convert to local time and include the offset in tooltips.",│ │518 + .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p3 ++ "\n\n" ++ tz_p4, │ │519 .tags = "bug i18n time", │ │520 }, │ │521 .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │556 }; │ │557 } │ │558 try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &issue_events); │ │559 + // the tip is the branch point for the conflicting edits below │ │560 + const seed_tip = (try template_repo.readRef(io, evt.events_ref)) orelse unreachable; │ │561 + │ │562 + // two divergent edits per conflicted issue: ours on the events │ │563 + // branch, theirs on a temp branch rooted at the seed tip, then a │ │564 + // merge. the 4th-newest issue conflicts on title and tags; the │ │565 + // 2nd-newest on its description, with a deletion conflict (ours │ │566 + // removes a paragraph theirs rewords) and an insertion conflict │ │567 + // (each side appends a different closing paragraph). │ │568 + { │ │569 + const other_ref: xit.ref.Ref = .{ .kind = .head, .name = "haxy/other" }; │ │570 + const title_issue = issue_data[issue_data.len - 4]; │ │571 + const desc_issue = issue_data[issue_data.len - 2]; │ │572 + │ │573 + const ours = [_]evt.EventWithId{ .{ │ │574 + .id = issue_events[issue_data.len - 4].id, │ │575 + .timestamp = 100, │ │576 + .author_email = user_data[1].email, │ │577 + .event = .{ .issue = .{ │ │578 + .title = "Crash when resizing the window during the splash animation", │ │579 + .description = title_issue.description, │ │580 + .tags = "bug crash ui priority-high", │ │581 + } }, │ │582 + }, .{ │ │583 + .id = issue_events[issue_data.len - 2].id, │ │584 + .timestamp = 101, │ │585 + .author_email = user_data[1].email, │ │586 + .event = .{ .issue = .{ │ │587 + .title = desc_issue.title, │ │588 + .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ │ │589 + "The confusion is worst for teams spread across timezones, who each read a different wall-clock time from the same feed.", │ │590 + .tags = desc_issue.tags, │ │591 + } }, │ │592 + } }; │ │593 + const theirs = [_]evt.EventWithId{ .{ │ │594 + .id = issue_events[issue_data.len - 4].id, │ │595 + .timestamp = 102, │ │596 + .author_email = user_data[2].email, │ │597 + .event = .{ .issue = .{ │ │598 + .title = "Segfault on early window resize", │ │599 + .description = title_issue.description, │ │600 + .tags = "bug crash rendering", │ │601 + } }, │ │602 + }, .{ │ │603 + .id = issue_events[issue_data.len - 2].id, │ │604 + .timestamp = 103, │ │605 + .author_email = user_data[2].email, │ │606 + .event = .{ .issue = .{ │ │607 + .title = desc_issue.title, │ │608 + .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ │ │609 + "Most other clients already render local time by default, which makes our UTC output stand out as a bug." ++ "\n\n" ++ tz_p4 ++ "\n\n" ++│ │610 + "Log exports inherit the same UTC rendering, so downstream tooling has to guess the source timezone.", │ │611 + .tags = desc_issue.tags, │ │612 + } }, │ │613 + } }; │ │614 + │ │615 + try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &ours); │ │616 + │ │617 + // consume can't root a new branch, so theirs commits by hand │ │618 + var json: std.Io.Writer.Allocating = .init(allocator); │ │619 + defer json.deinit(); │ │620 + for (theirs, 0..) |event, i| { │ │621 + json.clearRetainingCapacity(); │ │622 + try std.json.Stringify.value(event, .{}, &json.writer); │ │623 + const author = try std.fmt.allocPrint(allocator, "haxy <{s}>", .{event.author_email}); │ │624 + defer allocator.free(author); │ │625 + _ = try template_repo.commitAtRef(io, allocator, .{ │ │626 + .author = author, │ │627 + .message = json.written(), │ │628 + .timestamp = event.timestamp, │ │629 + // root the branch at the shared seed tip │ │630 + .parent_oids = if (i == 0) &.{seed_tip} else null, │ │631 + }, null, other_ref); │ │632 + } │ │633 + │ │634 + { │ │635 + var to_events = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); │ │636 + defer to_events.deinit(); │ │637 + } │ │638 + { │ │639 + var merge = try template_repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, null); │ │640 + defer merge.deinit(); │ │641 + if (merge.result != .success) return error.MergeFailed; │ │642 + } │ │643 + { │ │644 + var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); │ │645 + defer to_master.deinit(); │ │646 + } │ │647 + try template_repo.removeBranch(io, .{ .name = other_ref.name }); │ │648 + } │ │649 + │ │650 + try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &.{}); │ │651 } │ │652 │ │653 // copy the template to each repo's on-disk location, named by its │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯