╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Commits.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
812 // the host (it reloads the adjacent window), so they don't reach here. 813 .mouse => |mouse| switch (mouse.action) { 814 .scroll => |dir| try self.moveDiff(root_focus, if (dir == .up) -1 else 1), 815 else => {}, 816 }, 817 else => {}, 818 } 819 } 820 821 // move one step in `delta` (+ down, - up). in the terminal, `delta` is a 822 // line count unless there is a new hunk visible (in which case it is a 823 // hunk count). on the web `delta` is always a hunk count. 824 fn moveDiff(self: *View, root_focus: *Focus, delta: isize) !void { 825 const inner = self.diffInner(); 826 const keys = inner.children.keys(); 827 if (keys.len == 0) return; 828 const cur: usize = if (root_focus.grandchild_id) |g| (inner.children.getIndex(g) orelse 0) else 0; 829 const target = @as(isize, @intCast(cur)) + delta; 830 const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); 831 832 if (self.session.is_terminal) { 833 if (in_range and self.hunkVisible(@intCast(target))) { 834 root_focus.setFocus(keys[@intCast(target)]); 835 return; 836 } 837 838 const sc = self.diffScroll(); 839 sc.y += delta * 5; // magnify because it's a line count 840 self.diffScroll().clampToContent(); 841 if (in_range and self.hunkVisible(@intCast(target))) { 842 root_focus.setFocus(keys[@intCast(target)]); 843 } 844 } else { 845 if (in_range) { 846 root_focus.setFocus(keys[@intCast(target)]); 847 } 848 } 849 } 850 851 // page a fixed number of lines, then focus the leading visible hunk 852 // (bottom-most when paging down, top-most when paging up). in the 853 // terminal, `delta` is a line count, and on the web it's a hunk count. 854 fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { 855 if (self.session.is_terminal) { 856 const sc = self.diffScroll(); 857 sc.y += delta * 5; // magnify because it's a line count 858 self.diffScroll().clampToContent(); 859 try self.focusVisible(root_focus, delta > 0); 860 } else { 861 const inner = self.diffInner(); 862 const keys = inner.children.keys(); 863 if (keys.len == 0) return; 864 const cur: isize = if (root_focus.grandchild_id) |g| @intCast(inner.children.getIndex(g) orelse 0) else 0; 865 const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); 866 root_focus.setFocus(keys[target]); 867 } 868 } 869 870 // jump to the first or last hunk. on the web the browser scrolls to the 871 // focused hunk; on the terminal pin the scroll to the top/bottom too. 872 fn jumpDiff(self: *View, root_focus: *Focus, to_end: bool) !void { 873 const inner = self.diffInner(); 874 const keys = inner.children.keys(); 875 if (keys.len == 0) return; 876 if (self.session.is_terminal) { 877 const sc = self.diffScroll(); 878 sc.y = if (to_end) std.math.maxInt(isize) else 0; 879 self.diffScroll().clampToContent(); 880 } 881 root_focus.setFocus(if (to_end) keys[keys.len - 1] else keys[0]); 882 } 883 884 // whether hunk `index` is at least partly within the diff viewport, per the 885 // last build's layout rects (content space) and the current scroll offset. 886 fn hunkVisible(self: *View, index: usize) bool { 887 const inner = self.diffInner(); 888 const sc = self.diffScroll(); 889 const vp = sc.grid orelse return false; 890 const r = inner.children.values()[index].rect orelse return false; 891 const top = sc.y; 892 const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); 893 return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; 894 } 895 896 // focus the visible hunk at the scroll's leading edge. `prefer_last` picks 897 // the bottom-most visible (for downward motion), else the top-most. 898 fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) !void { 899 const inner = self.diffInner(); 900 const sc = self.diffScroll(); 901 const vp = sc.grid orelse return; 902 const top = sc.y; 903 const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); 904 var chosen: ?usize = null; 905 for (inner.children.keys(), inner.children.values()) |id, *child| { 906 const r = child.rect orelse continue; // content-space layout rect 907 const r_top = r.y; 908 const r_bot = r.y + @as(isize, @intCast(r.size.height)); 909 if (r_bot <= top or r_top >= bottom) continue; // not visible 910 chosen = id; 911 if (!prefer_last) break; // first visible 912 } 913 if (chosen) |id| root_focus.setFocus(id); 914 } 915 916 // enter the diff pane. the host arrives here on right-arrow or Enter from the 917 // list. a diff with no rows can't be entered. setFocus handles the too-narrow 918 // case where the pane isn't laid out yet (it gets selected, then focused after 919 // the next build, landing on its remembered hunk). 920 fn focusDiff(self: *View, root_focus: *Focus) !void { 921 if (self.diffInner().children.count() == 0) return; 922 root_focus.setFocus(self.diffOuter().getFocus().id); 923 } 924 925 // return to the list. 926 fn focusList(self: *View, root_focus: *Focus) !void { 927 root_focus.setFocus(self.listScroll().getFocus().id); 928 } 929 930 pub fn clearGrid(self: *View) void { 931 self.box.clearGrid(); 932 } 933 934 pub fn getGrid(self: View) ?Grid { 935 return self.box.getGrid(); 936 } 937 938 pub fn getFocus(self: *View) *Focus { 939 return self.box.getFocus(); 940 } 941 942 fn contentAtTop(self: *View) bool { 943 if (self.diffActive()) { 944 const inner = self.diffInner(); 945 const cid = inner.getFocus().child_id orelse return true; 946 const first_focused = inner.children.count() > 0 and cid == inner.children.keys()[0]; 947 return first_focused and self.diffScroll().y == 0; 948 } 949 const lb = self.listBox(); 950 const cid = lb.getFocus().child_id orelse return true; 951 return lb.children.getIndex(cid) == 0; 952 } 953 954 // only the clone-url row sits directly below the repository header. 955 pub fn getSelectedIndex(self: *View) ?usize { 956 if (self.headerActive()) return 0; 957 return if (self.header().hasCloneUrl() or !self.contentAtTop()) 1 else 0; 958 } 959 960 pub fn focusCloneUrl(self: *View, root_focus: *Focus) bool { 961 return self.header().focusCloneUrl(root_focus); 962 } 963 }; 964 965 // the "a:" navigation link for the commits page walking from commit `oid` within 966 // `location`, windowing the selected commit's diff from hunk `start`, filtered 967 // to `path` ("" = every file). 968 fn commitsLink(page_arena: *std.heap.ArenaAllocator, location: ui.RoutablePage.RepoLocation, oid: []const u8, start: usize, path: []const u8) ![]const u8 { 969 const route = location.commitsRoute(.object, oid, start, path) orelse return error.RouteTooLong; 970 const url = try route.toUrl(page_arena); 971 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 972 } 973 974 // the "a:" link to the files tab at commit `oid` (an object ref), at its root 975 // directory, within `location`. 976 fn filesObjectLink(page_arena: *std.heap.ArenaAllocator, location: ui.RoutablePage.RepoLocation, oid: []const u8) ![]const u8 { 977 const route = location.filesRoute(.object, oid, "", 0) orelse return error.RouteTooLong; 978 const url = try route.toUrl(page_arena); 979 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 980 } 981 982 // the in-page "ai:" anchor for selecting a commit with its current pane content 983 // and window. the href is only followed with js off. 984 fn commitRowLink(page_arena: *std.heap.ArenaAllocator, location: ui.RoutablePage.RepoLocation, commit: Commit, content: Content) ![]const u8 { 985 const route = (switch (content) { 986 .message => location.commitMessageRoute(.object, commit.oid), 987 .diff => |diff| location.commitsRoute(.object, commit.oid, commit.window_start, diff.path), 988 }) orelse return error.RouteTooLong; 989 const url = try route.toUrl(page_arena); 990 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 991 } 992 993 // the "a:" link to the page showing `oid`'s message on its own. 994 fn messageLink(page_arena: *std.heap.ArenaAllocator, location: ui.RoutablePage.RepoLocation, oid: []const u8) ![]const u8 { 995 const route = location.commitMessageRoute(.object, oid) orelse return error.RouteTooLong; 996 const url = try route.toUrl(page_arena); 997 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 998 } 999 1000 // the "<ref_or_oid> <value>" banner shown above the log. 1001 pub const Header = struct { 1002 content: []const u8, 1003 1004 // `value` arrives url-encoded, so decode it for display. 1005 pub fn init(aa: std.mem.Allocator, ref_or_oid: ui.RoutablePage.RefOrOid, value: []const u8) !Header { 1006 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, value)); 1007 return .{ 1008 .content = try std.fmt.allocPrint(aa, "{s} {s}", .{ @tagName(ref_or_oid), decoded }), 1009 }; 1010 } 1011 1012 pub const View = struct { 1013 box: wgt.Box(ui.Widget), 1014 data: *const Header, 1015 1016 pub fn init(allocator: std.mem.Allocator, data: *const Header, session: *ui.Session, location: ui.RoutablePage.RepoLocation) !Header.View { 1017 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 1018 errdefer box.deinit(allocator); 1019 1020 if (!session.data.is_local) { 1021 if (try ui.widget.CopyableText.initClone(allocator, session, location)) |value| { 1022 var clone_url = value; 1023 errdefer clone_url.deinit(allocator); 1024 const min_width = clone_url.minWidth(); 1025 box.getFocus().child_id = clone_url.getFocus().id; 1026 try box.children.put(allocator, clone_url.getFocus().id, .{ .widget = .{ .copyable_text = clone_url }, .rect = null, .min_size = .{ .width = min_width, .height = 3 }, .max_size = .{ .width = min_width, .height = 3 } }); 1027 } 1028 } 1029 1030 var spacer = try ui.widget.Spacer.init(allocator); 1031 errdefer spacer.deinit(allocator); 1032 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1033 1034 var text_box = try wgt.TextBox.init(allocator, data.content, .{ .border_style = .hidden, .wrap_kind = .none }); 1035 errdefer text_box.deinit(allocator); 1036 try box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null, .flex = .shrink }); 1037 1038 return .{ .box = box, .data = data }; 1039 } 1040 1041 pub fn deinit(self: *Header.View, allocator: std.mem.Allocator) void { 1042 self.box.deinit(allocator); 1043 } 1044 1045 pub fn build(self: *Header.View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1046 self.clearGrid(); 1047 try self.box.build(allocator, constraint, root_focus); 1048 } 1049 1050 pub fn input(self: *Header.View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1051 const clone_url = self.cloneUrl() orelse return; 1052 try clone_url.input(allocator, key, root_focus); 1053 } 1054 1055 pub fn focusCloneUrl(self: *Header.View, root_focus: *Focus) bool { 1056 const clone_url = self.cloneUrl() orelse return false; 1057 root_focus.setFocus(clone_url.getFocus().id); 1058 return true; 1059 } 1060 1061 pub fn hasCloneUrl(self: *Header.View) bool { 1062 return self.cloneUrl() != null; 1063 } 1064 1065 fn cloneUrl(self: *Header.View) ?*ui.widget.CopyableText { 1066 for (self.box.children.values()) |*child| switch (child.widget) { 1067 .copyable_text => |*copyable_text| return copyable_text, 1068 else => {}, 1069 }; 1070 return null; 1071 } 1072 1073 pub fn clearGrid(self: *Header.View) void { 1074 self.box.clearGrid(); 1075 } 1076 1077 pub fn getGrid(self: Header.View) ?Grid { 1078 return self.box.getGrid(); 1079 } 1080 1081 pub fn getFocus(self: *Header.View) *Focus { 1082 return self.box.getFocus(); 1083 } 1084 }; 1085 }; 1086