╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │281 next_id = try aa.dupe(u8, &id_hex); │ │282 break; │ │283 } │ │175 - const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, order_key[@sizeOf(u64)..])) orelse continue;│ │284 + const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, order_key[@sizeOf(u64)..])) orelse continue; │ │285 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); │ │286 try issues.append(aa, .{ │ │287 .id = try aa.dupe(u8, &id_hex), │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │179 - .issue = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map), │ │288 + .issue = try evt.read(evt.Issue, DB, hash_kind, arena, issue_map), │ │289 }); │ │181 - } │ │182 - │ │183 - // every tag in the repo, in the tag map's sorted order. │ │184 - var tags: std.ArrayList([]const u8) = .empty; │ │185 - if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag->issue-id-set"))) |tag_to_issues_cursor| {│ │186 - const tag_to_issues = try DB.SortedMap(.read_only).init(tag_to_issues_cursor); │ │187 - var tag_iter = try tag_to_issues.iterator(); │ │188 - while (try tag_iter.next()) |kv_pair_cursor| { │ │189 - if (tags.items.len == max_tags) break; │ │190 - var kv_cursor = kv_pair_cursor; │ │191 - const kv_pair = try kv_cursor.readKeyValuePair(); │ │192 - try tags.append(aa, try kv_pair.key_cursor.readBytesAlloc(aa, null)); │ │193 - } │ │290 } │ │291 │ │292 return .{ │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────╮ │197 - .identity = empty.identity, │ │198 - .tag = empty.tag, │ │199 - .selected_id = empty.selected_id,│ │293 .issues = issues.items, │ │294 .prev_id = prev_id, │ │295 .next_id = next_id, │ ╰──────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │203 - .count = @intCast(try issue_id_set.count()),│ │204 - .tags = tags.items, │ │296 + .count = @intCast(try set.count()), │ │297 }; │ │298 } │ │299 │ ╰─────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │300 pub const View = struct { │ │209 - // a vertical box: the header tabs on top, then a stack holding the │ │210 - // results view — a horizontal split with the issue list on the left and a │ │211 - // detail pane on the right showing the selected issue's description. │ │301 + // a vertical box: the header tabs on top, then a stack holding a │ │302 + // master-detail split (issue list + description pane) per status list, │ │303 + // plus the tags view. │ │304 box: wgt.Box(ui.Widget), // vert: [header_index] = tabs, [stack_index] = stack│ │305 data: *const Self, │ │306 session: *ui.Session, │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │215 - // the issue whose description the pane currently shows (index into data.issues).│ │216 - detailed_index: ?usize, │ │217 - // the pane's description text box's focus id (null until a detail is shown). │ │218 - description_id: ?usize, │ │307 + // per-split state, indexed like the stack's split children: the issue the │ │308 + // pane shows, and its description text box's focus id. │ │309 + detailed_index: [split_count]?usize, │ │310 + description_id: [split_count]?usize, │ │311 │ │312 const header_index: usize = 0; │ │313 const stack_index: usize = 1; │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │314 // indices within the stack, 1:1 with the header tabs. │ │223 - const results_view_index: usize = 0; │ │224 - const tags_view_index: usize = 1; │ │225 - // indices within the results box (the horizontal split inside the stack).│ │315 + const open_view_index: usize = 0; │ │316 + const closed_view_index: usize = 1; │ │317 + const tags_view_index: usize = 2; │ │318 + const split_count: usize = 2; │ │319 + // indices within a split (the horizontal box inside the stack). │ │320 const list_index: usize = 0; │ │321 const detail_index: usize = 1; │ │322 const list_max_width: usize = 40; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │323 const detail_min_width: usize = 40; │ │324 + │ │325 + fn viewIndex(view: ui.RoutablePage.IssuesView) usize { │ │326 + return switch (view) { │ │327 + .open => open_view_index, │ │328 + .closed => closed_view_index, │ │329 + .tags => tags_view_index, │ │330 + }; │ │331 + } │ │332 + │ │333 + fn splitStatus(index: usize) evt.Issue.Status { │ │334 + return if (index == open_view_index) .open else .closed; │ │335 + } │ │336 │ │337 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │338 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert });│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │340 │ │341 // the tabs at the top. │ │342 { │ │237 - var hdr = try Header.init(allocator, session, data.identity, data.count, data.tag); │ │343 + var hdr = try Header.init(allocator, session, data); │ │344 errdefer hdr.deinit(allocator); │ │345 try outer.children.put(allocator, hdr.getFocus().id, .{ .widget = .{ .repo_issues_header = hdr }, .rect = null, .min_size = null });│ │346 + } │ │347 + │ │348 + // the stack enters `outer` before its children enter it, so an error │ │349 + // frees each child exactly once. │ │350 + { │ │351 + var stack = try wgt.Stack(ui.Widget).init(allocator); │ │352 + errdefer stack.deinit(allocator); │ │353 + try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); │ │354 + } │ │355 + const stack = &outer.children.values()[stack_index].widget.stack; │ │356 + │ │357 + // a master-detail split per status list. │ │358 + for ([_]evt.Issue.Status{ .open, .closed }) |status| { │ │359 + var split = try initSplit(allocator, session, data, status); │ │360 + errdefer split.deinit(allocator); │ │361 + try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); │ │362 + } │ │363 + │ │364 + // the tags view │ │365 + { │ │366 + var tf = try ui.TagFlow.init(allocator); │ │367 + errdefer tf.deinit(allocator); │ │368 + var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │369 + defer items.deinit(allocator); │ │370 + // when filtered, the first item clears the filter │ │371 + if (data.tag.len != 0) │ │372 + try items.append(allocator, .{ .text = "╳", .link = try issuesLink(session.page_arena, data.identity, "", "") }); │ │373 + for (data.tags) |tag| │ │374 + try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, .open, tag) }); │ │375 + try tf.setItems(allocator, items.items); │ │376 + try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); │ │377 } │ │378 + │ │379 + // the stack starts on the page's view. │ │380 + stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; │ │381 + │ │382 + // focus entering the view lands on the tabs first. │ │383 + outer.getFocus().child_id = outer.children.keys()[header_index]; │ │384 │ │385 + return .{ │ │386 + .box = outer, │ │387 + .data = data, │ │388 + .session = session, │ │389 + .detailed_index = .{ null, null }, │ │390 + .description_id = .{ null, null }, │ │391 + }; │ │392 + } │ │393 + │ │394 + // the master-detail split showing `status`'s window. │ │395 + fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status: evt.Issue.Status) !wgt.Box(ui.Widget) { │ │396 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │397 errdefer box.deinit(allocator); │ │398 + │ │399 + const win = data.window(status); │ │400 │ │401 // the issue list (one focusable row per title), plus a "next" link that │ │402 // reloads the page rooted at the following issue. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │404 var list_scroll = blk: { │ │405 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │406 errdefer list_box.deinit(allocator); │ │251 - if (data.prev_id) |prev| │ │407 + if (win.prev_id) |prev| │ │408 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); │ │253 - for (data.issues) |entry| │ │409 + for (win.issues) |entry| │ │410 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id));│ │255 - if (data.next_id) |next| │ │411 + if (win.next_id) |next| │ │412 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next)); │ │413 // select the window's first issue (past a leading "previous" │ │414 // row) so its description shows on load. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯