╭ changed ──────────────╮ │src/ui/Repo/Threads.zig│ ╰───────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────╮ │ 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, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────╮ │ 50 - status_button_id: [view_count]?usize, │ │372 + primary_button_id: [view_count]?usize,│ │373 │ │374 const header_index: usize = 0; │ │375 const stack_index: usize = 1; │ ╰───────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │376 // indices within the stack, 1:1 with the header tabs. │ │377 const status_count = @typeInfo(Status).@"enum".fields.len; │ │378 const tags_view_index: usize = status_count; │ │ 57 - // the new-issue form, or the edit, comment, or resolve form when the page was │ │379 + // the new-thread form, or the edit, comment, or resolve form when the page was│ │380 // loaded at one of their urls. │ │381 const form_view_index: usize = tags_view_index + 1; │ │382 // the conflicts split; the tab and stack child exist only when the repo │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │ 61 - // has conflicted issues. │ │ 62 - const conflict_view_index: usize = form_view_index + 1; │ │383 + // has conflicted threads. │ │384 + const drafts_view_index: usize = form_view_index + 1; │ │385 + const conflict_view_index: usize = drafts_view_index + @intFromBool(has_drafts);│ │386 const view_count: usize = conflict_view_index + @intFromBool(has_conflicts); │ │387 // indices within a split (the horizontal box inside the stack). │ │388 const list_index: usize = 0; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │403 if (std.mem.eql(u8, name, "tags")) return tags_view_index; │ │404 if (std.mem.eql(u8, name, "new") or std.mem.eql(u8, name, "edit") or std.mem.eql(u8, name, "new_comment") or std.mem.eql(u8, name, "edit_comment") or std.mem.eql(u8, name, "remove") or std.mem.eql(u8, name, "resolve")) return form_view_index;│ │405 if (std.mem.eql(u8, name, "conflicts")) return conflict_view_index; │ │406 + if (std.mem.eql(u8, name, "drafts")) return drafts_view_index; │ │407 if (std.mem.eql(u8, name, "description")) unreachable; │ │408 inline for (@typeInfo(Status).@"enum".fields, 0..) |field, index| { │ │409 if (std.mem.eql(u8, name, field.name)) return index; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯