╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
151 const created = ui.RoutablePage.repoDiffRoute("alice:project", .object, &head, 0, "shared.txt", &zero) orelse return error.BadRoute; 152 const created_diff = try Diff.init(kind, opts, &arena, &repo, io, allocator, created.repo_diff); 153 try std.testing.expectEqual(1, created_diff.window.hunks.len); 154 try std.testing.expectEqualStrings("shared.txt", created_diff.window.hunks[0].path orelse return error.MissingPath); 155 156 // standalone panes keep their comparison when filtering or paginating 157 const id = "11111111111111111111111111111111"; 158 const fork_diff = Diff{ .route = .{ .fork = .{ .identity = "alice:project", .id = id } }, .window = first }; 159 for ([_]Diff{ diff, fork_diff }) |data| { 160 for ([_]bool{ true, false }) |terminal| { 161 var session = ui.Session{ .arena = &arena, .page_arena = &arena, .is_terminal = terminal }; 162 session.data.current_page = if (data.route == .repo) route else ui.RoutablePage.forkDiffRoute("alice:project", id, "", "", 0, "") orelse return error.BadRoute; 163 var view = try Diff.View.init(allocator, &data, &session); 164 defer view.deinit(allocator); 165 const focus = view.getFocus(); 166 try view.build(allocator, .{ .min_size = .{ .width = null, .height = null }, .max_size = .{ .width = 100, .height = 30 } }, focus); 167 const box = &view.scroll.child.box; 168 try std.testing.expectEqual(first.hunks.len * 2 + 1, box.children.count()); 169 const file = ui.crossPageLink(focus, box.children.keys()[0], session.data) orelse return error.MissingFileLink; 170 const next = ui.crossPageLink(focus, box.children.keys()[box.children.count() - 1], session.data) orelse return error.MissingLink; 171 if (data.route == .repo) { 172 try std.testing.expectEqualStrings("file-00.txt", file.repo_diff.path.slice()); 173 try std.testing.expectEqualStrings(&base, file.repo_diff.base_oid.slice()); 174 try std.testing.expectEqualStrings(&base, next.repo_diff.base_oid.slice()); 175 try std.testing.expectEqualStrings(&head, next.repo_diff.value.slice()); 176 try std.testing.expectEqual(Diff.page_size, next.repo_diff.start); 177 } else { 178 try std.testing.expectEqualStrings("file-00.txt", file.fork_diff.path.slice()); 179 try std.testing.expectEqual(Diff.page_size, next.fork_diff.start); 180 } 181 } 182 } 183 } 184 185 // the "next" row at the bottom of the commits list must be recognized as a 186 // cross-page link (so a click navigates), exactly like the diff pane's "next". 187 test "commits list next row is a cross-page link" { 188 const allocator = std.testing.allocator; 189 190 var arena = std.heap.ArenaAllocator.init(allocator); 191 defer arena.deinit(); 192 const identity = "alice:ziglings"; 193 const oid0 = "1111111111111111111111111111111111111111"; 194 const next_oid = "2222222222222222222222222222222222222222"; 195 const base_oid = "3333333333333333333333333333333333333333"; 196 197 const data = Commits{ 198 .handle = .{ .location = .{ .repo = identity } }, 199 .ref_or_oid = .object, 200 .ref_or_oid_value = oid0, 201 .base_oid = base_oid, 202 .commits = &.{ 203 .{ .oid = oid0, .parent_oid = base_oid, .date = "2024-01-01", .message = "first", .timestamp = "Jan 1, 2024, 00:00:00 UTC" }, 204 }, 205 .next_start = next_oid, 206 }; 207 208 var session = ui.Session{ .arena = &arena, .page_arena = &arena, .is_terminal = true }; 209 session.data.current_page = ui.RoutablePage.repoCommitsRoute(identity, .object, oid0, base_oid).?; 210 211 var view = try Commits.View.init(allocator, &data, &session); 212 defer view.deinit(allocator); 213 214 const root_focus = view.getFocus(); 215 try view.build(allocator, .{ 216 .min_size = .{ .width = null, .height = null }, 217 .max_size = .{ .width = 120, .height = 60 }, 218 }, root_focus); 219 220 // the "next" row is the last child of the list box: the view's outer box 221 // holds the sub-header then the list/detail split, whose first child is the 222 // list scroll. 223 const content = &view.box.children.values()[1].widget.box; 224 const lb = &content.children.values()[0].widget.scroll.child.box; 225 const next_id = lb.children.keys()[lb.children.count() - 1]; 226 227 const route = ui.crossPageLink(root_focus, next_id, session.data); 228 try std.testing.expect(route != null); 229 try std.testing.expectEqualStrings(base_oid, route.?.repo_commits.base_oid.slice()); 230 } 231 232 // a ref name with a '/' is url-encoded in the route, so it survives the 233 // '/'-delimited url round-trip as a single segment (kind + value) rather than 234 // being mis-parsed as extra path. 235 test "encoded ref name survives the commits url round-trip" { 236 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 237 defer arena.deinit(); 238 const RP = ui.RoutablePage; 239 const base_oid = "3333333333333333333333333333333333333333"; 240 241 // the route layer holds the value already url-encoded ("feature%2Ffoo"). 242 const route = RP.repoCommitsRoute("alice:ziglings", .branch, "feature%2Ffoo", base_oid).?; 243 const url = try route.toUrl(&arena); 244 try std.testing.expectEqualStrings("/alice:ziglings/commits/branch:feature%2Ffoo/base:" ++ base_oid, url); 245 246 // parsing it back yields the same route, and the ref splits out intact. 247 const parsed = RP.fromUrl(url); 248 try std.testing.expect(parsed != null); 249 const parsed_route = parsed.?.repo_commits; 250 try std.testing.expectEqual(RP.RefOrOid.branch, parsed_route.ref_or_oid.?); 251 try std.testing.expectEqualStrings("feature%2Ffoo", parsed_route.value.slice()); 252 try std.testing.expectEqualStrings(base_oid, parsed_route.base_oid.slice()); 253 const unfiltered = RP.repoCommitsRoute("alice:ziglings", .branch, "feature%2Ffoo", "").?; 254 try std.testing.expect(!RP.eql(route, unfiltered)); 255 } 256 257 test "commit pages stop at the base without excluding other ancestors" { 258 try testCommitBase(.git); 259 try testCommitBase(.xit); 260 } 261 262 fn testCommitBase(comptime kind: xit.repo.RepoKind) !void { 263 const io = std.testing.io; 264 const allocator = std.testing.allocator; 265 const opts: xit.repo.RepoOpts(kind) = .{ .is_test = true }; 266 var temp = std.testing.tmpDir(.{}); 267 defer temp.cleanup(); 268 const path = try temp.dir.realPathFileAlloc(io, ".", allocator); 269 defer allocator.free(path); 270 var repo = try xit.repo.Repo(kind, opts).init(io, allocator, .{ .path = path }); 271 defer repo.deinit(io, allocator); 272 var arena = std.heap.ArenaAllocator.init(allocator); 273 defer arena.deinit(); 274 const handle = ui.RepoHandle{ .location = .{ .repo = "" } }; 275 const branch = xit.ref.Ref{ .kind = .head, .name = "master" }; 276 277 // one more commit than fits on the first page 278 const base = try repo.commitAtRef(io, allocator, .{ .message = "base" }, null, branch); 279 var tip = base; 280 var previous = base; 281 for (0..21) |_| { 282 previous = tip; 283 tip = try repo.commitAtRef(io, allocator, .{ .message = "next" }, null, branch); 284 } 285 const first = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, .object, &tip, false, &base, "", ""); 286 try std.testing.expectEqual(20, first.commits.len); 287 const last = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, .object, first.next_start orelse return error.MissingNext, false, &base, "", ""); 288 try std.testing.expectEqual(1, last.commits.len); 289 try std.testing.expectEqual(null, last.next_start); 290 291 // the base at a page boundary must not create an empty next page 292 const full = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, .object, &previous, false, &base, "", ""); 293 try std.testing.expectEqual(20, full.commits.len); 294 try std.testing.expectEqual(null, full.next_start); 295 const tag_oid = try repo.addTag(io, allocator, .{ .name = "tip", .message = "annotated" }); 296 const sources = [_]struct { ref: ui.RoutablePage.RefOrOid, value: []const u8 }{ 297 .{ .ref = .object, .value = &tip }, 298 .{ .ref = .object, .value = &tag_oid }, 299 .{ .ref = .branch, .value = branch.name }, 300 .{ .ref = .tag, .value = "tip" }, 301 }; 302 for (sources) |source| { 303 const empty = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, source.ref, source.value, false, &tip, "", ""); 304 try std.testing.expectEqual(0, empty.commits.len); 305 const paged = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, source.ref, source.value, false, &base, "", first.next_start orelse return error.MissingNext); 306 try std.testing.expectEqual(1, paged.commits.len); 307 try std.testing.expectEqualStrings(last.commits[0].oid, paged.commits[0].oid); 308 try std.testing.expectEqual(source.ref, paged.ref_or_oid); 309 try std.testing.expectEqualStrings(source.value, paged.ref_or_oid_value); 310 } 311 312 // an off-chain stopping point must not hide its parent on this chain 313 const other = try repo.commitAtRef(io, allocator, .{ .message = "other", .parent_oids = &.{base} }, null, .{ .kind = .head, .name = "other" }); 314 const off_chain = try Commits.init(kind, opts, &arena, &repo, io, allocator, null, handle, .object, last.commits[0].oid, false, &other, "", ""); 315 try std.testing.expectEqual(2, off_chain.commits.len); 316 try std.testing.expectEqualStrings(&base, off_chain.commits[1].oid); 317 } 318 319 test "sync creates missing event branches and preserves head" { 320 const io = std.testing.io; 321 const allocator = std.testing.allocator; 322 323 var temp = std.testing.tmpDir(.{}); 324 defer temp.cleanup(); 325 const temp_path = try temp.dir.realPathFileAlloc(io, ".", allocator); 326 defer allocator.free(temp_path); 327 328 const local_path = try std.fs.path.join(allocator, &.{ temp_path, "local" }); 329 defer allocator.free(local_path); 330 const remote_path = try std.fs.path.join(allocator, &.{ temp_path, "remote" }); 331 defer allocator.free(remote_path); 332 333 const Repo = xit.repo.Repo(.git, .{}); 334 const RemoteRepo = xit.repo.Repo(.git, .{}); 335 { 336 var remote = try RemoteRepo.init(io, allocator, .{ .path = remote_path }); 337 remote.deinit(io, allocator); 338 } 339 { 340 var local = try Repo.init(io, allocator, .{ .path = local_path }); 341 defer local.deinit(io, allocator); 342 try local.addConfig(io, allocator, .{ .name = "user.name", .value = "user" }); 343 try local.addConfig(io, allocator, .{ .name = "user.email", .value = "user@haxy" }); 344 { 345 const file = try local.core.work_dir.createFile(io, "keep.txt", .{}); 346 defer file.close(io); 347 try file.writeStreamingAll(io, "keep"); 348 } 349 try local.add(io, allocator, &.{"keep.txt"}); 350 _ = try local.commit(io, allocator, .{ .message = "master" }); 351 try local.addRemote(io, allocator, .{ .name = "origin", .value = remote_path }); 352 353 const id = [_]u8{1} ** evt.event_id_size; 354 try evt.consume(.local, .repo, .git, .{}, io, allocator, &local, evt.events_ref, &.{.{ 355 .id = std.fmt.bytesToHex(id, .lower), 356 .author = .{ .name = "haxy", .email = "user@haxy" }, 357 .event = .{ .issue = .{ .title = "sync", .description = "", .labels = "" } }, 358 }}); 359 } 360 361 try std.testing.expectEqual(null, try Events.sync(io, allocator, .{ .path = local_path, .repo_kind = .git })); 362 { 363 var local = try Repo.open(io, allocator, .{ .path = local_path }); 364 defer local.deinit(io, allocator); 365 const id = [_]u8{2} ** evt.event_id_size; 366 try evt.consume(.local, .repo, .git, .{}, io, allocator, &local, evt.events_ref, &.{.{ 367 .id = std.fmt.bytesToHex(id, .lower), 368 .author = .{ .name = "haxy", .email = "user@haxy" }, 369 .event = .{ .issue = .{ .title = "local", .description = "", .labels = "" } }, 370 }}); 371 } 372 { 373 var remote = try RemoteRepo.open(io, allocator, .{ .path = remote_path }); 374 defer remote.deinit(io, allocator); 375 const id = [_]u8{3} ** evt.event_id_size; 376 try evt.consume(.local, .repo, .git, .{}, io, allocator, &remote, evt.events_ref, &.{.{ 377 .id = std.fmt.bytesToHex(id, .lower), 378 .author = .{ .name = "haxy", .email = "user@haxy" }, 379 .event = .{ .issue = .{ .title = "remote", .description = "", .labels = "" } }, 380 }}); 381 } 382 try std.testing.expectEqual(null, try Events.sync(io, allocator, .{ .path = local_path, .repo_kind = .git })); 383 { 384 var local = try Repo.open(io, allocator, .{ .path = local_path }); 385 defer local.deinit(io, allocator); 386 try local.removeBranch(io, .{ .name = evt.events_ref.name }); 387 try evt.consume(.local, .repo, .git, .{}, io, allocator, &local, evt.events_ref, &.{}); 388 } 389 try std.testing.expectEqual(null, try Events.sync(io, allocator, .{ .path = local_path, .repo_kind = .git })); 390 391 var local = try Repo.open(io, allocator, .{ .path = local_path }); 392 defer local.deinit(io, allocator); 393 var head_buffer: [xit.ref.MAX_REF_CONTENT_SIZE]u8 = undefined; 394 const head = try local.head(io, &head_buffer); 395 switch (head) { 396 .ref => |ref| try std.testing.expectEqualStrings("master", ref.name), 397 .oid => return error.TestExpectedEqual, 398 } 399 const kept = try local.core.work_dir.openFile(io, "keep.txt", .{}); 400 kept.close(io); 401 const local_tip = (try local.readRef(io, evt.events_ref)) orelse return error.TestExpectedEqual; 402 403 var remote = try RemoteRepo.open(io, allocator, .{ .path = remote_path }); 404 defer remote.deinit(io, allocator); 405 const remote_tip = (try remote.readRef(io, evt.events_ref)) orelse return error.TestExpectedEqual; 406 try std.testing.expectEqualStrings(&local_tip, &remote_tip); 407 408 var start_oids = [_][local_tip.len]u8{local_tip}; 409 var commit_iter = try local.log(io, allocator, .{ .start_oids = &start_oids }); 410 defer commit_iter.deinit(); 411 var merge_commit = (try commit_iter.next(allocator)) orelse return error.TestExpectedEqual; 412 defer merge_commit.deinit(); 413 const parent_oids = merge_commit.content.commit.metadata.parent_oids orelse return error.TestExpectedEqual; 414 try std.testing.expectEqual(2, parent_oids.len); 415 416 var arena = std.heap.ArenaAllocator.init(allocator); 417 defer arena.deinit(); 418 const status = try Events.Header.local(.git, .{}, arena.allocator(), &local, io, null); 419 try std.testing.expectEqualStrings("nothing to sync", status.sync_status orelse return error.TestExpectedEqual); 420 } 421