╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │356 self.clearGrid(); │ │213 - // the selected row shows a border (the focused TextBox upgrades it to a │ │214 - // double border itself); the rest stay borderless. │ │215 - const box = self.innerBox(); │ │216 - for (box.children.keys(), box.children.values()) |id, *child| { │ │357 + │ │358 + // swap the detail pane to the selected entry when the selection changes. │ │359 + try self.refreshDetail(allocator); │ │360 + │ │361 + // the selected list row shows a border (the focused TextBox upgrades it │ │362 + // to a double border itself); the rest stay borderless. │ │363 + const lb = self.listBox(); │ │364 + for (lb.children.keys(), lb.children.values()) |id, *child| { │ │365 + switch (child.widget) { │ │366 + .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, │ │367 + else => {}, │ │368 + } │ │369 + } │ │370 + │ │371 + // the focused content box shows a single border (the focused TextBox │ │372 + // upgrades it to a double border itself, so it stays single when focus is │ │373 + // on the list); the rest keep their space-reserving hidden border. │ │374 + const inner = self.detailInner(); │ │375 + for (inner.children.keys(), inner.children.values()) |id, *child| { │ │376 switch (child.widget) { │ │218 - .text_box => |*tb| tb.options.border_style = if (box.getFocus().child_id == id) .single else .hidden, │ │377 + .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden,│ │378 else => {}, │ │379 } │ │380 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │222 - // clear the incoming min_size so rows size to their content rather than │ │223 - // stretching to fill the page height; max_size still bounds the scroll │ │224 - // viewport so a long listing clips and scrolls. │ │225 - try self.box.build(allocator, .{ │ │226 - .min_size = .{ .width = null, .height = null }, │ │227 - .max_size = constraint.max_size, │ │228 - }, root_focus); │ │381 + │ │382 + // cap the list at list_max_width only while the detail pane fits beside │ │383 + // it. the box drops the detail pane when the width can't hold both │ │384 + // minimums, so when it's that narrow we lift the cap and let the list │ │385 + // fill the whole width. │ │386 + const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; │ │387 + self.contentBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null;│ │388 + │ │389 + try self.box.build(allocator, constraint, root_focus); │ │390 + │ │391 + // if the detail pane is selected but focus is still elsewhere in this │ │392 + // view, the pane was too narrow to lay out when focus crossed over. it's │ │393 + // laid out now, so focus its content box. │ │394 + if (self.detailActive() and inner.children.count() > 0) { │ │395 + if (root_focus.grandchild_id) |g| { │ │396 + const in_view = self.box.getFocus().children.contains(g); │ │397 + const in_detail = inner.children.contains(g); │ │398 + if (in_view and !in_detail) │ │399 + try root_focus.setFocus(inner.getFocus().child_id orelse inner.children.keys()[0]); │ │400 + } │ │401 + } │ │402 + } │ │403 + │ │404 + fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { │ │405 + const cur = self.selectedRowIndex(); │ │406 + if (std.meta.eql(cur, self.shown_index)) return; │ │407 + try self.populateDetail(allocator); │ │408 + self.shown_index = cur; │ │409 + } │ │410 + │ │411 + fn populateDetail(self: *View, allocator: std.mem.Allocator) !void { │ │412 + const inner = self.detailInner(); │ │413 + │ │414 + for (inner.children.values()) |*child| child.widget.deinit(allocator); │ │415 + inner.children.clearAndFree(allocator); │ │416 + inner.getFocus().child_id = null; │ │417 + │ │418 + // only files have contents to show; directories and the ".." row leave │ │419 + // the pane empty. │ │420 + if (self.selectedEntry()) |entry| { │ │421 + if (!entry.is_dir) { │ │422 + if (entry.is_binary) { │ │423 + try self.addContentBox(allocator, inner, "(binary file)"); │ │424 + } else { │ │425 + const text = try std.mem.join(self.session.page_arena.allocator(), "\n", entry.lines); │ │426 + try self.addContentBox(allocator, inner, text); │ │427 + } │ │428 + } │ │429 + } │ │430 + │ │431 + // reset the scroll to the top for the newly-shown file: directly on the │ │432 + // terminal (the wasm offset), and via a version bump on the web (so the │ │433 + // renderer's scroll id changes and JS drops the preserved position). │ │434 + const sc = self.detailScroll(); │ │435 + sc.x = 0; │ │436 + sc.y = 0; │ │437 + sc.getFocus().version +%= 1; │ │438 } │ │439 │ │440 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │441 _ = allocator; │ │233 - // up/down move the selection. Enter and clicks on a link row are turned │ │234 - // into navigation by the host (crossPageLink); the ".." row and the │ │235 - // browser Back button (or Escape on the TUI) handle ascending. │ │442 + if (self.detailActive()) { │ │443 + try self.detailInput(key, root_focus); │ │444 + } else { │ │445 + try self.listInput(key, root_focus); │ │446 + } │ │447 + } │ │448 + │ │449 + fn listInput(self: *View, key: inp.Key, root_focus: *Focus) !void { │ │450 + // up/down (and the scroll wheel) move the selection; page up/down jump a │ │451 + // fixed amount. right/Enter cross into the detail pane. Enter/clicks on a │ │452 + // directory (or "..") row are turned into navigation by the host │ │453 + // (crossPageLink) before reaching here. │ │454 switch (key) { │ │455 + .enter, .arrow_right => try self.focusDetail(root_focus), │ │456 .arrow_down => try self.moveSelection(root_focus, 1), │ │457 .arrow_up => try self.moveSelection(root_focus, -1), │ │458 + .page_down => try self.moveSelection(root_focus, 10), │ │459 + .page_up => try self.moveSelection(root_focus, -10), │ │460 + .end => try self.moveSelection(root_focus, @intCast(self.listBox().children.count())), │ │461 + .home => try self.moveSelection(root_focus, -@as(isize, @intCast(self.listBox().children.count()))),│ │462 + .mouse => |mouse| switch (mouse.action) { │ │463 + .scroll => |dir| try self.moveSelection(root_focus, if (dir == .up) -1 else 1), │ │464 + else => {}, │ │465 + }, │ │466 else => {}, │ │467 } │ │468 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │469 │ │470 + fn detailInput(self: *View, key: inp.Key, root_focus: *Focus) !void { │ │471 + const sc = self.detailScroll(); │ │472 + // on the web each Scroll is a native scrollable element, so vertical │ │473 + // scrolling is handled by the browser; the terminal scrolls the content │ │474 + // by moving the offset. left scrolls horizontally, then returns to the │ │475 + // list once flush left. │ │476 + switch (key) { │ │477 + .arrow_left => { │ │478 + if (sc.x > 0) { │ │479 + sc.x -= 1; │ │480 + self.clampDetailScroll(); │ │481 + } else try self.focusList(root_focus); │ │482 + }, │ │483 + .arrow_right => { │ │484 + sc.x += 1; │ │485 + self.clampDetailScroll(); │ │486 + }, │ │487 + .arrow_up => if (self.session.is_terminal) { │ │488 + sc.y -= 1; │ │489 + self.clampDetailScroll(); │ │490 + }, │ │491 + .arrow_down => if (self.session.is_terminal) { │ │492 + sc.y += 1; │ │493 + self.clampDetailScroll(); │ │494 + }, │ │495 + .page_up => if (self.session.is_terminal) { │ │496 + sc.y -= 10; │ │497 + self.clampDetailScroll(); │ │498 + }, │ │499 + .page_down => if (self.session.is_terminal) { │ │500 + sc.y += 10; │ │501 + self.clampDetailScroll(); │ │502 + }, │ │503 + .home => if (self.session.is_terminal) { │ │504 + sc.y = 0; │ │505 + self.clampDetailScroll(); │ │506 + }, │ │507 + .end => if (self.session.is_terminal) { │ │508 + sc.y = std.math.maxInt(isize); │ │509 + self.clampDetailScroll(); │ │510 + }, │ │511 + .mouse => |mouse| switch (mouse.action) { │ │512 + .scroll => |dir| if (self.session.is_terminal) { │ │513 + sc.y += if (dir == .up) -5 else 5; │ │514 + self.clampDetailScroll(); │ │515 + }, │ │516 + else => {}, │ │517 + }, │ │518 + else => {}, │ │519 + } │ │520 + } │ │521 + │ │522 + // keep the detail scroll within its content, using the last build's grids. │ │523 + // the scroll bar's reserved column/row isn't part of the content viewport, │ │524 + // so exclude it or the last column/row stays unreachable. │ │525 + fn clampDetailScroll(self: *View) void { │ │526 + const sc = self.detailScroll(); │ │527 + const vp = sc.grid orelse return; │ │528 + const content = sc.child.box.grid orelse return; │ │529 + const view_w = vp.size.width - sc.bar_w; │ │530 + const view_h = vp.size.height - sc.bar_h; │ │531 + const max_y: isize = if (content.size.height > view_h) @intCast(content.size.height - view_h) else 0; │ │532 + const max_x: isize = if (content.size.width > view_w) @intCast(content.size.width - view_w) else 0; │ │533 + sc.y = std.math.clamp(sc.y, 0, max_y); │ │534 + sc.x = std.math.clamp(sc.x, 0, max_x); │ │535 + } │ │536 + │ │537 + // enter the detail pane by focusing its content box. the host arrives here on │ │538 + // right-arrow or Enter from the list. an empty pane (a directory or ".." row │ │539 + // is selected) can't be entered. │ │540 + fn focusDetail(self: *View, root_focus: *Focus) !void { │ │541 + const inner = self.detailInner(); │ │542 + if (inner.children.count() == 0) return; │ │543 + const target = inner.getFocus().child_id orelse inner.children.keys()[0]; │ │544 + try root_focus.setFocus(target); │ │545 + if (root_focus.grandchild_id == target) return; │ │546 + // the detail pane wasn't laid out last build (too narrow to show beside │ │547 + // the list), so its content isn't in the focus tree yet. select the pane │ │548 + // at the box level, and the content will be focused after the next build. │ │549 + self.contentBox().getFocus().child_id = self.detailOuter().getFocus().id; │ │550 + } │ │551 + │ │552 + fn focusList(self: *View, root_focus: *Focus) !void { │ │553 + const lb = self.listBox(); │ │554 + const id = lb.getFocus().child_id orelse (if (lb.children.count() > 0) lb.children.keys()[0] else return);│ │555 + try root_focus.setFocus(id); │ │556 + } │ │557 + │ │558 fn moveSelection(self: *View, root_focus: *Focus, delta: isize) !void { │ │244 - const box = self.innerBox(); │ │245 - const keys = box.children.keys(); │ │246 - const cur_id = box.getFocus().child_id orelse return; │ │247 - const cur = box.children.getIndex(cur_id) orelse return; │ │248 - if (delta < 0 and cur == 0) return; │ │249 - if (delta > 0 and cur + 1 >= keys.len) return; │ │250 - const next = if (delta < 0) cur - 1 else cur + 1; │ │559 + const lb = self.listBox(); │ │560 + const keys = lb.children.keys(); │ │561 + if (keys.len == 0) return; │ │562 + const cur_id = lb.getFocus().child_id orelse return; │ │563 + const cur: isize = @intCast(lb.children.getIndex(cur_id) orelse return); │ │564 + const last: isize = @intCast(keys.len - 1); │ │565 + const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); │ │566 + if (next == @as(usize, @intCast(cur))) return; │ │567 try root_focus.setFocus(keys[next]); │ │252 - if (box.children.values()[next].rect) |rect| self.scrollWidget().scrollToRect(rect); │ │568 + if (lb.children.values()[next].rect) |rect| self.listScroll().scrollToRect(rect); │ │569 } │ │570 │ │571 pub fn clearGrid(self: *View) void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │580 return self.box.getFocus(); │ │581 } │ │582 │ │583 + // for the parent's "scroll up at the top jumps to the header" check. inside │ │584 + // the detail pane up-arrow stays in the pane (scrolling); when the list holds │ │585 + // focus, report its selection directly so up-arrow at the top row ascends. │ │586 pub fn getSelectedIndex(self: *View) ?usize { │ │268 - const box = self.innerBox(); │ │269 - const child_id = box.focus.child_id orelse return null; │ │270 - return box.children.getIndex(child_id); │ │587 + if (self.detailActive()) return 1; │ │588 + const lb = self.listBox(); │ │589 + const cid = lb.getFocus().child_id orelse return null; │ │590 + return lb.children.getIndex(cid); │ │591 } │ │592 }; │ │593 + │ │594 +// the index of the "README"/"README.md" file entry (case-insensitive), or null │ │595 +// if none. used to pick the default selection at the repo root. │ │596 +fn readmeIndex(entries: []const Entry) ?usize { │ │597 + for (entries, 0..) |entry, i| { │ │598 + if (entry.is_dir) continue; │ │599 + if (std.ascii.eqlIgnoreCase(entry.name, "README") or std.ascii.eqlIgnoreCase(entry.name, "README.md"))│ │600 + return i; │ │601 + } │ │602 + return null; │ │603 +} │ │604 │ │605 // the "a:" link for the files route at `dir`, pinned to the listing's ref. │ │606 fn dirLink(page_arena: *std.heap.ArenaAllocator, data: *const Self, dir: []const u8) ![]const u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯