╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │259 - if (data.issues.len > 0) │ │260 - list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] │ │415 + if (win.issues.len > 0) │ │416 + list_box.getFocus().child_id = list_box.children.keys()[if (win.prev_id != null) 1 else 0] │ │417 else if (list_box.children.count() > 0) │ │418 list_box.getFocus().child_id = list_box.children.keys()[0]; │ │419 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal });│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │447 } │ │448 │ │449 box.getFocus().child_id = box.children.keys()[list_index]; │ │294 - │ │295 - // the split sits alone in a stack so future tabs can swap other views │ │296 - // in. the stack enters `outer` before the split enters the stack, so an │ │297 - // error frees the split exactly once. │ │298 - { │ │299 - var stack = try wgt.Stack(ui.Widget).init(allocator); │ │300 - errdefer stack.deinit(allocator); │ │301 - try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null });│ │302 - } │ │303 - const stack = &outer.children.values()[stack_index].widget.stack; │ │304 - stack.getFocus().child_id = box.getFocus().id; │ │305 - try stack.children.put(allocator, box.getFocus().id, .{ .box = box }); │ │306 - │ │307 - // the tags view │ │308 - { │ │309 - var tf = try ui.TagFlow.init(allocator); │ │310 - errdefer tf.deinit(allocator); │ │311 - var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │312 - defer items.deinit(allocator); │ │313 - // when filtered, the first item clears the filter │ │314 - if (data.tag.len != 0) │ │315 - try items.append(allocator, .{ .text = "╳", .link = try issuesLink(session.page_arena, data.identity, "", "") }); │ │316 - for (data.tags) |tag| │ │317 - try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, tag) }); │ │318 - try tf.setItems(allocator, items.items); │ │319 - try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); │ │320 - } │ │321 - │ │322 - // focus entering the view lands on the tabs first. │ │323 - outer.getFocus().child_id = outer.children.keys()[header_index]; │ │324 - │ │325 - return .{ │ │326 - .box = outer, │ │327 - .data = data, │ │328 - .session = session, │ │329 - .detailed_index = null, │ │330 - .description_id = null, │ │331 - }; │ │450 + return box; │ │451 } │ │452 │ │453 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │470 return &self.box.children.values()[stack_index].widget.stack; │ │471 } │ │472 │ │354 - // the master-detail split inside the stack. │ │355 - fn resultsBox(self: *View) *wgt.Box(ui.Widget) { │ │356 - return &self.viewStack().children.values()[results_view_index].box;│ │473 + // `index`'s master-detail split inside the stack. │ │474 + fn resultsBox(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │475 + return &self.viewStack().children.values()[index].box; │ │476 } │ │477 │ │478 // the tags view's flow inside the stack. │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │480 return &self.viewStack().children.values()[tags_view_index].tag_flow; │ │481 } │ │482 │ │364 - fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │365 - return &self.resultsBox().children.values()[list_index].widget.scroll; │ │483 + fn listScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │484 + return &self.resultsBox(index).children.values()[list_index].widget.scroll;│ │485 } │ │486 │ │368 - fn listBox(self: *View) *wgt.Box(ui.Widget) { │ │369 - return &self.listScroll().child.box; │ │487 + fn listBox(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │488 + return &self.listScroll(index).child.box; │ │489 } │ │490 │ │372 - fn detailOuter(self: *View) *wgt.Box(ui.Widget) { │ │373 - return &self.resultsBox().children.values()[detail_index].widget.box; │ │491 + fn detailOuter(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │492 + return &self.resultsBox(index).children.values()[detail_index].widget.box; │ │493 } │ │494 │ │376 - fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │377 - return &self.detailOuter().children.values()[0].widget.scroll; │ │495 + fn detailScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │496 + return &self.detailOuter(index).children.values()[0].widget.scroll; │ │497 } │ │498 │ │380 - fn detailInner(self: *View) *wgt.Box(ui.Widget) { │ │381 - return &self.detailScroll().child.box; │ │499 + fn detailInner(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │500 + return &self.detailScroll(index).child.box; │ │501 + } │ │502 + │ │503 + fn window(self: *View, index: usize) *const Window { │ │504 + return self.data.window(splitStatus(index)); │ │505 } │ │506 │ │384 - fn detailActive(self: *View) bool { │ │385 - const rb = self.resultsBox(); │ │507 + // the stack's selected master-detail split (null when the tags view shows). │ │508 + fn selectedSplitIndex(self: *View) ?usize { │ │509 + const stack = self.viewStack(); │ │510 + const cid = stack.getFocus().child_id orelse return null; │ │511 + const idx = stack.children.getIndex(cid) orelse return null; │ │512 + return if (idx == tags_view_index) null else idx; │ │513 + } │ │514 + │ │515 + fn detailActive(self: *View, index: usize) bool { │ │516 + const rb = self.resultsBox(index); │ │517 const cid = rb.getFocus().child_id orelse return false; │ │518 return rb.children.getIndex(cid) == detail_index; │ │519 } │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │525 │ │526 fn tagsViewActive(self: *View) bool { │ │527 if (self.headerActive()) return false; │ │397 - const stack = self.viewStack(); │ │398 - const cid = stack.getFocus().child_id orelse return false; │ │399 - return stack.children.getIndex(cid) == tags_view_index; │ │528 + return self.selectedSplitIndex() == null; │ │529 } │ │530 │ │531 // the selected issue's index, or null when a window-navigation row is│ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │532 // selected (a leading "previous" row shifts the issue rows down by one).│ │404 - fn selectedIssueIndex(self: *View) ?usize { │ │405 - const lb = self.listBox(); │ │533 + fn selectedIssueIndex(self: *View, index: usize) ?usize { │ │534 + const lb = self.listBox(index); │ │535 const cid = lb.getFocus().child_id orelse return null; │ │536 const idx = lb.children.getIndex(cid) orelse return null; │ │408 - const lead: usize = if (self.data.prev_id != null) 1 else 0; │ │409 - if (idx < lead or idx - lead >= self.data.issues.len) return null; │ │537 + const win = self.window(index); │ │538 + const lead: usize = if (win.prev_id != null) 1 else 0; │ │539 + if (idx < lead or idx - lead >= win.issues.len) return null; │ │540 return idx - lead; │ │541 } │ │542 │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │543 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │544 self.clearGrid(); │ │545 │ │416 - // each header tab maps 1:1 to a stack child by position │ │546 + // each header tab maps 1:1 to a stack child by position. mirror the │ │547 + // selection into the url (the list routes keep the tag filter). │ │548 if (self.header().getSelectedIndex()) |index| { │ │549 const stack = self.viewStack(); │ │550 stack.getFocus().child_id = stack.children.keys()[index]; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │551 self.session.data.current_page = (if (index == tags_view_index) │ │421 - ui.RoutablePage.repoIssuesTagsRoute(self.data.identity) │ │552 + ui.RoutablePage.repoIssuesTagsRoute(self.data.identity, self.data.tag) │ │553 else │ │423 - ui.RoutablePage.repoIssuesRoute(self.data.identity, self.data.tag, "")) orelse self.session.data.current_page; │ │554 + ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(index), self.data.tag, "")) orelse self.session.data.current_page; │ │555 } │ │556 │ │426 - // swap the detail pane to the selected issue when it changes. │ │427 - try self.refreshDetail(allocator); │ │557 + if (self.selectedSplitIndex()) |i| { │ │558 + // swap the detail pane to the selected issue when it changes. │ │559 + try self.refreshDetail(allocator, i); │ │560 │ │429 - // mirror the focused issue into the url so it updates as the selection │ │430 - // moves, but only while focus is inside this view (the list or detail). │ │431 - // when focus sits on the header tab, use the page's base /issues route. │ │432 - // an issue's url is the same whether or not the list is filtered, so │ │433 - // the mirror drops the tag. │ │434 - if (root_focus.grandchild_id) |g| { │ │435 - if (self.resultsBox().getFocus().children.contains(g)) { │ │436 - if (self.selectedIssueIndex()) |sel| { │ │437 - if (ui.RoutablePage.repoIssuesRoute(self.data.identity, "", self.data.issues[sel].id)) |route| │ │438 - self.session.data.current_page = route; │ │561 + // mirror the focused issue into the url, but only while focus is │ │562 + // inside the split. an issue's url is the same whether or not the │ │563 + // list is filtered, so the mirror drops the tag. │ │564 + if (root_focus.grandchild_id) |g| { │ │565 + if (self.resultsBox(i).getFocus().children.contains(g)) { │ │566 + if (self.selectedIssueIndex(i)) |sel| { │ │567 + if (ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(i), "", self.window(i).issues[sel].id)) |route| │ │568 + self.session.data.current_page = route; │ │569 + } │ │570 } │ │571 } │ │441 - } │ │572 │ │443 - // the selected list row shows a border (the focused TextBox upgrades it │ │444 - // to a double border itself); the rest stay borderless. │ │445 - const lb = self.listBox(); │ │446 - for (lb.children.keys(), lb.children.values()) |id, *child| { │ │447 - switch (child.widget) { │ │448 - .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, │ │449 - else => {}, │ │573 + // the selected list row shows a border (the focused TextBox │ │574 + // upgrades it to a double border itself); the rest stay borderless. │ │575 + const lb = self.listBox(i); │ │576 + for (lb.children.keys(), lb.children.values()) |id, *child| { │ │577 + switch (child.widget) { │ │578 + .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, │ │579 + else => {}, │ │580 + } │ │581 } │ │451 - } │ │582 │ │453 - // the pane's selected child shows the selection border: the description │ │454 - // directly, the tags via the flow's selected item. │ │455 - const inner = self.detailInner(); │ │456 - for (inner.children.keys(), inner.children.values()) |id, *child| { │ │457 - switch (child.widget) { │ │458 - .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, │ │459 - else => {}, │ │583 + // the pane's selected child shows the selection border: the │ │584 + // description directly, the tags via the flow's selected item. │ │585 + const inner = self.detailInner(i); │ │586 + for (inner.children.keys(), inner.children.values()) |id, *child| { │ │587 + switch (child.widget) { │ │588 + .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, │ │589 + else => {}, │ │590 + } │ │591 } │ │461 - } │ │462 - if (self.tagFlow()) |tf| tf.selected = self.tagsFocused(); │ │463 - self.tagsView().selected = self.tagsViewActive(); │ │592 + if (self.tagFlow(i)) |tf| tf.selected = self.tagsFocused(i); │ │593 │ │465 - // cap the list at list_max_width only while the detail pane fits beside │ │466 - // it. the box drops the detail when the width can't hold both minimums, │ │467 - // so when it's that narrow we lift the cap and let the list fill the │ │468 - // whole width. │ │469 - const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; │ │470 - self.resultsBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; │ │594 + // cap the list at list_max_width only while the detail pane fits │ │595 + // beside it. the box drops the detail when the width can't hold │ │596 + // both minimums, so when it's that narrow we lift the cap and let │ │597 + // the list fill the whole width. │ │598 + const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; │ │599 + self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; │ │600 │ │472 - // stretch the detail pane across the rest of the width so it fills the │ │473 - // area rather than shrinking to its content; its scroll fills the pane. │ │474 - if (constraint.max_size.width) |w| { │ │475 - self.resultsBox().children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; │ │476 - } else { │ │477 - self.resultsBox().children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; │ │601 + // stretch the detail pane across the rest of the width so it fills │ │602 + // the area rather than shrinking to its content; its scroll fills │ │603 + // the pane. │ │604 + if (constraint.max_size.width) |w| { │ │605 + self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null };│ │606 + } else { │ │607 + self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; │ │608 + } │ │609 } │ │610 + │ │611 + self.tagsView().selected = self.tagsViewActive(); │ │612 │ │613 try self.box.build(allocator, constraint, root_focus); │ │614 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │615 │ │483 - fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { │ │484 - const sel = self.selectedIssueIndex() orelse return; │ │485 - if (self.detailed_index) |d| if (d == sel) return; │ │486 - try self.populateDetail(allocator, sel); │ │487 - self.detailed_index = sel; │ │616 + fn refreshDetail(self: *View, allocator: std.mem.Allocator, index: usize) !void { │ │617 + const sel = self.selectedIssueIndex(index) orelse return; │ │618 + if (self.detailed_index[index]) |d| if (d == sel) return; │ │619 + try self.populateDetail(allocator, index, sel); │ │620 + self.detailed_index[index] = sel; │ │621 } │ │622 │ │490 - fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { │ │491 - const entry = self.data.issues[sel]; │ │492 - const inner = self.detailInner(); │ │623 + fn populateDetail(self: *View, allocator: std.mem.Allocator, index: usize, sel: usize) !void {│ │624 + const entry = self.window(index).issues[sel]; │ │625 + const inner = self.detailInner(index); │ │626 │ │627 for (inner.children.values()) |*child| child.widget.deinit(allocator); │ │628 inner.children.clearAndFree(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │629 inner.getFocus().child_id = null; │ │630 │ │498 - // the issue's tags, each linking to the list filtered to that tag.│ │631 + // the issue's tags, each linking to this status's list filtered to│ │632 + // that tag. │ │633 { │ │634 var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │635 defer items.deinit(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯