╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ try.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
458 .tags = "enhancement cli", 459 }, 460 .{ 461 .title = "Race condition between autosave and manual save", 462 .description = "Saving manually while an autosave is in flight can interleave the two writes and corrupt the file. Serialize saves through a single queue.", 463 .tags = "bug priority-high data-loss", 464 }, 465 .{ 466 .title = "Reduce binary size of release builds", 467 .description = "The release binary has grown past 40 MB, mostly from debug info and an unused bundled font. Strip symbols and drop the font from the default build.", 468 .tags = "performance build", 469 }, 470 .{ 471 .title = "Tooltips flicker when the cursor moves between adjacent buttons", 472 .description = "Moving the cursor across a toolbar hides and re-shows the tooltip for every button. Keep the tooltip open with a short grace period between neighbors.", 473 .tags = "bug ui polish", 474 }, 475 .{ 476 .title = "Support environment variable expansion in config paths", 477 .description = "Paths in the config file are taken literally, so shared configs can't refer to the home directory portably. Expand environment variables when loading.", 478 .tags = "enhancement config", 479 }, 480 .{ 481 .title = "Log rotation deletes the newest file instead of the oldest", 482 .description = "When the log directory hits its size cap, the rotation logic sorts by name rather than mtime and removes the most recent log. Sort by modification time.", 483 .tags = "bug logging", 484 .status = .closed, 485 }, 486 .{ 487 .title = "Add man pages for all subcommands", 488 .description = "Only the top-level command has a man page. Generate one per subcommand from the existing help text as part of the release build.", 489 .tags = "documentation cli", 490 }, 491 .{ 492 .title = "High CPU usage while idle in the background", 493 .description = "The main loop polls for file changes every 10 ms even when no window is visible. Switch to native file watching and idle at zero CPU.", 494 .tags = "performance priority-high", 495 }, 496 .{ 497 .title = "Paste from clipboard drops the final newline", 498 .description = "Pasting text that ends with a newline silently trims it, which breaks pasted shell snippets. Preserve the clipboard content exactly.", 499 .tags = "bug editor clipboard", 500 }, 501 .{ 502 .title = "Improve error message for expired credentials", 503 .description = "An expired token currently surfaces as a bare 401 with no guidance. Detect the expiry case and tell the user how to re-authenticate.", 504 .tags = "enhancement ux auth", 505 }, 506 .{ 507 .title = "Crash when window is resized during startup animation", 508 .description = "Resizing the window while the splash animation is running dereferences a freed layout node. Cancel the animation before rebuilding the layout.", 509 .tags = "bug crash ui", 510 }, 511 .{ 512 .title = "Add integration tests for the sync engine", 513 .description = "The sync engine only has unit tests with mocked transports. Add end-to-end tests that run two instances against a real local server.", 514 .tags = "testing sync", 515 }, 516 .{ 517 .title = "Timestamps display in UTC instead of local time", 518 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p3 ++ "\n\n" ++ tz_p4, 519 .tags = "bug i18n time", 520 }, 521 .{ 522 .title = "Deprecate the legacy plugin format", 523 .description = "Both plugin formats are currently loaded, doubling the maintenance surface. Warn on legacy plugins this release and drop support in the next.", 524 .tags = "maintenance plugins", 525 }, 526 }; 527 528 var issue_events: [issue_data.len]evt.EventWithId = undefined; 529 for (issue_data, 0..) |issue, i| { 530 // the newest issue's description runs past what the detail 531 // pane shows, so it shows the truncated description and its 532 // link. 533 const description = if (i == issue_data.len - 1) desc: { 534 var desc_writer = std.Io.Writer.Allocating.init(allocator); 535 defer desc_writer.deinit(); 536 try desc_writer.writer.print("{s}", .{issue.description}); 537 var line: usize = 0; 538 while (desc_writer.written().len <= ui.Repo.Issues.max_description_size) : (line += 1) { 539 try desc_writer.writer.print("\n{d} {s}", .{ line, scatter_words[line % scatter_words.len] }); 540 } 541 break :desc try arena.allocator().dupe(u8, desc_writer.written()); 542 } else issue.description; 543 issue_events[i] = .{ 544 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 545 // stepped timestamps so the issues list in a stable order 546 .timestamp = @intCast(i + 1), 547 .author_email = user_data[i % user_data.len].email, 548 .event = .{ 549 .issue = .{ 550 .title = issue.title, 551 .description = description, 552 .tags = issue.tags, 553 .status = issue.status, 554 }, 555 }, 556 }; 557 } 558 try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &issue_events); 559 // the tip is the branch point for the conflicting edits below 560 const seed_tip = (try template_repo.readRef(io, evt.events_ref)) orelse unreachable; 561 562 // two divergent edits per conflicted issue: ours on the events 563 // branch, theirs on a temp branch rooted at the seed tip, then a 564 // merge. the 4th-newest issue conflicts on title and tags; the 565 // 2nd-newest on its description, with a deletion conflict (ours 566 // removes a paragraph theirs rewords) and an insertion conflict 567 // (each side appends a different closing paragraph). 568 { 569 const other_ref: xit.ref.Ref = .{ .kind = .head, .name = "haxy/other" }; 570 const title_issue = issue_data[issue_data.len - 4]; 571 const desc_issue = issue_data[issue_data.len - 2]; 572 573 const ours = [_]evt.EventWithId{ .{ 574 .id = issue_events[issue_data.len - 4].id, 575 .timestamp = 100, 576 .author_email = user_data[1].email, 577 .event = .{ .issue = .{ 578 .title = "Crash when resizing the window during the splash animation", 579 .description = title_issue.description, 580 .tags = "bug crash ui priority-high", 581 } }, 582 }, .{ 583 .id = issue_events[issue_data.len - 2].id, 584 .timestamp = 101, 585 .author_email = user_data[1].email, 586 .event = .{ .issue = .{ 587 .title = desc_issue.title, 588 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ 589 "The confusion is worst for teams spread across timezones, who each read a different wall-clock time from the same feed.", 590 .tags = desc_issue.tags, 591 } }, 592 } }; 593 const theirs = [_]evt.EventWithId{ .{ 594 .id = issue_events[issue_data.len - 4].id, 595 .timestamp = 102, 596 .author_email = user_data[2].email, 597 .event = .{ .issue = .{ 598 .title = "Segfault on early window resize", 599 .description = title_issue.description, 600 .tags = "bug crash rendering", 601 } }, 602 }, .{ 603 .id = issue_events[issue_data.len - 2].id, 604 .timestamp = 103, 605 .author_email = user_data[2].email, 606 .event = .{ .issue = .{ 607 .title = desc_issue.title, 608 .description = tz_p1 ++ "\n\n" ++ tz_p2 ++ "\n\n" ++ 609 "Most other clients already render local time by default, which makes our UTC output stand out as a bug." ++ "\n\n" ++ tz_p4 ++ "\n\n" ++ 610 "Log exports inherit the same UTC rendering, so downstream tooling has to guess the source timezone.", 611 .tags = desc_issue.tags, 612 } }, 613 } }; 614 615 try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &ours); 616 617 // consume can't root a new branch, so theirs commits by hand 618 var json: std.Io.Writer.Allocating = .init(allocator); 619 defer json.deinit(); 620 for (theirs, 0..) |event, i| { 621 json.clearRetainingCapacity(); 622 try std.json.Stringify.value(event, .{}, &json.writer); 623 const author = try std.fmt.allocPrint(allocator, "haxy <{s}>", .{event.author_email}); 624 defer allocator.free(author); 625 _ = try template_repo.commitAtRef(io, allocator, .{ 626 .author = author, 627 .message = json.written(), 628 .timestamp = event.timestamp, 629 // root the branch at the shared seed tip 630 .parent_oids = if (i == 0) &.{seed_tip} else null, 631 }, null, other_ref); 632 } 633 634 { 635 var to_events = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 636 defer to_events.deinit(); 637 } 638 { 639 var merge = try template_repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_ref }} } } }, null); 640 defer merge.deinit(); 641 if (merge.result != .success) return error.MergeFailed; 642 } 643 { 644 var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); 645 defer to_master.deinit(); 646 } 647 try template_repo.removeBranch(io, .{ .name = other_ref.name }); 648 } 649 650 try evt.consume(.xit, .{}, io, allocator, &template_repo, evt.events_ref, &.{}); 651 } 652 653 // copy the template to each repo's on-disk location, named by its 654 // hex-encoded event id. the template repo is deinitialized above, so its 655 // db file is fully written before we copy it. 656 { 657 var template_dir = try cwd.openDir(io, template_path, .{ .iterate = true }); 658 defer template_dir.close(io); 659 660 for (repo_event_ids) |id_bytes| { 661 const repo_id = std.fmt.bytesToHex(id_bytes, .lower); 662 const repo_path = try std.fs.path.join(arena.allocator(), &.{ cwd_path, temp_dir_name, "server", "repos", &repo_id }); 663 var dest_dir = try cwd.createDirPathOpen(io, repo_path, .{}); 664 defer dest_dir.close(io); 665 try copyDir(io, template_dir, dest_dir); 666 } 667 } 668 669 break :blk try ui.Session.init(&session_arena, &repo, .{ .user_id = &admin_user_id }); 670 }; 671 session.is_terminal = true; 672 673 // start the server 674 675 var cli = false; 676 677 var arg_it = try init.minimal.args.iterateAllocator(allocator); 678 defer arg_it.deinit(); 679 _ = arg_it.skip(); 680 while (arg_it.next()) |arg| { 681 if (std.mem.eql(u8, "--cli", arg)) { 682 cli = true; 683 } 684 } 685 686 const server_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "server" }); 687 defer allocator.free(server_path); 688 689 // let the native TUI's page builders open the on-disk repos for the file tree 690 session.io = io; 691 session.repos_dir = try std.fs.path.join(session_arena.allocator(), &.{ server_path, "repos" }); 692 693 // leave a one-shot session for the first browser to hit the web ui, so it 694 // starts logged in as admin 695 { 696 var data_dir = try cwd.createDirPathOpen(io, server_path, .{}); 697 defer data_dir.close(io); 698 const store = try hx.web.SessionStore.init(io, data_dir); 699 defer store.deinit(); 700 const token = try store.create(&admin_user_id); 701 try store.offerAutoLogin(&token); 702 } 703 704 if (cli) { 705 var stdout_writer = std.Io.File.stdout().writer(io, &.{}); 706 var stderr_writer = std.Io.File.stderr().writer(io, &.{}); 707 const run_opts = hx.main.RunOpts{ .out = &stdout_writer.interface, .err = &stderr_writer.interface }; 708 709 const key_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name, "key" }); 710 defer allocator.free(key_path); 711 712 const Runnable = struct { 713 io: std.Io, 714 key_path: []const u8, 715 716 pub fn run(self: @This(), web_port: u16, ssh_port: u16) !void { 717 _ = web_port; 718 std.debug.print( 719 \\ 720 \\connect to the TUI with: 721 \\ ssh -p {d} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null localhost 722 \\ 723 \\create a git repo and push over SSH: 724 \\ mkdir -p temp-try/client/test 725 \\ cd temp-try/client/test 726 \\ git init 727 \\ echo "hello" > hello.txt 728 \\ git add hello.txt 729 \\ git commit -m "let there be light" 730 \\ GIT_SSH_COMMAND='ssh -p {d} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -i {s}' git push localhost:admin/test HEAD:master 731 \\ 732 \\to quit, press enter. 733 \\ 734 , .{ ssh_port, ssh_port, self.key_path }); 735 // portable stdin read via std.Io — equivalent to the 736 // std.posix.read(STDIN_FILENO, ...) that doesn't compile 737 // on windows. 738 var buf: [1]u8 = undefined; 739 var stdin_reader = std.Io.File.stdin().reader(self.io, &buf); 740 _ = stdin_reader.interface.takeByte() catch {}; 741 } 742 }; 743 744 try srv.run(.xit, .{}, io, allocator, cwd_path, .{ 745 .data_dir = server_path, 746 }, run_opts.err, Runnable{ .io = io, .key_path = key_path }); 747 } else { 748 var null_writer = std.Io.Writer.Discarding.init(&.{}); 749 const run_opts = hx.main.RunOpts{ .out = &null_writer.writer, .err = &null_writer.writer }; 750 751 const Runnable = struct { 752 io: std.Io, 753 allocator: std.mem.Allocator, 754 session: *ui.Session, 755 repo: *Repo, 756 757 pub fn run(self: @This(), web_port: u16, ssh_port: u16) !void { 758 _ = ssh_port; 759 // launch the TUI; the footer points at whatever port was bound 760 self.session.web_port = web_port; 761 try hx.ui.run(self.io, self.allocator, self.session, self.repo); 762 } 763 }; 764 765 try srv.run(.xit, .{}, io, allocator, cwd_path, .{ 766 .data_dir = server_path, 767 }, run_opts.err, Runnable{ .io = io, .allocator = allocator, .session = &session, .repo = &repo }); 768 } 769 } 770 771 // words cycled through to pad generated lines into prose-like content 772 const scatter_words = [_][]const u8{ 773 "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", 774 "quantum", "vector", "matrix", "buffer", "kernel", "socket", 775 "falcon", "otter", "badger", "walrus", "ferret", "marmot", 776 "scatter", "gather", "encode", "decode", "render", "commit", 777 }; 778 779 // write `path` as 40 lines of mostly-stable filler. revision `c` and file 780 // index `fi` shift which lines change and the words used, so each (c, fi) 781 // yields a distinct multi-hunk diff against the previous revision. untouched 782 // lines stay byte-identical across revisions, so a bump in `c` is a scatter of 783 // small hunks rather than a full rewrite. 784 fn writeScatterFile(io: std.Io, allocator: std.mem.Allocator, repo_dir: std.Io.Dir, path: []const u8, fi: usize, c: usize) !void { 785 const file = try repo_dir.createFile(io, path, .{}); 786 defer file.close(io); 787 var writer = std.Io.Writer.Allocating.init(allocator); 788 defer writer.deinit(); 789 for (0..40) |line| { 790 const start = writer.written().len; 791 if ((line + fi + c) % 8 == 0) { 792 const tag = scatter_words[(line + fi + c * 3) % scatter_words.len]; 793 try writer.writer.print("rev {d} {s}", .{ c, tag }); 794 } 795 const target = 40 + (line * 17 + fi * 23) % 70; 796 var w = line + fi; 797 while (true) : (w += 1) { 798 const word = scatter_words[w % scatter_words.len]; 799 const len = writer.written().len - start; 800 if (len >= target or len + 1 + word.len > 120) break; 801 if (len == 0) 802 try writer.writer.print("{s}", .{word}) 803 else 804 try writer.writer.print(" {s}", .{word}); 805 } 806 try writer.writer.writeByte('\n'); 807 } 808 try file.writeStreamingAll(io, writer.written()); 809 } 810 811 // tag the current HEAD as the next sequential version: v1, v2, and so on 812 fn addNextTag(repo: *rp.Repo(.xit, .{}), io: std.Io, allocator: std.mem.Allocator, n: *usize) !void { 813 var buf: [16]u8 = undefined; 814 const name = try std.fmt.bufPrint(&buf, "v{d}", .{n.*}); 815 _ = try repo.addTag(io, allocator, .{ .name = name }); 816 n.* += 1; 817 } 818 819 // recursively copy the contents of src_dir into dest_dir 820 fn copyDir(io: std.Io, src_dir: std.Io.Dir, dest_dir: std.Io.Dir) !void { 821 var iter = src_dir.iterate(); 822 while (try iter.next(io)) |entry| { 823 switch (entry.kind) { 824 .file => try src_dir.copyFile(entry.name, dest_dir, entry.name, io, .{}), 825 .directory => { 826 try dest_dir.createDirPath(io, entry.name); 827 var dest_entry_dir = try dest_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 828 defer dest_entry_dir.close(io); 829 var src_entry_dir = try src_dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }); 830 defer src_entry_dir.close(io); 831 try copyDir(io, src_entry_dir, dest_entry_dir); 832 }, 833 else => {}, 834 } 835 } 836 } 837