╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 90 name: Array(repo_route_max_len), │ │ 91 // the tag the list is filtered to, url-encoded ("" = unfiltered). │ │ 92 tag: Array(issue_tag_route_max_len) = .{}, │ │ 93 + // the issue the route names: the list's window root, or the issue the│ │ 94 + // edit/description/resolve view shows. │ │ 95 selected: Array(evt.event_id_size * 2) = .{}, │ │ 96 + // the resolve view's comma-separated list of fields prefilled from │ │ 97 + // their side of the conflict. │ │ 98 + theirs: Array(theirs_route_max_len) = .{}, │ │ 99 // the view the url names; a route with `selected` derives its view │ │100 // from that issue's status instead. │ │101 view: IssuesView = .open, │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │107 │ │108 pub const RefKind = enum { branch, tag }; │ │109 │ │105 - pub const IssuesView = enum { open, closed, tags, new, edit, description }; │ │110 + pub const IssuesView = enum { open, closed, tags, new, edit, description, conflicts, resolve };│ │111 │ │112 pub const RefOrOid = enum { │ │113 branch, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │117 │ │118 // the "key:value" segments of a url tail, collected in any order │ │119 const Params = struct { │ │115 - const ParamKey = enum { start, line, branch, tag, object }; │ │120 + const ParamKey = enum { start, line, branch, tag, object, theirs };│ │121 │ │122 // a branch:/tag:/object: param; the value stays url-encoded │ │123 const RefParam = struct { kind: RefOrOid, value: []const u8 }; │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │194 const tag_filter_seg = @tagName(Params.ParamKey.tag) ++ ":"; │ │195 const start_seg = @tagName(Params.ParamKey.start) ++ ":"; │ │196 const line_seg = @tagName(Params.ParamKey.line) ++ ":"; │ │197 + const theirs_seg = @tagName(Params.ParamKey.theirs) ++ ":"; │ │198 // marks a files or commits route's trailing path: the value is the raw│ │199 // remainder of the url, so it must come last │ │200 const path_seg = "path:"; │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │215 // a url-encoded tag can grow to three bytes per source byte. │ │216 pub const issue_tag_route_max_len = evt.Issue.tag_max_len * 3; │ │217 │ │218 + // caps the resolve view's theirs: field list (a longer one doesn't route).│ │219 + pub const theirs_route_max_len = 512; │ │220 + │ │221 // caps a url-encoded ref name in a route (a longer name doesn't route). │ │222 pub const ref_route_max_len = 512; │ │223 │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │382 .name = Array(repo_route_max_len).from(identity) orelse return null, │ │383 .selected = Array(evt.event_id_size * 2).from(selected) orelse return null, │ │384 .view = .description, │ │385 + } }; │ │386 + } │ │387 + │ │388 + // build a `.repo_issues` route showing the conflicts list, rooted at the │ │389 + // issue with hex event id `start` ("" = the first window). │ │390 + pub fn repoIssuesConflictsRoute(identity: []const u8, start: []const u8) ?RoutablePage { │ │391 + return .{ .repo_issues = .{ │ │392 + .name = Array(repo_route_max_len).from(identity) orelse return null, │ │393 + .selected = Array(evt.event_id_size * 2).from(start) orelse return null, │ │394 + .view = .conflicts, │ │395 + } }; │ │396 + } │ │397 + │ │398 + // build a `.repo_issues` route showing the conflict resolve form for the │ │399 + // issue with hex event id `selected`; `theirs` is the comma-separated list │ │400 + // of fields prefilled from their side. │ │401 + pub fn repoIssuesResolveRoute(identity: []const u8, selected: []const u8, theirs: []const u8) ?RoutablePage {│ │402 + if (selected.len == 0) return null; │ │403 + return .{ .repo_issues = .{ │ │404 + .name = Array(repo_route_max_len).from(identity) orelse return null, │ │405 + .selected = Array(evt.event_id_size * 2).from(selected) orelse return null, │ │406 + .theirs = Array(theirs_route_max_len).from(theirs) orelse return null, │ │407 + .view = .resolve, │ │408 } }; │ │409 } │ │410 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │487 const prefix = try repoUrlPrefix(arena, i.name.slice()); │ │488 if (i.view == .edit) break :blk try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/edit", .{ prefix, i.selected.slice() }); │ │489 if (i.view == .description) break :blk try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/description", .{ prefix, i.selected.slice() }); │ │490 + if (i.view == .resolve) break :blk if (i.theirs.len == 0) │ │491 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/resolve", .{ prefix, i.selected.slice() }) │ │492 + else │ │493 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/" ++ theirs_seg ++ "{s}/{s}/resolve", .{ prefix, i.theirs.slice(), i.selected.slice() });│ │494 + if (i.view == .conflicts) break :blk if (i.selected.len == 0) │ │495 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/conflicts", .{prefix}) │ │496 + else │ │497 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/" ++ start_seg ++ "{s}/conflicts", .{ prefix, i.selected.slice() }); │ │498 // a selected issue's url carries its id instead of a view │ │499 // name, so it survives status changes │ │500 const word = if (i.selected.len != 0) i.selected.slice() else @tagName(i.view); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │558 fn repoUrlPrefix(arena: *std.heap.ArenaAllocator, identity: []const u8) ![]const u8 {│ │559 if (identity.len == 0) return ""; │ │560 return std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{identity});│ │561 + } │ │562 + │ │563 + // true when the page builds at its content height on the web, letting the │ │564 + // browser scroll the whole page, rather than being pinned to the viewport │ │565 + pub fn fullHeight(self: RoutablePage) bool { │ │566 + return switch (self) { │ │567 + .repo_issues => |i| i.view == .resolve, │ │568 + else => false, │ │569 + }; │ │570 } │ │571 │ │572 // the "owner/name" a repo route carries, null for any other route. the │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │652 if (id.len == 0 or std.mem.indexOfScalar(u8, id, '/') != null) return null; │ │653 return repoIssuesDescriptionRoute(pair, id); │ │654 } │ │655 + // "[theirs:<list>/]<id>/resolve" is the conflict resolve form. │ │656 + if (std.mem.endsWith(u8, after_tab, "/resolve")) { │ │657 + var resolve_segments = std.mem.splitScalar(u8, after_tab[0 .. after_tab.len - "/resolve".len], '/');│ │658 + const id = (params.scanTail(&resolve_segments) catch return null) orelse return null; │ │659 + if (!params.only(&.{.theirs})) return null; │ │660 + return repoIssuesResolveRoute(pair, id, params.values.get(.theirs) orelse ""); │ │661 + } │ │662 // the word is a view name or an issue event id (whose status │ │663 // picks the view); a filter with neither is never emitted. │ │664 const word = params.scanTail(&segments) catch return null; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │665 + const tag_value = params.values.get(.tag) orelse ""; │ │666 + const w = word orelse return if (params.only(&.{})) repoIssuesRoute(pair, .open, "", "") else null; │ │667 + // the conflicts list windows by an issue id, not a number, so it │ │668 + // reads the start param raw │ │669 + if (std.mem.eql(u8, w, "conflicts")) { │ │670 + if (!params.only(&.{.start})) return null; │ │671 + return repoIssuesConflictsRoute(pair, params.values.get(.start) orelse ""); │ │672 + } │ │673 if (!params.only(&.{.tag})) return null; │ │610 - const tag_value = params.values.get(.tag) orelse ""; │ │611 - const w = word orelse return if (tag_value.len == 0) repoIssuesRoute(pair, .open, "", "") else null; │ │674 if (std.meta.stringToEnum(evt.Issue.Status, w)) |status| return repoIssuesRoute(pair, status, tag_value, "");│ │675 if (std.mem.eql(u8, w, "tags")) return repoIssuesTagsRoute(pair, tag_value); │ │676 if (std.mem.eql(u8, w, "new")) return if (tag_value.len == 0) repoIssuesNewRoute(pair) else null; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯