╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Files.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
69 const gop = try children.getOrPut(gpa, name); 70 if (!gop.found_existing) gop.value_ptr.* = is_dir else if (is_dir) gop.value_ptr.* = true; 71 } 72 73 // sort directories first, then alphabetically, and dupe into the page arena. 74 const Pair = struct { name: []const u8, is_dir: bool }; 75 var pairs = try gpa.alloc(Pair, children.count()); 76 defer gpa.free(pairs); 77 for (children.keys(), children.values(), 0..) |name, is_dir, i| pairs[i] = .{ .name = name, .is_dir = is_dir }; 78 std.mem.sort(Pair, pairs, {}, struct { 79 fn lessThan(_: void, a: Pair, b: Pair) bool { 80 if (a.is_dir != b.is_dir) return a.is_dir; 81 return std.mem.lessThan(u8, a.name, b.name); 82 } 83 }.lessThan); 84 85 const entries = try aa.alloc(Entry, pairs.len); 86 for (pairs, 0..) |pair, i| entries[i] = .{ .name = try aa.dupe(u8, pair.name), .is_dir = pair.is_dir }; 87 88 return .{ .identity = empty.identity, .dir = empty.dir, .entries = entries }; 89 } 90 91 pub const View = struct { 92 // a vertical list: one focusable row per entry. each subdirectory row is an 93 // `a:` link to its /repo/.../files/<path> route, so following it (click or 94 // Enter) navigates through the host's link handling, like the Users/Repos 95 // lists. files are unlinked. a ".." row at the top links to the parent. 96 scroll: wgt.Scroll(ui.Widget), // wraps a vertical Box of rows 97 data: *const Self, 98 99 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 100 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 101 errdefer box.deinit(allocator); 102 103 // labels and link kinds are borrowed by the rows, so they live in the 104 // page arena (as long as this page's widget tree). 105 const aa = session.page_arena.allocator(); 106 if (data.dir.len != 0) { 107 try addRow(allocator, &box, "..", try dirLink(session.page_arena, data.identity, parentDir(data.dir))); 108 } 109 for (data.entries) |entry| { 110 const label = if (entry.is_dir) try std.fmt.allocPrint(aa, "{s}/", .{entry.name}) else entry.name; 111 const link = if (entry.is_dir) try dirLink(session.page_arena, data.identity, try childDir(aa, data.dir, entry.name)) else ""; 112 try addRow(allocator, &box, label, link); 113 } 114 if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; 115 116 var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 117 errdefer scroll.deinit(allocator); 118 return .{ .scroll = scroll, .data = data }; 119 } 120 121 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 122 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 123 errdefer row.deinit(allocator); 124 row.getFocus().focusable = true; 125 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 126 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 127 } 128 129 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 130 self.scroll.deinit(allocator); 131 } 132 133 fn innerBox(self: *View) *wgt.Box(ui.Widget) { 134 return &self.scroll.child.box; 135 } 136 137 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 138 self.clearGrid(); 139 // the selected row shows a border (the focused TextBox upgrades it to a 140 // double border itself); the rest stay borderless. 141 const box = self.innerBox(); 142 for (box.children.keys(), box.children.values()) |id, *child| { 143 switch (child.widget) { 144 .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, 145 else => {}, 146 } 147 } 148 // clear the incoming min_size so rows size to their content rather than 149 // stretching to fill the page height; max_size still bounds the scroll 150 // viewport so a long listing clips and scrolls. 151 try self.scroll.build(allocator, .{ 152 .min_size = .{ .width = null, .height = null }, 153 .max_size = constraint.max_size, 154 }, root_focus); 155 } 156 157 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 158 _ = allocator; 159 // up/down move the selection. Enter and clicks on a link row are turned 160 // into navigation by the host (crossPageLink); the ".." row and the 161 // browser Back button (or Escape on the TUI) handle ascending. 162 switch (key) { 163 .arrow_down => try self.moveSelection(root_focus, 1), 164 .arrow_up => try self.moveSelection(root_focus, -1), 165 else => {}, 166 } 167 } 168 169 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { 170 const box = self.innerBox(); 171 const keys = box.children.keys(); 172 const cur_id = box.getFocus().child_id orelse return; 173 const cur = box.children.getIndex(cur_id) orelse return; 174 if (delta < 0 and cur == 0) return; 175 if (delta > 0 and cur + 1 >= keys.len) return; 176 const next = if (delta < 0) cur - 1 else cur + 1; 177 try root_focus.setFocus(keys[next]); 178 if (box.children.values()[next].rect) |rect| self.scroll.scrollToRect(rect); 179 } 180 181 pub fn clearGrid(self: *View) void { 182 self.scroll.clearGrid(); 183 } 184 185 pub fn getGrid(self: View) ?Grid { 186 return self.scroll.getGrid(); 187 } 188 189 pub fn getFocus(self: *View) *Focus { 190 return self.scroll.getFocus(); 191 } 192 193 pub fn getSelectedIndex(self: View) ?usize { 194 const box = &self.scroll.child.box; 195 const child_id = box.focus.child_id orelse return null; 196 return box.children.getIndex(child_id); 197 } 198 }; 199 200 // the "a:" link for the files route at `dir` within `identity` ("owner/name"). 201 fn dirLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, dir: []const u8) ![]const u8 { 202 const route = ui.RoutablePage.repoFilesRoute(identity, dir) orelse return error.RouteTooLong; 203 const url = try route.urlAlloc(page_arena); 204 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 205 } 206 207 // `dir`/`name`, or just `name` at the root. 208 fn childDir(arena: std.mem.Allocator, dir: []const u8, name: []const u8) ![]const u8 { 209 return if (dir.len == 0) name else std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); 210 } 211 212 // `dir` with its last segment removed ("" when `dir` has a single segment). 213 fn parentDir(dir: []const u8) []const u8 { 214 const slash = std.mem.lastIndexOfScalar(u8, dir, '/') orelse return ""; 215 return dir[0..slash]; 216 } 217