╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────╮ │686 .box = outer, │ │687 .data = data, │ │688 .session = session, │ │461 - .detailed_index = .{ null, null },│ │462 - .description_id = .{ null, null },│ │463 - .author_id = .{ null, null }, │ │689 + .detailed_index = @splat(null), │ │690 + .description_id = @splat(null), │ │691 + .author_id = @splat(null), │ │692 }; │ │693 } │ │694 │ ╰───────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │467 - // the master-detail split showing `status`'s window. │ │468 - fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status: evt.Issue.Status) !wgt.Box(ui.Widget) { │ │695 + // the master-detail split showing `status`'s window, or the conflicts │ │696 + // window when null. │ │697 + fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status_maybe: ?evt.Issue.Status) !wgt.Box(ui.Widget) {│ │698 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │699 errdefer box.deinit(allocator); │ │700 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │472 - const win = data.window(status); │ │701 + const win = if (status_maybe) |status| data.window(status) else &data.conflicts;│ │702 │ │703 // the issue list (one focusable row per title), plus a "next" link that │ │704 // reloads the page rooted at the following issue. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │707 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); │ │708 errdefer list_box.deinit(allocator); │ │709 if (win.prev_id) |prev| │ │481 - try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, status, data.tag, prev)); │ │482 - for (win.issues) |entry| │ │483 - try addRow(allocator, &list_box, entry.issue.event.title, try issueRowLink(session.page_arena, data.identity, entry.id)); │ │710 + try addRow(allocator, &list_box, "← previous", "", try windowLink(session.page_arena, data, status_maybe, prev)); │ │711 + for (win.issues) |entry| { │ │712 + // conflicted issues carry a marker in the status lists │ │713 + const label: []const u8 = if (entry.conflicted and status_maybe != null) "[conflict]" else ""; │ │714 + try addRow(allocator, &list_box, entry.issue.event.title, label, try issueRowLink(session.page_arena, data.identity, entry.id));│ │715 + } │ │716 if (win.next_id) |next| │ │485 - try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, status, data.tag, next)); │ │717 + try addRow(allocator, &list_box, "next →", "", try windowLink(session.page_arena, data, status_maybe, next)); │ │718 // select the window's first issue (past a leading "previous" │ │719 // row) so its description shows on load. │ │720 if (win.issues.len > 0) │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │794 if (issue) |i| try description.setContent(allocator, i.event.description); │ │795 try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = null }); │ │796 } │ │797 + │ │798 + try addSubmitButton(allocator, &box); │ │799 │ │800 + box.getFocus().child_id = box.children.keys()[title_field_index]; │ │801 + return box; │ │802 + } │ │803 + │ │804 + // a form's submit button, then a spacer absorbing the leftover │ │805 + // min-height the box hands its last child, so the button keeps its │ │806 + // natural height │ │807 + fn addSubmitButton(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { │ │808 { │ │809 var button = try wgt.TextBox(ui.Widget).init(allocator, "submit", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │810 errdefer button.deinit(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │814 button.getFocus().kind = .{ .custom = "submit" }; │ │815 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = null, .height = 3 } });│ │816 } │ │575 - │ │576 - // absorbs the leftover min-height the box hands its last child, so │ │577 - // the button keeps its natural height │ │817 { │ │818 var spacer = try ui.Spacer.init(allocator); │ │819 errdefer spacer.deinit(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 820 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │ 821 } │ │ 822 + } │ │ 823 │ │ 584 - box.getFocus().child_id = box.children.keys()[title_field_index]; │ │ 824 + // the conflict resolve form. the "use this" links are navigations that │ │ 825 + // flip the url's theirs: pick, reloading the prefills; one submit │ │ 826 + // settles every conflict. the form: subtree makes the web overlay POST │ │ 827 + // to the resolve route. │ │ 828 + fn initResolveForm(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !wgt.Box(ui.Widget) { │ │ 829 + const aa = session.page_arena.allocator(); │ │ 830 + // init only picks the resolve view once it has read the conflict │ │ 831 + const conflict = if (data.conflict) |*c| c else unreachable; │ │ 832 + │ │ 833 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │ 834 + errdefer box.deinit(allocator); │ │ 835 + box.getFocus().kind = .{ .custom = if (data.identity.len == 0) │ │ 836 + try std.fmt.allocPrint(aa, "form:/issues/{s}/resolve", .{data.selected_id}) │ │ 837 + else │ │ 838 + try std.fmt.allocPrint(aa, "form:/repo/{s}/issues/{s}/resolve", .{ data.identity, data.selected_id }) }; │ │ 839 + │ │ 840 + if (conflict.title) |*fc| { │ │ 841 + try addLabel(allocator, &box, "title conflict:"); │ │ 842 + try addFieldConflict(allocator, &box, session, data, "title", fc); │ │ 843 + } │ │ 844 + if (conflict.tags) |*fc| { │ │ 845 + try addLabel(allocator, &box, "tags conflict:"); │ │ 846 + try addFieldConflict(allocator, &box, session, data, "tags", fc); │ │ 847 + } │ │ 848 + │ │ 849 + if (conflict.description) |*desc| { │ │ 850 + try addLabel(allocator, &box, "description conflict:"); │ │ 851 + var hunk_index: usize = 0; │ │ 852 + for (desc.chunks, 0..) |*chunk, chunk_index| { │ │ 853 + if (chunk_index > 0) try addGap(allocator, &box); │ │ 854 + switch (chunk.*) { │ │ 855 + .same => |text| { │ │ 856 + var tb = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word }); │ │ 857 + errdefer tb.deinit(allocator); │ │ 858 + tb.getFocus().focusable = true; │ │ 859 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │ 860 + }, │ │ 861 + // an auto-resolved chunk: only one side changed it, so its │ │ 862 + // version stands, shown distinctly with its author │ │ 863 + .auto => |auto| { │ │ 864 + const author = if (auto.theirs) desc.theirs_author else desc.ours_author; │ │ 865 + const verb: []const u8 = if (auto.text == null) "removed" else "edited"; │ │ 866 + const label = switch (author) { │ │ 867 + .user_name, .email => |name| try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, name }), │ │ 868 + .unknown => try std.fmt.allocPrint(aa, " {s} by {s} ", .{ verb, if (auto.theirs) @as([]const u8, "them") else "us" }), │ │ 869 + }; │ │ 870 + var tb = try wgt.TextBox(ui.Widget).init(allocator, auto.text orelse "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); │ │ 871 + errdefer tb.deinit(allocator); │ │ 872 + tb.getFocus().focusable = true; │ │ 873 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │ 874 + }, │ │ 875 + .conflict => |hunk| { │ │ 876 + const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); │ │ 877 + hunk_index += 1; │ │ 878 + const picked_theirs = data.theirsPicked(name); │ │ 879 + try addVersionRow(allocator, &box, try sideLabel(aa, desc.ours_author, true), hunk.ours orelse "", try useThisLink(session, data, name, false)); │ │ 880 + try addVersionRow(allocator, &box, try sideLabel(aa, desc.theirs_author, false), hunk.theirs orelse "", try useThisLink(session, data, name, true)); │ │ 881 + var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal, .multiline = true });│ │ 882 + errdefer resolution_input.deinit(allocator); │ │ 883 + resolution_input.getFocus().focusable = true; │ │ 884 + try resolution_input.setContent(allocator, (if (picked_theirs) hunk.theirs else hunk.ours) orelse ""); │ │ 885 + try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); │ │ 886 + }, │ │ 887 + } │ │ 888 + } │ │ 889 + } │ │ 890 + │ │ 891 + try addSubmitButton(allocator, &box); │ │ 892 + │ │ 893 + box.getFocus().child_id = box.children.keys()[0]; │ │ 894 return box; │ │ 895 } │ │ 896 + │ │ 897 + // one conflicted scalar field: both sides' version rows, then the │ │ 898 + // resolution input prefilled from the picked side. │ │ 899 + fn addFieldConflict(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, data: *const Self, comptime name: []const u8, fc: *const FieldConflict) !void { │ │ 900 + const aa = session.page_arena.allocator(); │ │ 901 + try addVersionRow(allocator, box, try sideLabel(aa, fc.ours.author, true), fc.ours.text, try useThisLink(session, data, name, false)); │ │ 902 + try addVersionRow(allocator, box, try sideLabel(aa, fc.theirs.author, false), fc.theirs.text, try useThisLink(session, data, name, true)); │ │ 903 │ │ 588 - fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { │ │ 589 - var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); │ │ 904 + var resolution_input = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " resolution ", .name = name, .visible_width = null, .rounded_corners = true, .render_content = session.is_terminal }); │ │ 905 + errdefer resolution_input.deinit(allocator); │ │ 906 + resolution_input.getFocus().focusable = true; │ │ 907 + try resolution_input.setContent(allocator, if (data.theirsPicked(name)) fc.theirs.text else fc.ours.text); │ │ 908 + try box.children.put(allocator, resolution_input.getFocus().id, .{ .widget = .{ .text_input = resolution_input }, .rect = null, .min_size = null }); │ │ 909 + } │ │ 910 + │ │ 911 + // a blank row separating adjacent chunks │ │ 912 + fn addGap(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget)) !void { │ │ 913 + var text = try wgt.Text(ui.Widget).init(allocator, " "); │ │ 914 + errdefer text.deinit(allocator); │ │ 915 + try box.children.put(allocator, text.getFocus().id, .{ .widget = .{ .text = text }, .rect = null, .min_size = null }); │ │ 916 + } │ │ 917 + │ │ 918 + // a section label │ │ 919 + fn addLabel(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), content: []const u8) !void { │ │ 920 + var tb = try wgt.TextBox(ui.Widget).init(allocator, content, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │ 921 + errdefer tb.deinit(allocator); │ │ 922 + tb.getFocus().focusable = true; │ │ 923 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │ 924 + } │ │ 925 + │ │ 926 + // one side of a conflict: the "use this" link on the left, the version │ │ 927 + // itself as a labeled read-only box. │ │ 928 + fn addVersionRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: []const u8) !void { │ │ 929 + var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │ 930 + errdefer row.deinit(allocator); │ │ 931 + │ │ 932 + { │ │ 933 + var use = try wgt.TextBox(ui.Widget).init(allocator, "use this", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │ 934 + errdefer use.deinit(allocator); │ │ 935 + use.getFocus().focusable = true; │ │ 936 + use.getFocus().kind = .{ .custom = link }; │ │ 937 + try row.children.put(allocator, use.getFocus().id, .{ │ │ 938 + .widget = .{ .text_box = use }, │ │ 939 + .rect = null, │ │ 940 + .min_size = .{ .width = "use this".len + 2, .height = null }, │ │ 941 + .max_size = .{ .width = "use this".len + 2, .height = null }, │ │ 942 + }); │ │ 943 + } │ │ 944 + │ │ 945 + { │ │ 946 + var tb = try wgt.TextBox(ui.Widget).init(allocator, if (text.len > 0) text else "(removed)", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = label }); │ │ 947 + errdefer tb.deinit(allocator); │ │ 948 + tb.getFocus().focusable = true; │ │ 949 + try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │ 950 + } │ │ 951 + │ │ 952 + row.getFocus().child_id = row.children.keys()[0]; │ │ 953 + try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │ 954 + } │ │ 955 + │ │ 956 + // the label of one side's version box, naming its author │ │ 957 + fn sideLabel(aa: std.mem.Allocator, author: ui.Author, ours: bool) ![]const u8 { │ │ 958 + return switch (author) { │ │ 959 + .user_name, .email => |name| try std.fmt.allocPrint(aa, " edited by {s} ", .{name}), │ │ 960 + .unknown => if (ours) " current version " else " their version ", │ │ 961 + }; │ │ 962 + } │ │ 963 + │ │ 964 + // the a: link that reloads the resolve page with `name` picked from ours │ │ 965 + // or theirs, reprefilling its resolution input │ │ 966 + fn useThisLink(session: *ui.Session, data: *const Self, name: []const u8, theirs: bool) ![]const u8 { │ │ 967 + const aa = session.page_arena.allocator(); │ │ 968 + var picks: std.ArrayList(u8) = .empty; │ │ 969 + var it = std.mem.splitScalar(u8, data.theirs_picks, ','); │ │ 970 + while (it.next()) |pick| { │ │ 971 + if (pick.len == 0 or std.mem.eql(u8, pick, name)) continue; │ │ 972 + if (picks.items.len > 0) try picks.append(aa, ','); │ │ 973 + try picks.appendSlice(aa, pick); │ │ 974 + } │ │ 975 + if (theirs) { │ │ 976 + if (picks.items.len > 0) try picks.append(aa, ','); │ │ 977 + try picks.appendSlice(aa, name); │ │ 978 + } │ │ 979 + const route = ui.RoutablePage.repoIssuesResolveRoute(data.identity, data.selected_id, picks.items) orelse return error.RouteTooLong; │ │ 980 + return std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(session.page_arena)}); │ │ 981 + } │ │ 982 + │ │ 983 + // the resolve form's page-constant initial value for the input `name`: │ │ 984 + // the picked side of its conflicted field or hunk │ │ 985 + fn resolvePrefill(self: *View, name: []const u8) ?[]const u8 { │ │ 986 + const conflict = if (self.data.conflict) |*c| c else return null; │ │ 987 + const theirs = self.data.theirsPicked(name); │ │ 988 + if (std.mem.eql(u8, name, "title")) { │ │ 989 + const fc = if (conflict.title) |*f| f else return null; │ │ 990 + return if (theirs) fc.theirs.text else fc.ours.text; │ │ 991 + } │ │ 992 + if (std.mem.eql(u8, name, "tags")) { │ │ 993 + const fc = if (conflict.tags) |*f| f else return null; │ │ 994 + return if (theirs) fc.theirs.text else fc.ours.text; │ │ 995 + } │ │ 996 + if (std.mem.startsWith(u8, name, "d")) { │ │ 997 + const desc = if (conflict.description) |*d| d else return null; │ │ 998 + const hunk_index = std.fmt.parseInt(usize, name[1..], 10) catch return null; │ │ 999 + var seen: usize = 0; │ │1000 + for (desc.chunks) |chunk| switch (chunk) { │ │1001 + .conflict => |hunk| { │ │1002 + if (seen == hunk_index) return (if (theirs) hunk.theirs else hunk.ours) orelse ""; │ │1003 + seen += 1; │ │1004 + }, │ │1005 + else => {}, │ │1006 + }; │ │1007 + } │ │1008 + return null; │ │1009 + } │ │1010 + │ │1011 + fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, bottom_label: []const u8, link: []const u8) !void { │ │1012 + var row = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word, .bottom_label = bottom_label }); │ │1013 errdefer row.deinit(allocator); │ │1014 row.getFocus().focusable = true; │ │1015 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │1038 return &self.viewStack().children.values()[tags_view_index].tag_flow; │ │1039 } │ │1040 │ │ 618 - // the new-issue (or edit) form inside the stack, or null when the │ │1041 + // the new-issue, edit, or resolve form inside the stack, or null when the│ │1042 // unauthorized view stands in for it. │ │1043 fn issueForm(self: *View) ?*wgt.Box(ui.Widget) { │ │1044 return switch (self.viewStack().children.values()[form_view_index]) { │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │1045 .box => |*box| box, │ │1046 + // the resolve form sits inside a scroll on the terminal│ │1047 + .scroll => |*scroll| &scroll.child.box, │ │1048 else => null, │ │1049 }; │ │1050 } │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │1051 │ │1052 + // the resolve form's scroll on the terminal (the web page scrolls itself) │ │1053 + fn resolveScroll(self: *View) ?*wgt.Scroll(ui.Widget) { │ │1054 + return switch (self.viewStack().children.values()[form_view_index]) { │ │1055 + .scroll => |*scroll| scroll, │ │1056 + else => null, │ │1057 + }; │ │1058 + } │ │1059 + │ │1060 fn listScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │1061 return &self.resultsBox(index).children.values()[list_index].widget.scroll;│ │1062 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯