╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │16 // how many lines of a file's content one window shows before a "next" link. │ │17 const file_page = 2000; │ │18 │ │19 -// the most directory entries (files + dirs) we read from a tree and display. │ │20 -// each file's content is also loaded (windowed), so an unbounded directory could│ │21 -// add up to a lot of memory; entries past this limit are ignored. │ │19 +// how many files in a directory get their content preloaded (windowed) so │ │20 +// selecting them shows contents without a reload. every entry is still listed; │ │21 +// files past this limit carry an "a:" link instead, so activating them reloads │ │22 +// the page with that file selected (and its content loaded as the selection). │ │23 // │ │23 -// TODO: this caps what we keep, but `tr.Tree.init` still flattens the entire │ │24 -// repo tree (every path) into memory before we ever apply the cap. read the │ │25 -// directory level-by-level instead, using `obj.Object` to load one tree object │ │26 -// at a time: initCommit -> root tree, then descend each `dir` segment, reading │ │27 -// only the immediate entries of the target tree (and applying max_entries │ │28 -// there). that bounds the work to O(depth) tree objects rather than the whole │ │29 -// repo. it's doable entirely in haxy (no xit change) — `obj.Object(...).init` │ │30 -// on a tree oid already returns just that tree's immediate children. │ │31 -const max_entries = 100; │ │24 +// TODO: `tr.Tree.init` still flattens the entire repo tree (every path) into │ │25 +// memory before we ever filter to one directory. read the directory │ │26 +// level-by-level instead, using `obj.Object` to load one tree object at a │ │27 +// time: initCommit -> root tree, then descend each `dir` segment, reading │ │28 +// only the immediate entries of the target tree. that bounds the work to │ │29 +// O(depth) tree objects rather than the whole repo. it's doable entirely in │ │30 +// haxy (no xit change) — `obj.Object(...).init` on a tree oid already returns │ │31 +// just that tree's immediate children. │ │32 +const max_preloaded = 100; │ │33 │ │34 // one entry in the directory currently being viewed. │ │35 pub const Entry = struct { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │46 window_start: usize = 0, │ │47 // whether more lines exist after this window. │ │48 has_more: bool = false, │ │49 + // whether this file's content was read; unloaded files load via their│ │50 + // "a:" link. │ │51 + loaded: bool = false, │ │52 }; │ │53 │ │54 // "owner/name", so the view can build /repo/owner/name/files/... links. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │127 const slash = std.mem.indexOfScalar(u8, rel, '/'); │ │128 const name = if (slash) |s| rel[0..s] else rel; │ │129 const is_dir = slash != null; │ │126 - // stop taking new entries once we hit the cap; the rest are ignored. │ │127 - if (children.count() >= max_entries and !children.contains(name)) continue; │ │130 const gop = try children.getOrPut(gpa, name); │ │131 if (!gop.found_existing) gop.value_ptr.* = is_dir else if (is_dir) gop.value_ptr.* = true;│ │132 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │142 // arena) so the detail pane can show them without another lookup.│ │143 const entries = try aa.alloc(Entry, children.count()); │ │144 var i: usize = 0; │ │145 + var preloaded: usize = 0; │ │146 for ([_]bool{ true, false }) |want_dir| { │ │147 for (children.keys(), children.values()) |name, is_dir| { │ │148 if (is_dir != want_dir) continue; │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │149 var entry: Entry = .{ .name = try aa.dupe(u8, name), .is_dir = is_dir }; │ │150 if (!is_dir) { │ │148 - const file_path = try childDir(aa, dir, name); │ │149 - if (tree.entries.get(file_path)) |tree_entry| { │ │150 - // only the route's selected file paginates; the rest show │ │151 - // their first window (for the in-page detail when selected). │ │152 - const is_selected = if (selected_file) |sf| std.mem.eql(u8, sf, name) else false; │ │153 - const window_start = if (is_selected) start else 0; │ │154 - const content = readFileContent(repo_kind, repo_opts, state, io, gpa, aa, file_path, tree_entry, window_start) catch │ │155 - FileContent{ .lines = &.{} }; │ │156 - entry.lines = content.lines; │ │157 - entry.is_binary = content.is_binary; │ │158 - entry.window_start = window_start; │ │159 - entry.has_more = content.has_more; │ │151 + // only the route's selected file paginates; the rest show │ │152 + // their first window (for the in-page detail when selected). │ │153 + const is_selected = if (selected_file) |sf| std.mem.eql(u8, sf, name) else false; │ │154 + // the README always loads because it's the default selection. │ │155 + if (preloaded < max_preloaded or is_selected or isReadme(name)) { │ │156 + const file_path = try childDir(aa, dir, name); │ │157 + if (tree.entries.get(file_path)) |tree_entry| { │ │158 + preloaded += 1; │ │159 + const window_start = if (is_selected) start else 0; │ │160 + const content = readFileContent(repo_kind, repo_opts, state, io, gpa, aa, file_path, tree_entry, window_start) catch│ │161 + FileContent{ .lines = &.{} }; │ │162 + entry.lines = content.lines; │ │163 + entry.is_binary = content.is_binary; │ │164 + entry.window_start = window_start; │ │165 + entry.has_more = content.has_more; │ │166 + entry.loaded = true; │ │167 + } │ │168 } │ │169 } │ │170 entries[i] = entry; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │258 const detail_scroll_index: usize = 1; │ │259 const list_max_width: usize = 40; │ │260 const detail_min_width: usize = 40; │ │261 + // a list row's height: one text line inside its (hidden) border. │ │262 + const row_height: usize = 3; │ │263 │ │264 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │265 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert });│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │293 const link = if (entry.is_dir) │ │294 try dirLink(session.page_arena, data, path) │ │295 else │ │286 - try fileLink(session.page_arena, data, path); │ │296 + try fileLink(session.page_arena, data, path, entry.loaded);│ │297 try addRow(allocator, &list_box, label, link); │ │298 } │ │299 // select the file the route named, else prefer a README so its │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │300 // contents greet the visitor in the detail pane, else the first │ │301 // row. a non-root directory has a leading ".." row, so the entry │ │302 // index is offset by one there. │ │303 + var sel: usize = 0; │ │304 if (list_box.children.count() > 0) { │ │305 const base: usize = if (data.dir.len != 0) 1 else 0; │ │306 const entry_idx = selectedFileIndex(data) orelse readmeIndex(data.entries);│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │296 - const sel = if (entry_idx) |i| i + base else 0; │ │307 + sel = if (entry_idx) |i| i + base else 0; │ │308 list_box.getFocus().child_id = list_box.children.keys()[sel]; │ │309 } │ │299 - break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │310 + var sc = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │311 + // start scrolled so the selected row begins at the top of the │ │312 + // viewport. rows are a fixed height, so no layout pass is │ │313 + // needed; a too-far offset near the end of the list is clamped │ │314 + // in build (the browser clamps its own scrolling natively). │ │315 + sc.y = @intCast(sel * row_height); │ │316 + break :blk sc; │ │317 }; │ │318 errdefer list_scroll.deinit(allocator); │ │319 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } });│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │357 // recovery descends the frame's selected child). the nav links │ │358 // above are reached by scrolling to the top, then up. │ │359 frame.getFocus().child_id = scroll_frame.getFocus().id; │ │343 - try frame.children.put(allocator, scroll_frame.getFocus().id, .{ .widget = .{ .box = scroll_frame }, .rect = null, .min_size = null }); │ │360 + // shrink, so the fill scroll yields the nav box's row │ │361 + // instead of consuming the frame's whole height. │ │362 + try frame.children.put(allocator, scroll_frame.getFocus().id, .{ .widget = .{ .box = scroll_frame }, .rect = null, .min_size = null, .shrink = true });│ │363 } │ │364 break :blk frame; │ │365 }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯