╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Home/Repos.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │32 │ │33 var repos: std.ArrayList(evt.Repo) = .empty; │ │34 var owner_names: std.ArrayList([]const u8) = .empty; │ │35 + │ │36 + const empty: Self = .{ .repos = &.{}, .owner_names = &.{}, .after = after, .next_after = null }; │ │37 │ │28 - const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->repo")) orelse return error.NotFound;│ │38 + // the ordered repo-list (oldest first); absent until the first repo exists. │ │39 + const repo_list_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "repo-list")) orelse return empty; │ │40 + const repo_list = try DB.ArrayList(.read_only).init(repo_list_cursor); │ │41 + const count = try repo_list.count(); │ │42 + │ │43 + const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->repo")) orelse return empty; │ │44 const event_id_to_repo = try DB.HashMap(.read_only).init(event_id_to_repo_cursor); │ │45 │ │31 - var repos_iter = try event_id_to_repo.iterator(); │ │32 - while (try repos_iter.next()) |kv_cursor| { │ │33 - const kv = try kv_cursor.readKeyValuePair(); │ │34 - const repo_map = try DB.HashMap(.read_only).init(kv.value_cursor); │ │46 + // read the window [after, after+page_size) by index — a direct O(log n) │ │47 + // seek per entry, never scanning the whole table. ids whose repo has been │ │48 + // deleted (tombstones) are skipped. │ │49 + const end = @min(after + page_size, count); │ │50 + var i = after; │ │51 + while (i < end) : (i += 1) { │ │52 + const id_cursor = try repo_list.getCursor(@intCast(i)) orelse continue; │ │53 + var event_id: [evt.event_id_size]u8 = undefined; │ │54 + _ = try id_cursor.readBytes(&event_id); │ │55 + const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(hash_kind, &event_id)) orelse continue; │ │56 + const repo_map = try DB.HashMap(.read_only).init(repo_cursor); │ │57 const repo_event = try evt.read(evt.Repo, DB, hash_kind, arena, repo_map); │ │58 try repos.append(arena.allocator(), repo_event); │ │59 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │64 return .{ │ │65 .repos = repos.items, │ │66 .owner_names = owner_names.items, │ │67 + .after = after, │ │68 + .next_after = if (end < count) end else null,│ │69 }; │ │70 } │ │71 │ ╰─────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │89 defer arena.deinit(); │ │90 const aa = arena.allocator(); │ │91 │ │68 - const lines = try aa.alloc([]const u8, data.repos.len); │ │69 - const links = try aa.alloc([]const u8, data.repos.len); │ │92 + // one row per repo, plus a trailing "load more" row when more remain. │ │93 + const extra: usize = if (data.next_after != null) 1 else 0; │ │94 + const lines = try aa.alloc([]const u8, data.repos.len + extra); │ │95 + const links = try aa.alloc([]const u8, data.repos.len + extra); │ │96 for (data.repos, 0..) |repo, i| { │ │97 lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo.name, repo.description });│ │98 // clicking a repo opens its page; the "a:" prefix makes the web │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │101 const owner = data.owner_names[i]; │ │102 links[i] = if (owner.len > 0) try std.fmt.allocPrint(aa, "a:/repo/{s}/{s}", .{ owner, repo.name }) else "";│ │103 } │ │104 + if (data.next_after) |next_after| { │ │105 + // the load-more row navigates to the next window (full reload on web, │ │106 + // Nav rebuild on the TUI), exactly like the commits "load more". │ │107 + lines[data.repos.len] = "load more"; │ │108 + links[data.repos.len] = try std.fmt.allocPrint(aa, "a:/repos?after={d}", .{next_after}); │ │109 + } │ │110 try self.list.setItems(allocator, lines, links); │ │111 │ │112 return self; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Home/Users.zig│ ╰─────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │ 9 const inp = xitui.input; │ │10 const Grid = xitui.grid.Grid; │ │11 const Focus = xitui.focus.Focus; │ │12 + │ │13 +// how many users a page shows before the "load more" row appears. │ │14 +pub const page_size = 20; │ │15 │ │16 users: []const evt.User.Safe, │ │17 +// the window start this page was built with, mirrored into the url. │ │18 +after: usize, │ │19 +// the `after` for the "load more" link, or null when this is the last page.│ │20 +next_after: ?usize, │ │21 │ │22 const Self = @This(); │ │23 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │24 pub fn init( │ │25 arena: *std.heap.ArenaAllocator, │ │26 haxy_moment: evt.AdminDB.HashMap(.read_only), │ │27 + after: usize, │ │28 ) !Self { │ │29 const DB = evt.AdminDB; │ │30 + const hash_kind = evt.admin_repo_opts.hash; │ │31 │ │32 var users: std.ArrayList(evt.User.Safe) = .empty;│ │33 │ ╰─────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │25 - const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(evt.admin_repo_opts.hash, "event-id->user")) orelse return error.NotFound;│ │34 + // the ordered user-list (oldest first); absent until the first user exists. │ │35 + const user_list_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "user-list")) orelse │ │36 + return .{ .users = &.{}, .after = after, .next_after = null }; │ │37 + const user_list = try DB.ArrayList(.read_only).init(user_list_cursor); │ │38 + const count = try user_list.count(); │ │39 + │ │40 + const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->user")) orelse │ │41 + return .{ .users = &.{}, .after = after, .next_after = null }; │ │42 const event_id_to_user = try DB.HashMap(.read_only).init(event_id_to_user_cursor); │ │43 │ │28 - var users_iter = try event_id_to_user.iterator(); │ │29 - while (try users_iter.next()) |kv_cursor| { │ │30 - const kv = try kv_cursor.readKeyValuePair(); │ │31 - const user_map = try DB.HashMap(.read_only).init(kv.value_cursor); │ │32 - const user_event = try evt.read(evt.User, DB, evt.admin_repo_opts.hash, arena, user_map); │ │44 + // read the window [after, after+page_size) by index — a direct O(log n) │ │45 + // seek per entry, never scanning the whole table. ids whose user has been │ │46 + // deleted (tombstones) are skipped. │ │47 + const end = @min(after + page_size, count); │ │48 + var i = after; │ │49 + while (i < end) : (i += 1) { │ │50 + const id_cursor = try user_list.getCursor(@intCast(i)) orelse continue; │ │51 + var event_id: [evt.event_id_size]u8 = undefined; │ │52 + _ = try id_cursor.readBytes(&event_id); │ │53 + const user_cursor = try event_id_to_user.getCursor(hash.hashInt(hash_kind, &event_id)) orelse continue; │ │54 + const user_map = try DB.HashMap(.read_only).init(user_cursor); │ │55 + const user_event = try evt.read(evt.User, DB, hash_kind, arena, user_map); │ │56 try users.append(arena.allocator(), evt.User.Safe.init(user_event)); │ │57 } │ │58 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────╮ │59 return .{ │ │60 .users = users.items, │ │61 + .after = after, │ │62 + .next_after = if (end < count) end else null,│ │63 }; │ │64 } │ │65 │ ╰─────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │83 defer arena.deinit(); │ │84 const aa = arena.allocator(); │ │85 │ │61 - const lines = try aa.alloc([]const u8, data.users.len); │ │62 - const links = try aa.alloc([]const u8, data.users.len); │ │86 + // one row per user, plus a trailing "load more" row when more remain. │ │87 + const extra: usize = if (data.next_after != null) 1 else 0; │ │88 + const lines = try aa.alloc([]const u8, data.users.len + extra); │ │89 + const links = try aa.alloc([]const u8, data.users.len + extra); │ │90 for (data.users, 0..) |user, i| { │ │91 lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ user.name, user.display_name });│ │92 // clicking a user opens their page; the "a:" prefix makes the web │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 93 // renderer emit an <a href="/user/foo"> anchor. │ │ 94 links[i] = try std.fmt.allocPrint(aa, "a:/user/{s}", .{user.name}); │ │ 95 + } │ │ 96 + if (data.next_after) |next_after| { │ │ 97 + // the load-more row navigates to the next window (full reload on web, │ │ 98 + // Nav rebuild on the TUI), exactly like the commits "load more". │ │ 99 + lines[data.users.len] = "load more"; │ │100 + links[data.users.len] = try std.fmt.allocPrint(aa, "a:/users?after={d}", .{next_after});│ │101 } │ │102 try self.list.setItems(allocator, lines, links); │ │103 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯