╭ changed ──────╮ │src/test/ui.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 4 const Commits = @import("../ui/Repo/Commits.zig"); │ │ 5 const Events = @import("../ui/Repo/Events.zig"); │ │ 6 const xit = @import("xit"); │ │ 7 + │ │ 8 +test "diff windows span the net changes across commits" { │ │ 9 + const io = std.testing.io; │ │10 + const allocator = std.testing.allocator; │ │11 + const Diff = Commits.Diff; │ │12 + const opts: xit.repo.RepoOpts(.xit) = .{ .is_test = true }; │ │13 + var temp = std.testing.tmpDir(.{}); │ │14 + defer temp.cleanup(); │ │15 + const path = try temp.dir.realPathFileAlloc(io, ".", allocator); │ │16 + defer allocator.free(path); │ │17 + var repo = try xit.repo.Repo(.xit, opts).init(io, allocator, .{ .path = path }); │ │18 + defer repo.deinit(io, allocator); │ │19 + var arena = std.heap.ArenaAllocator.init(allocator); │ │20 + defer arena.deinit(); │ │21 │ │22 + // the shared base is absent from the diff │ │23 + { │ │24 + const file = try repo.core.work_dir.createFile(io, "shared.txt", .{}); │ │25 + defer file.close(io); │ │26 + try file.writeStreamingAll(io, "shared\n"); │ │27 + } │ │28 + try repo.add(io, allocator, &.{"shared.txt"}); │ │29 + const base = try repo.commit(io, allocator, .{ .message = "base" }); │ │30 + │ │31 + // changes from earlier commits must survive into the combined diff │ │32 + var head = base; │ │33 + for (0..Diff.page_size + 1) |i| { │ │34 + var name_buf: [32]u8 = undefined; │ │35 + const name = try std.fmt.bufPrint(&name_buf, "file-{d:0>2}.txt", .{i}); │ │36 + { │ │37 + const file = try repo.core.work_dir.createFile(io, name, .{}); │ │38 + defer file.close(io); │ │39 + try file.writeStreamingAll(io, "added\n"); │ │40 + } │ │41 + try repo.add(io, allocator, &.{name}); │ │42 + head = try repo.commit(io, allocator, .{ .message = "add file" }); │ │43 + } │ │44 + const first = try Diff.render(.xit, opts, io, allocator, arena.allocator(), &repo, &base, head, 0, ""); │ │45 + try std.testing.expectEqual(Diff.page_size, first.hunks.len); │ │46 + try std.testing.expect(first.start == 0 and first.has_more); │ │47 + try std.testing.expectEqualStrings("file-00.txt", first.hunks[0].path orelse return error.MissingPath); │ │48 + const last = try Diff.render(.xit, opts, io, allocator, arena.allocator(), &repo, &base, head, Diff.page_size, ""); │ │49 + try std.testing.expectEqual(1, last.hunks.len); │ │50 + try std.testing.expect(last.start > 0 and !last.has_more); │ │51 + try std.testing.expectEqualStrings("file-10.txt", last.hunks[0].path orelse return error.MissingPath); │ │52 + const filtered = try Diff.render(.xit, opts, io, allocator, arena.allocator(), &repo, &base, head, 0, "file-00.txt"); │ │53 + try std.testing.expectEqual(1, filtered.hunks.len); │ │54 + try std.testing.expect(!filtered.has_more); │ │55 + │ │56 + // the standalone pane links to the next fork diff window in both clients │ │57 + const id = "11111111111111111111111111111111"; │ │58 + const data = Diff{ .route = .{ .fork = .{ .identity = "alice/project", .id = id } }, .window = first }; │ │59 + for ([_]bool{ true, false }) |terminal| { │ │60 + var session = ui.Session{ .arena = &arena, .page_arena = &arena, .is_terminal = terminal }; │ │61 + session.data.current_page = ui.RoutablePage.forkDiffRoute("alice/project", id, 0, "") orelse return error.BadRoute; │ │62 + var view = try Diff.View.init(allocator, &data, &session); │ │63 + defer view.deinit(allocator); │ │64 + const focus = view.getFocus(); │ │65 + try view.build(allocator, .{ .min_size = .{ .width = null, .height = null }, .max_size = .{ .width = 100, .height = 30 } }, focus);│ │66 + const box = &view.scroll.child.box; │ │67 + try std.testing.expectEqual(first.hunks.len * 2 + 1, box.children.count()); │ │68 + const file = ui.crossPageLink(focus, box.children.keys()[0], session.data) orelse return error.MissingFileLink; │ │69 + try std.testing.expectEqualStrings("file-00.txt", file.fork_diff.path.slice()); │ │70 + const next = ui.crossPageLink(focus, box.children.keys()[box.children.count() - 1], session.data) orelse return error.MissingLink; │ │71 + try std.testing.expectEqual(Diff.page_size, next.fork_diff.start); │ │72 + } │ │73 +} │ │74 + │ │75 // the "next" row at the bottom of the commits list must be recognized as a │ │76 // cross-page link (so a click navigates), exactly like the diff pane's "next". │ │77 test "commits list next row is a cross-page link" { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │88 .ref_or_oid = .object, │ │89 .ref_or_oid_value = oid0, │ │90 .commits = &.{ │ │24 - .{ .oid = oid0, .date = "2024-01-01", .message = "first", .hunks = &.{}, .window_start = 0, .has_prev = false, .has_more = false },│ │91 + .{ .oid = oid0, .date = "2024-01-01", .message = "first", .window = .{} }, │ │92 }, │ │93 .next_start = next_oid, │ │94 .header = try Commits.Header.init(arena.allocator(), .object, oid0), │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │91 else => return error.UnexpectedRoute, │ │92 }, │ │93 .fork => switch (route) { │ │94 - .fork_patch, .fork_files, .fork_commits, .fork_settings, .fork_auth => .{ .fork = try Fork.init(arena, session, route) }, │ │94 + .fork_patch, .fork_diff, .fork_files, .fork_commits, .fork_settings, .fork_auth => .{ .fork = try Fork.init(arena, session, route) },│ │95 else => return error.UnexpectedRoute, │ │96 }, │ │97 }; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │166 repo_settings: Array(repo_route_max_len), │ │167 repo_auth: Array(repo_route_max_len), │ │168 fork_patch: ForkRoute, │ │169 + fork_diff: struct { fork: ForkRoute, start: usize = 0, path: Array(repo_route_max_len) = .{} },│ │170 fork_files: ForkFilesRoute, │ │171 fork_commits: ForkCommitsRoute, │ │172 fork_settings: ForkRoute, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │447 return .{ .fork_patch = initForkRoute(identity, id) orelse return null }; │ │448 } │ │449 │ │450 + pub fn forkDiffRoute(identity: []const u8, id: []const u8, start: usize, path: []const u8) ?RoutablePage { │ │451 + return .{ .fork_diff = .{ │ │452 + .fork = initForkRoute(identity, id) orelse return null, │ │453 + .start = start, │ │454 + .path = Array(repo_route_max_len).from(path) orelse return null, │ │455 + } }; │ │456 + } │ │457 + │ │458 pub fn forkFilesRoute(identity: []const u8, id: []const u8, oid: []const u8, path: []const u8, line: usize) ?RoutablePage {│ │459 return .{ .fork_files = .{ │ │460 .fork = initForkRoute(identity, id) orelse return null, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │981 .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/settings", .{try repoUrlPrefix(arena, name.slice())}), │ │982 .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{try repoUrlPrefix(arena, name.slice())}), │ │983 .fork_patch => |f| try std.fmt.allocPrint(arena.allocator(), fork_segment ++ "{s}/" ++ patch_seg ++ "{s}", .{ f.name.slice(), f.id.slice() }),│ │984 + .fork_diff => |d| blk: { │ │985 + var out: std.Io.Writer.Allocating = .init(arena.allocator()); │ │986 + try out.writer.print(fork_segment ++ "{s}/" ++ patch_seg ++ "{s}/diff", .{ d.fork.name.slice(), d.fork.id.slice() }); │ │987 + if (d.start != 0) try out.writer.print("/" ++ start_seg ++ "{d}", .{d.start}); │ │988 + if (d.path.len != 0) try out.writer.print("/" ++ path_seg ++ "{s}", .{d.path.slice()}); │ │989 + break :blk out.written(); │ │990 + }, │ │991 .fork_files => |f| blk: { │ │992 var out: std.Io.Writer.Allocating = .init(arena.allocator()); │ │993 try out.writer.print(fork_segment ++ "{s}/" ++ patch_seg ++ "{s}/" ++ files_seg, .{ f.fork.name.slice(), f.fork.id.slice() }); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1050 if (id.len != evt.event_id_size * 2) return null; │ │1051 const tab = segments.next() orelse return forkPatchRoute(identity, id); │ │1052 var params = Params{}; │ │1053 + if (std.mem.eql(u8, tab, "diff")) { │ │1054 + params.scanPairs(&segments) catch return null; │ │1055 + if (!params.only(&.{.start})) return null; │ │1056 + return forkDiffRoute(identity, id, params.start() orelse return null, pathValue(segments.rest()) orelse return null);│ │1057 + } │ │1058 if (std.mem.eql(u8, tab, files_seg)) { │ │1059 params.scanPairs(&segments) catch return null; │ │1060 if (!params.only(&.{ .line, .object })) return null; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │1140 pub fn forkRoute(self: *const RoutablePage) ?*const ForkRoute {│ │1141 return switch (self.*) { │ │1142 .fork_patch => |*f| f, │ │1143 + .fork_diff => |*d| &d.fork, │ │1144 .fork_files => |*f| &f.fork, │ │1145 .fork_commits => |*f| &f.fork, │ │1146 .fork_settings => |*f| f, │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1174 .home_users, .home_repos, .home_settings, .home_auth => .home, │ │1175 .user_repos, .user_forks, .user_settings, .user_auth => .user, │ │1176 .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_patches, .repo_discussions, .repo_events, .repo_settings, .repo_auth => .repo,│ │1155 - .fork_patch, .fork_files, .fork_commits, .fork_settings, .fork_auth => .fork, │ │1177 + .fork_patch, .fork_diff, .fork_files, .fork_commits, .fork_settings, .fork_auth => .fork, │ │1178 }; │ │1179 } │ │1180 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1449 .repo_settings => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_settings.slice()), │ │1450 .repo_auth => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_auth.slice()), │ │1451 .fork_patch => |a_f| std.mem.eql(u8, a_f.name.slice(), b.fork_patch.name.slice()) and std.mem.eql(u8, a_f.id.slice(), b.fork_patch.id.slice()),│ │1452 + .fork_diff => |a_d| std.mem.eql(u8, a_d.fork.name.slice(), b.fork_diff.fork.name.slice()) and │ │1453 + std.mem.eql(u8, a_d.fork.id.slice(), b.fork_diff.fork.id.slice()) and │ │1454 + a_d.start == b.fork_diff.start and std.mem.eql(u8, a_d.path.slice(), b.fork_diff.path.slice()), │ │1455 .fork_files => |a_f| std.mem.eql(u8, a_f.fork.name.slice(), b.fork_files.fork.name.slice()) and │ │1456 std.mem.eql(u8, a_f.fork.id.slice(), b.fork_files.fork.id.slice()) and │ │1457 std.mem.eql(u8, a_f.oid.slice(), b.fork_files.oid.slice()) and │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯