╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │347 aa: std.mem.Allocator, │ │348 commit_object: *xit.object.Object(repo_kind, repo_opts), │ │311 -) ![]const u8 { │ │312 - const md = commit_object.content.commit.metadata; │ │313 - try commit_object.object_reader.seekTo(commit_object.content.commit.message_position); │ │314 - const message = commit_object.object_reader.interface.allocRemaining(aa, .limited(max_message_size)) catch │ │315 - return try aa.dupe(u8, std.mem.trim(u8, md.message orelse "", " \t\r\n")); │ │316 - return std.mem.trim(u8, message, " \t\r\n"); │ │349 + limit: usize, │ │350 +) !struct { []const u8, bool } { │ │351 + var message: std.ArrayList(u8) = .empty; │ │352 + // a message past the limit keeps the bytes read so far │ │353 + const truncated = if (commit_object.readMessage(aa, &message, .limited(limit))) |_| │ │354 + false │ │355 + else |err| switch (err) { │ │356 + error.StreamTooLong => true, │ │357 + else => |e| return e, │ │358 + }; │ │359 + // the cut lands wherever the limit falls, so a truncated message keeps │ │360 + // only its whole lines: a line break can't fall inside a codepoint, and │ │361 + // the widgets need valid utf-8. a first line past the limit keeps none. │ │362 + const text = if (truncated) message.items[0 .. std.mem.lastIndexOfScalar(u8, message.items, '\n') orelse 0] else message.items;│ │363 + return .{ std.mem.trim(u8, text, " \t\r\n"), truncated }; │ │364 +} │ │365 + │ │366 +// a focusable, word-wrapping box holding a commit message. `bottom_label` │ │367 +// marks a message the page only shows part of ("" when it's whole). │ │368 +fn messageBox(allocator: std.mem.Allocator, message: []const u8, bottom_label: []const u8) !wgt.TextBox(ui.Widget) { │ │369 + var tb = try wgt.TextBox(ui.Widget).init(allocator, message, .{ │ │370 + .border_style = .single, │ │371 + .rounded_corners = true, │ │372 + .wrap_kind = .word, │ │373 + .label = " commit message ", │ │374 + .bottom_label = bottom_label, │ │375 + }); │ │376 + tb.getFocus().focusable = true; │ │377 + return tb; │ │378 } │ │379 │ │380 // the first line of a commit message, for the one-row list entries. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │392 session: *ui.Session, │ │393 // the commit whose diff the pane currently shows (index into data.commits).│ │394 diffed_index: ?usize, │ │334 - // the diff pane's message row, capped to the pane width so it word-wraps. │ │335 - message_id: ?usize, │ │395 │ │396 const header_index: usize = 0; │ │397 const content_index: usize = 1; │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────╮ │472 .data = data, │ │473 .session = session, │ │474 .diffed_index = null,│ │416 - .message_id = null, │ │475 }; │ │476 } │ │477 │ ╰──────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │530 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │531 } │ │532 │ │475 - // the selected commit's full message, focusable like the rows around it. it │ │476 - // word-wraps, which needs a bounded width: the diff scroll grants its rows │ │477 - // an unbounded one, so build caps this row via `message_id`. │ │478 - fn addMessageBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), message: []const u8) !void { │ │479 - if (message.len == 0) return; │ │480 - var tb = try wgt.TextBox(ui.Widget).init(allocator, message, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = "commit message" });│ │533 + // how much of the message a pane's box holds: the preview links to the pane │ │534 + // holding the whole thing, which is that link's destination. │ │535 + const MessageBox = enum { preview, whole }; │ │536 + │ │537 + // the selected commit's message. │ │538 + fn addMessageBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), commit: Commit, kind: MessageBox) !void { │ │539 + // a truncated message keeps its box even with no whole line to show, │ │540 + // since the box is what links to the whole thing. │ │541 + if (commit.message.len == 0 and !commit.message_truncated) return; │ │542 + const cut_short = commit.message_truncated and kind == .preview; │ │543 + var tb = try messageBox(allocator, commit.message, if (cut_short) " click or press enter to see more " else ""); │ │544 errdefer tb.deinit(allocator); │ │482 - tb.getFocus().focusable = true; │ │545 + if (kind == .preview) tb.getFocus().kind = .{ .custom = try messageLink(self.session.page_arena, self.data.identity, commit.oid) }; │ │546 try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │484 - self.message_id = tb.getFocus().id; │ │547 } │ │548 │ │549 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │580 return content.children.getIndex(cid) == diff_index; │ │581 } │ │582 │ │583 + // what the pane shows for the commit at `sel`. the page's content only │ │584 + // applies to the commit it walks from; the rest show their plain diff. │ │585 + fn paneContent(self: *View, sel: usize) Self.Content { │ │586 + return if (sel == 0) self.data.content else .{ .diff = .{} }; │ │587 + } │ │588 + │ │589 // the selected commit's index, or null when the "next" row is selected.│ │590 fn selectedCommitIndex(self: *View) ?usize { │ │591 const lb = self.listBox(); │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │607 if (root_focus.grandchild_id) |g| { │ │608 if (self.box.getFocus().children.contains(g)) { │ │609 if (self.selectedCommitIndex()) |sel| { │ │610 + const oid = self.data.commits[sel].oid; │ │611 + const window_start = self.data.commits[sel].window_start; │ │612 // mirror the commit's current diff window so the url stays │ │613 // linkable (0 for any commit but the windowed start one). │ │544 - if (ui.RoutablePage.repoCommitsRoute(self.data.identity, .object, self.data.commits[sel].oid, self.data.commits[sel].window_start, if (sel == 0) self.data.path else "")) |route|│ │545 - self.session.data.current_page = route; │ │614 + // selecting another commit leaves the message behind, like │ │615 + // it leaves the path filter behind. │ │616 + const mirrored = switch (self.paneContent(sel)) { │ │617 + .message => ui.RoutablePage.repoCommitMessageRoute(self.data.identity, .object, oid), │ │618 + .diff => |d| ui.RoutablePage.repoCommitsRoute(self.data.identity, .object, oid, window_start, d.path), │ │619 + }; │ │620 + if (mirrored) |route| self.session.data.current_page = route; │ │621 } │ │622 } │ │623 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │647 diff_min_width; │ │648 self.contentBox().children.values()[diff_index].min_size = .{ .width = diff_width, .height = null };│ │649 │ │575 - // cap the message row so it wraps within the pane, leaving room for the │ │576 - // pane's border and its scrollbar column. │ │577 - if (self.message_id) |id| { │ │578 - if (self.diffInner().children.getPtr(id)) |child| { │ │650 + // the message is the pane's only wrapping row, and wrapping needs a │ │651 + // bounded width, which the pane's scroll doesn't grant (it scrolls │ │652 + // horizontally too). cap it to the pane, leaving room for the border │ │653 + // and the scrollbar column. re-capped each build so it tracks resizes. │ │654 + for (self.diffInner().children.values()) |*child| switch (child.widget) { │ │655 + .text_box => |text_box| if (text_box.options.wrap_kind == .word) { │ │656 child.max_size = .{ .width = diff_width -| 3, .height = null }; │ │580 - } │ │581 - } │ │657 + }, │ │658 + else => {}, │ │659 + }; │ │660 │ │661 // the web bounds the layout to the browser viewport like the terminal; │ │662 // each Scroll's web-native mode hands its full content to a real │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │679 │ │680 fn populateDiff(self: *View, allocator: std.mem.Allocator, sel: usize) !void {│ │681 const commit = self.data.commits[sel]; │ │604 - // the path filter only applies to the top commit (the walk root). │ │605 - const path = if (sel == 0) self.data.path else ""; │ │682 const inner = self.diffInner(); │ │683 │ │684 for (inner.children.values()) |*child| child.widget.deinit(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │685 inner.children.clearAndFree(allocator); │ │686 inner.getFocus().child_id = null; │ │611 - self.message_id = null; │ │687 │ │613 - if (path.len != 0) { │ │614 - // the file's path, then a row returning to this commit's unfiltered diff. │ │615 - var label = try wgt.TextBox(ui.Widget).init(allocator, path, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │616 - errdefer label.deinit(allocator); │ │617 - label.getFocus().focusable = true; │ │618 - try inner.children.put(allocator, label.getFocus().id, .{ .widget = .{ .text_box = label }, .rect = null, .min_size = null }); │ │619 - try self.addNavLink(allocator, inner, "← all files", commit.oid, 0, ""); │ │620 - } else { │ │621 - try self.addMessageBox(allocator, inner, commit.message); │ │622 - try self.addViewFilesLink(allocator, inner, commit.oid); │ │623 - } │ │688 + switch (self.paneContent(sel)) { │ │689 + .message => { │ │690 + try self.addNavLink(allocator, inner, "← back to diff", commit.oid, 0, ""); │ │691 + try self.addMessageBox(allocator, inner, commit, .whole); │ │692 + }, │ │693 + .diff => |d| { │ │694 + if (d.path.len != 0) { │ │695 + // the file's path, then a row returning to this commit's unfiltered diff. │ │696 + var label = try wgt.TextBox(ui.Widget).init(allocator, d.path, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │697 + errdefer label.deinit(allocator); │ │698 + label.getFocus().focusable = true; │ │699 + try inner.children.put(allocator, label.getFocus().id, .{ .widget = .{ .text_box = label }, .rect = null, .min_size = null }); │ │700 + try self.addNavLink(allocator, inner, "← all files", commit.oid, 0, ""); │ │701 + } else { │ │702 + try self.addMessageBox(allocator, inner, commit, .preview); │ │703 + try self.addViewFilesLink(allocator, inner, commit.oid); │ │704 + } │ │705 │ │625 - // a "previous" row and a "next" row at the bottom reload the page on the │ │626 - // adjacent diff window. │ │627 - if (commit.has_prev) try self.addNavLink(allocator, inner, "← previous", commit.oid, commit.window_start -| diff_page, path); │ │628 - for (commit.hunks) |hunk| { │ │629 - if (path.len == 0) if (hunk.path) |hunk_path| try self.addPathBox(allocator, inner, hunk_path, commit.oid); │ │630 - try self.addHunkBox(allocator, inner, hunk.lines); │ │706 + // a "previous" row and a "next" row at the bottom reload the page │ │707 + // on the adjacent diff window. │ │708 + if (commit.has_prev) try self.addNavLink(allocator, inner, "← previous", commit.oid, commit.window_start -| diff_page, d.path); │ │709 + for (commit.hunks) |hunk| { │ │710 + if (d.path.len == 0) if (hunk.path) |hunk_path| try self.addPathBox(allocator, inner, hunk_path, commit.oid); │ │711 + try self.addHunkBox(allocator, inner, hunk.lines); │ │712 + } │ │713 + if (commit.has_more) try self.addNavLink(allocator, inner, "next →", commit.oid, commit.window_start + diff_page, d.path); │ │714 + }, │ │715 } │ │632 - if (commit.has_more) try self.addNavLink(allocator, inner, "next →", commit.oid, commit.window_start + diff_page, path); │ │716 │ │717 // point the pane at its first row so focus recovery can land here. │ │718 if (inner.children.count() > 0) inner.getFocus().child_id = inner.children.keys()[0]; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │949 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); │ │950 } │ │951 │ │952 +// the "a:" link to the page showing `oid`'s message on its own. │ │953 +fn messageLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, oid: []const u8) ![]const u8 { │ │954 + const route = ui.RoutablePage.repoCommitMessageRoute(identity, .object, oid) orelse return error.RouteTooLong;│ │955 + const url = try route.toUrl(page_arena); │ │956 + return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); │ │957 +} │ │958 + │ │959 // the "viewing <ref_or_oid> <value>" banner shown above the log. │ │960 pub const Header = struct { │ │961 content: []const u8, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯