╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 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 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │1086 } │ │1087 │ │1088 fn window(self: *View, index: usize) *const Window { │ │1089 + if (index == conflicts_view_index) return &self.data.conflicts;│ │1090 return self.data.window(splitStatus(index)); │ │1091 } │ │1092 │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │1100 // new-issue form shows). │ │1101 fn selectedSplitIndex(self: *View) ?usize { │ │1102 const idx = self.stackSelectedIndex() orelse return null; │ │ 669 - return if (idx < split_count) idx else null; │ │1103 + return if (idx < split_count or idx == conflicts_view_index) idx else null;│ │1104 } │ │1105 │ │1106 fn detailActive(self: *View, index: usize) bool { │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1146 if (self.header().getSelectedIndex()) |index| { │ │1147 const stack = self.viewStack(); │ │1148 stack.getFocus().child_id = stack.children.keys()[index]; │ │ 715 - self.session.data.current_page = (if (index == form_view_index and self.data.view == .edit) │ │ 716 - ui.RoutablePage.repoIssuesEditRoute(self.data.identity, self.data.selected_id) │ │1149 + self.session.data.current_page = (if (index == form_view_index) │ │1150 + (switch (self.data.view) { │ │1151 + .edit => ui.RoutablePage.repoIssuesEditRoute(self.data.identity, self.data.selected_id), │ │1152 + .resolve => ui.RoutablePage.repoIssuesResolveRoute(self.data.identity, self.data.selected_id, self.data.theirs_picks), │ │1153 + else => ui.RoutablePage.repoIssuesNewRoute(self.data.identity), │ │1154 + }) │ │1155 + else if (index == conflicts_view_index) │ │1156 + ui.RoutablePage.repoIssuesConflictsRoute(self.data.identity, if (self.data.view == .conflicts) self.data.selected_id else "")│ │1157 else if (index == viewIndex(self.data.view) and self.data.selected_id.len != 0) │ │1158 (if (self.data.description_page) │ │1159 ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, self.data.selected_id) │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1161 ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, self.data.tag, self.data.selected_id)) │ │1162 else if (index == tags_view_index) │ │1163 ui.RoutablePage.repoIssuesTagsRoute(self.data.identity, self.data.tag) │ │ 724 - else if (index == form_view_index) │ │ 725 - ui.RoutablePage.repoIssuesNewRoute(self.data.identity) │ │1164 else │ │1165 ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(index), self.data.tag, "")) orelse self.session.data.current_page;│ │1166 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1177 if (self.selectedIssueIndex(i)) |sel| { │ │1178 // selecting another issue leaves the description page │ │1179 // behind, like it leaves the tag filter behind. │ │ 742 - const id = self.window(i).issues[sel].id; │ │ 743 - const mirrored = if (self.data.description_page and std.mem.eql(u8, id, self.data.selected_id)) │ │ 744 - ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, id) │ │1180 + const entry = &self.window(i).issues[sel]; │ │1181 + const mirrored = if (self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id))│ │1182 + ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, entry.id) │ │1183 else │ │ 746 - ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(i), "", id); │ │1184 + ui.RoutablePage.repoIssuesRoute(self.data.identity, entry.issue.event.status, "", entry.id); │ │1185 if (mirrored) |route| self.session.data.current_page = route; │ │1186 } │ │1187 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1237 try self.session.input_values.put(inputs_arena, fields[description_field_index].widget.text_input.getFocus().id, entry.issue.event.description);│ │1238 } │ │1239 } │ │1240 + │ │1241 + // the resolve form's inputs prefill from the picked side, which │ │1242 + // is url state and so page-constant like the edit values above │ │1243 + if (self.data.view == .resolve) { │ │1244 + for (form.children.values()) |*child| switch (child.widget) { │ │1245 + .text_input => |*ti| if (self.resolvePrefill(ti.options.name)) |value| { │ │1246 + try self.session.input_values.put(inputs_arena, ti.getFocus().id, value); │ │1247 + }, │ │1248 + else => {}, │ │1249 + }; │ │1250 + } │ │1251 } │ │1252 │ │1253 try self.box.build(allocator, constraint, root_focus); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯