╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────╮ │src/ui/User.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │33 repos: []const evt.Repo.Record, │ │34 repos_start: usize, // the repos window this page was built with, mirrored into the url │ │35 repos_next_start: ?usize, // the `start` for the "next" row, or null on the last window │ │36 +repos_search: ?[]const u8, // the name prefix the repos are narrowed to (decoded; null = no search)│ │37 forks: []const ForkItem, │ │38 forks_start: usize, │ │39 forks_next_start: ?usize, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────╮ │49 haxy_moment: evt.AdminDB.HashMap(.read_only), │ │50 name: ui.RoutablePage.Array(evt.User.name_max_len),│ │51 repos_start: usize, │ │52 + // the url-encoded repos search ("" = none) │ │53 + repos_search: []const u8, │ │54 forks_start: usize, │ │55 ) !Self { │ │56 // admin and user repos share one db type │ ╰───────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │70 defer if (user_repo_maybe) |*user_repo| user_repo.deinit(io, gpa); │ │71 const user_moment = if (user_repo_maybe) |*user_repo| try evt.userMoment(user_repo) else null; │ │72 │ │73 + const search: ?[]const u8 = if (repos_search.len == 0) null else std.Uri.percentDecodeInPlace(try arena.allocator().dupe(u8, repos_search));│ │74 + const prefix = search orelse ""; │ │75 + │ │76 var repos: std.ArrayList(evt.Repo.Record) = .empty; │ │77 var repos_next_start: ?usize = null; │ │78 if (user_moment) |moment| { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │73 - if (try moment.getCursor(hash.hashInt(hash_kind, evt.Repo.active_id_set_key))) |user_repos_cursor| { │ │74 - const user_repos = try DB.SortedSet(.read_only).init(user_repos_cursor); │ │75 - const count = try user_repos.count(); │ │79 + if (try moment.getCursor(hash.hashInt(hash_kind, evt.Repo.name_index_key))) |index_cursor| { │ │80 + const index = try DB.SortedMap(.read_only).init(index_cursor); │ │81 │ │77 - // read the window [start, start+page_size) with one seek to the start │ │78 - // rank, then a sequential walk. │ │79 - const end = @min(repos_start +| page_size, count); │ │80 - var repos_iter = try user_repos.iteratorFromIndex(repos_start); │ │81 - var i = repos_start; │ │82 - while (i < end) : (i += 1) { │ │83 - const kv_cursor = (try repos_iter.next()) orelse break; │ │84 - const event_id = try evt.readOrderKeyId(DB, kv_cursor); │ │82 + // the names with the prefix are contiguous from its rank, so the │ │83 + // window is one seek then a walk that stops at the first name without it │ │84 + var taken: usize = 0; │ │85 + var repos_iter = try index.iteratorFromIndex(try index.rank(prefix) +| repos_start); │ │86 + while (try repos_iter.next()) |cursor| { │ │87 + const pair = try cursor.readKeyValuePair(); │ │88 + const repo_name = try pair.key_cursor.readBytesAlloc(arena.allocator(), null); │ │89 + if (!std.mem.startsWith(u8, repo_name, prefix)) break; │ │90 + if (taken == page_size) { │ │91 + repos_next_start = repos_start + page_size; │ │92 + break; │ │93 + } │ │94 + taken += 1; │ │95 + var event_id: [evt.event_id_size]u8 = undefined; │ │96 + _ = try pair.value_cursor.readBytes(&event_id); │ │97 const repo_event = (try evt.Repo.readById(DB, hash_kind, moment, arena, &event_id)) orelse continue;│ │98 // unreadable repos leave their window short rather than shifting the others │ │99 if (evt.Repo.roleOf(repo_event, session.userId()) == .none) continue; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │100 try repos.append(arena.allocator(), repo_event);│ │101 } │ │ 90 - repos_next_start = if (end < count) end else null; │ │102 } │ │103 } │ │104 │ ╰─────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────╮ │149 .repos = repos.items, │ │150 .repos_start = repos_start, │ │151 .repos_next_start = repos_next_start,│ │152 + .repos_search = search, │ │153 .forks = forks.items, │ │154 .forks_start = forks_start, │ │155 .forks_next_start = forks_next_start,│ ╰──────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │183 │ │184 // repos list — the default tab │ │185 { │ │174 - var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); │ │175 - errdefer list.deinit(allocator); │ │176 - │ │177 - var arena = std.heap.ArenaAllocator.init(allocator); │ │178 - defer arena.deinit(); │ │179 - const aa = arena.allocator(); │ │180 - │ │181 - // a leading "previous" row off the first window, one row per repo, │ │182 - // then a trailing "next" row when more remain. each window row │ │183 - // navigates to the adjacent window of this user's repos. │ │184 - var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; │ │185 - if (data.repos_start > 0) │ │186 - try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:/user/{s}/repos/start:{d}", .{ data.user.name, data.repos_start -| page_size }) });│ │187 - for (data.repos) |repo| │ │188 - // clicking a repo opens its page; the "a:" prefix makes the web │ │189 - // renderer emit an <a href="/repo/alice/foo"> anchor. │ │190 - try items.append(aa, .{ │ │191 - .text = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo.event.name, repo.event.description }), │ │192 - .link = try std.fmt.allocPrint(aa, "a:/repo/{s}/{s}", .{ data.user.name, repo.event.name }), │ │193 - }); │ │194 - if (data.repos_next_start) |next_start| │ │195 - try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:/user/{s}/repos/start:{d}", .{ data.user.name, next_start }) }); │ │196 - try list.setItems(allocator, items.items); │ │197 - │ │198 - try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); │ │186 + var repos_view = try ReposView.init(allocator, data, session); │ │187 + errdefer repos_view.deinit(allocator); │ │188 + try stack.children.put(allocator, repos_view.getFocus().id, .{ .user_repos = repos_view }); │ │189 } │ │190 │ │191 // forks list │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │236 } │ │237 │ │238 var self = View{ .box = box }; │ │249 - self.getFocus().child_id = box.children.keys()[header_index]; │ │239 + // search results open in the tab's own search box │ │240 + self.getFocus().child_id = box.children.keys()[if (data.repos_search != null) stack_index else header_index];│ │241 return self; │ │242 } │ │243 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │257 } │ │258 │ │259 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void {│ │260 + const stack = &self.box.children.values()[stack_index].widget.stack; │ │261 if (self.getFocus().child_id) |child_id| { │ │262 if (self.box.children.getIndex(child_id)) |current_index| { │ │263 const child = &self.box.children.values()[current_index].widget; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │286 .down => { │ │287 switch (child.*) { │ │288 .user_header => { │ │289 + if (stack.getSelected()) |selected_widget| switch (selected_widget.*) {│ │290 + .user_repos => |*v| return v.focusHeader(root_focus), │ │291 + // an empty list has nothing to focus │ │292 + .flow_box_scroll => |*v| if (v.isEmpty()) return, │ │293 + else => {}, │ │294 + }; │ │295 index = stack_index; │ │296 }, │ │297 .stack => { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯