╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ User.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
56 const user_id = (try evt.User.readIdByName(DB, hash_kind, haxy_moment, name.slice())) orelse return error.NotFound; 57 const user = (try evt.User.readById(DB, hash_kind, haxy_moment, arena, &user_id)) orelse return error.NotFound; 58 59 const io = session.io orelse return error.NoMoment; 60 const users_dir = session.users_dir orelse return error.NoMoment; 61 const gpa = arena.child_allocator; 62 63 // a user with no user repo or no events has empty lists 64 var user_repo_maybe = try evt.openUserRepo(io, gpa, users_dir, &user_id); 65 defer if (user_repo_maybe) |*user_repo| user_repo.deinit(io, gpa); 66 const user_moment = if (user_repo_maybe) |*user_repo| try evt.userMoment(user_repo) else null; 67 68 const search: ?[]const u8 = if (repos_search.len == 0) null else std.Uri.percentDecodeInPlace(try arena.allocator().dupe(u8, repos_search)); 69 70 var repos: std.ArrayList(evt.Repo.Record) = .empty; 71 var repos_next_start: ?usize = null; 72 if (user_moment) |moment| { 73 if (search) |prefix| { 74 if (try moment.getCursor(hash.hashInt(hash_kind, evt.Repo.name_index_key))) |index_cursor| { 75 const index = try DB.SortedMap(.read_only).init(index_cursor); 76 77 // the names with the prefix are contiguous from its rank, so the 78 // window is one seek then a walk that stops at the first name without it 79 var taken: usize = 0; 80 var repos_iter = try index.iteratorFromIndex(try index.rank(prefix) +| repos_start); 81 while (try repos_iter.next()) |cursor| { 82 const pair = try cursor.readKeyValuePair(); 83 const repo_name = try pair.key_cursor.readBytesAlloc(arena.allocator(), null); 84 if (!std.mem.startsWith(u8, repo_name, prefix)) break; 85 if (taken == page_size) { 86 repos_next_start = repos_start + page_size; 87 break; 88 } 89 taken += 1; 90 var event_id: [evt.event_id_size]u8 = undefined; 91 _ = try pair.value_cursor.readBytes(&event_id); 92 const repo_event = (try evt.Repo.readById(DB, hash_kind, moment, arena, &event_id)) orelse continue; 93 // unreadable repos leave their window short rather than shifting the others 94 if (try evt.Repo.roleOf(DB, hash_kind, moment, arena, repo_event, &event_id, session.userId()) == .none) continue; 95 try repos.append(arena.allocator(), repo_event); 96 } 97 } 98 } else { 99 // unsearched, the newest activity comes first. its index sits beside the events. 100 const user_repo = if (user_repo_maybe) |*user_repo| user_repo else unreachable; 101 if (try (try user_repo.core.latestMoment()).getCursor(hash.hashInt(hash_kind, evt.recent_repo_id_set_key))) |recent_cursor| { 102 const recent = try DB.SortedSet(.read_only).init(recent_cursor); 103 const count = try recent.count(); 104 const end = @min(repos_start +| page_size, count); 105 var iter = try recent.iteratorFromIndex(repos_start); 106 var i = repos_start; 107 while (i < end) : (i += 1) { 108 const kv_cursor = (try iter.next()) orelse break; 109 const repo_id = try evt.readOrderKeyId(DB, kv_cursor); 110 const repo_event = (try evt.Repo.readById(DB, hash_kind, moment, arena, &repo_id)) orelse continue; 111 if (try evt.Repo.roleOf(DB, hash_kind, moment, arena, repo_event, &repo_id, session.userId()) == .none) continue; 112 try repos.append(arena.allocator(), repo_event); 113 } 114 repos_next_start = if (end < count) end else null; 115 } 116 } 117 } 118 119 var forks: std.ArrayList(ForkItem) = .empty; 120 var forks_next_start: ?usize = null; 121 if (user_moment) |moment| { 122 if (try moment.getCursor(hash.hashInt(hash_kind, evt.Fork.active_id_set_key))) |user_forks_cursor| { 123 const user_forks = try DB.SortedSet(.read_only).init(user_forks_cursor); 124 const count = try user_forks.count(); 125 const end = @min(forks_start +| page_size, count); 126 var iter = try user_forks.iteratorFromIndex(forks_start); 127 var i = forks_start; 128 while (i < end) : (i += 1) { 129 const kv_cursor = (try iter.next()) orelse break; 130 const fork_id = try evt.readOrderKeyId(DB, kv_cursor); 131 const fork_id_hex = std.fmt.bytesToHex(fork_id, .lower); 132 const record = (try evt.Fork.readById(DB, hash_kind, moment, arena, &fork_id)) orelse continue; 133 134 // the target lives in its owner's user repo 135 const owner_id = record.event.repo_user_id[0..evt.event_id_size]; 136 const target = (try evt.readRepoById(io, gpa, arena, users_dir, owner_id, record.event.repo_id[0..evt.event_id_size], session.userId())) orelse continue; 137 if (target.role == .none) continue; 138 const target_repo = target.repo; 139 140 var title: []const u8 = "(unavailable)"; 141 const path = try fork.forkPath(arena.allocator(), users_dir, &user_id, &fork_id); 142 if (rp.AnyRepo(.xit, .{}).open(io, gpa, .{ .path = path, .require_repo_root = true })) |opened| { 143 var any_fork = opened; 144 defer any_fork.deinit(io, gpa); 145 switch (any_fork) { 146 inline else => |*fork_repo| if (evt.currentMoment(fork_repo.self_repo_opts, fork_repo)) |fork_moment| { 147 const kind = fork_repo.self_repo_opts.hash; 148 if (try evt.Patch.readById(evt.EventDB(kind), kind, fork_moment, arena, &fork_id)) |patch| title = patch.event.title; 149 } else |_| {}, 150 } 151 } else |_| {} 152 try forks.append(arena.allocator(), .{ 153 .id = try arena.allocator().dupe(u8, &fork_id_hex), 154 .target = target_repo.event, 155 .title = title, 156 }); 157 } 158 forks_next_start = if (end < count) end else null; 159 } 160 } 161 162 return .{ 163 .header = try Header.init(arena, user.event.name), 164 .user = evt.project(evt.User.Public, user.event), 165 .repos = repos.items, 166 .repos_start = repos_start, 167 .repos_next_start = repos_next_start, 168 .repos_search = search, 169 .forks = forks.items, 170 .forks_start = forks_start, 171 .forks_next_start = forks_next_start, 172 .quit = Quit.init(), 173 }; 174 } 175 176 pub const View = struct { 177 box: wgt.Box(ui.Widget), 178 179 const header_index: usize = 0; 180 const stack_index: usize = 1; 181 182 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 183 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .round_corners = true, .direction = .vert }); 184 errdefer box.deinit(allocator); 185 186 // build the header first so we can grab the repos-tab id for the user 187 // view (it focuses there after login). 188 { 189 var header_view = try Header.View.init(allocator, &data.header, session); 190 errdefer header_view.deinit(allocator); 191 try box.children.put(allocator, header_view.getFocus().id, .{ .widget = .{ .user_header = header_view }, .rect = null, .min_size = null }); 192 } 193 194 { 195 var stack = try wgt.Stack(ui.Widget).init(allocator); 196 errdefer stack.deinit(allocator); 197 198 // repos list — the default tab 199 { 200 var repos_view = try ReposView.init(allocator, data, session); 201 errdefer repos_view.deinit(allocator); 202 try stack.children.put(allocator, repos_view.getFocus().id, .{ .user_repos = repos_view }); 203 } 204 205 // forks list 206 { 207 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 208 errdefer list.deinit(allocator); 209 210 var item_arena = std.heap.ArenaAllocator.init(allocator); 211 defer item_arena.deinit(); 212 const aa = item_arena.allocator(); 213 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 214 if (data.forks_start > 0) 215 try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:/{s}/forks/start:{d}", .{ data.user.name, data.forks_start -| page_size }) }); 216 for (data.forks) |fork_item| { 217 // the fork route names the forker 218 const identity = try std.fmt.allocPrint(aa, "{s}:{s}", .{ data.user.name, fork_item.target.name }); 219 const route = ui.RoutablePage.forkPatchRoute(identity, fork_item.id) orelse return error.RouteTooLong; 220 try items.append(aa, .{ 221 .text = try std.fmt.allocPrint(aa, "{s} - {s}", .{ fork_item.target.name, fork_item.title }), 222 .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(session.page_arena)}), 223 .bottom_label = if (fork_item.target.read_access == .private) "(private)" else "", 224 }); 225 } 226 if (data.forks_next_start) |next_start| 227 try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:/{s}/forks/start:{d}", .{ data.user.name, next_start }) }); 228 try list.setItems(allocator, items.items); 229 try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); 230 } 231 232 // the header shows new repo with a login and new user without 233 // one, so keep the stack's children 1:1 with the tabs 234 if (session.data.user_id != null) { 235 const route = ui.RoutablePage{ .user_repo_new = ui.RoutablePage.Array(evt.User.name_max_len).from(data.user.name) orelse return error.RouteTooLong }; 236 var new_repo_view = try ui.NewRepo.View.init(allocator, session, route); 237 errdefer new_repo_view.deinit(allocator); 238 try stack.children.put(allocator, new_repo_view.getFocus().id, .{ .new_repo = new_repo_view }); 239 } else { 240 const route = ui.RoutablePage{ .user_user_new = ui.RoutablePage.Array(evt.User.name_max_len).from(data.user.name) orelse return error.RouteTooLong }; 241 var new_user_view = try ui.NewUser.View.init(allocator, session, route); 242 errdefer new_user_view.deinit(allocator); 243 try stack.children.put(allocator, new_user_view.getFocus().id, .{ .new_user = new_user_view }); 244 } 245 246 { 247 var user_view = try ui.UserLogout.initView(allocator, session); 248 errdefer user_view.deinit(allocator); 249 try stack.children.put(allocator, user_view.getFocus().id, user_view); 250 } 251 252 if (session.is_terminal) { 253 var quit_view = try Quit.View.init(allocator, session); 254 errdefer quit_view.deinit(allocator); 255 try stack.children.put(allocator, quit_view.getFocus().id, .{ .quit = quit_view }); 256 } 257 258 try box.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 259 } 260 261 var self = View{ .box = box }; 262 // search results open in the tab's own search box 263 self.getFocus().child_id = box.children.keys()[if (data.repos_search != null) stack_index else header_index]; 264 return self; 265 } 266 267 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 268 self.box.deinit(allocator); 269 } 270 271 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 272 self.clearGrid(); 273 const header = &self.box.children.values()[header_index].widget.user_header; 274 const stack = &self.box.children.values()[stack_index].widget.stack; 275 276 // each header tab maps 1:1 to a stack child by position 277 if (header.getSelectedIndex()) |index| 278 stack.getFocus().child_id = stack.children.keys()[index]; 279 try self.box.build(allocator, constraint, root_focus); 280 } 281 282 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 283 const stack = &self.box.children.values()[stack_index].widget.stack; 284 if (self.getFocus().child_id) |child_id| { 285 if (self.box.children.getIndex(child_id)) |current_index| { 286 const child = &self.box.children.values()[current_index].widget; 287 var index = current_index; 288 289 const direction = inp.vertDirection(key); 290 291 switch (direction) { 292 .up => { 293 switch (child.*) { 294 .user_header => { 295 try child.input(allocator, key, root_focus); 296 }, 297 .stack => { 298 if (child.stack.getSelected()) |selected_widget| { 299 if (selected_widget.atTop(root_focus)) { 300 index = header_index; 301 } else { 302 try child.input(allocator, key, root_focus); 303 } 304 } 305 }, 306 else => {}, 307 } 308 }, 309 .down => { 310 switch (child.*) { 311 .user_header => { 312 if (stack.getSelected()) |selected_widget| switch (selected_widget.*) { 313 .user_repos => |*v| return v.focusHeader(root_focus), 314 // an empty list has nothing to focus 315 .flow_box_scroll => |*v| if (v.isEmpty()) return, 316 else => {}, 317 }; 318 index = stack_index; 319 }, 320 .stack => { 321 try child.input(allocator, key, root_focus); 322 }, 323 else => {}, 324 } 325 }, 326 .none => { 327 try child.input(allocator, key, root_focus); 328 }, 329 } 330 331 if (index != current_index) { 332 root_focus.setFocus(self.box.children.keys()[index]); 333 } 334 } 335 } 336 } 337 338 pub fn clearGrid(self: *View) void { 339 self.box.clearGrid(); 340 } 341 342 pub fn getGrid(self: View) ?Grid { 343 return self.box.getGrid(); 344 } 345 346 pub fn getFocus(self: *View) *Focus { 347 return self.box.getFocus(); 348 } 349 }; 350 351 pub const ReposView = struct { 352 // a vertical box: the search sub-header above the scrolling list. focus 353 // points at the header's box or the list's selected row. 354 box: wgt.Box(ui.Widget), 355 data: *const Self, 356 session: *ui.Session, 357 358 const header_index = 0; 359 const list_index = 1; 360 361 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ReposView { 362 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 363 errdefer box.deinit(allocator); 364 365 { 366 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 367 errdefer header.deinit(allocator); 368 { 369 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); 370 errdefer search_box.deinit(allocator); 371 header.getFocus().child_id = search_box.getFocus().id; 372 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 373 } 374 // the spacer keeps the box at its own width 375 { 376 var spacer = try ui.widget.Spacer.init(allocator); 377 errdefer spacer.deinit(allocator); 378 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 379 } 380 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 381 } 382 383 { 384 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 385 errdefer list.deinit(allocator); 386 387 var arena = std.heap.ArenaAllocator.init(allocator); 388 defer arena.deinit(); 389 const aa = arena.allocator(); 390 391 const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; 392 393 // a leading "previous" row off the first window, one row per repo, 394 // then a trailing "next" row when more remain. each window row 395 // navigates to the adjacent window of this user's repos. 396 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 397 if (data.repos_start > 0) { 398 var prev = route; 399 prev.user_repos.start = data.repos_start -| page_size; 400 try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try prev.toUrl(&arena)}) }); 401 } 402 for (data.repos) |repo| 403 // clicking a repo opens its page; the "a:" prefix makes the web 404 // renderer emit an <a href="/alice:foo"> anchor. 405 try items.append(aa, .{ 406 .text = if (repo.event.description.len == 0) repo.event.name else try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo.event.name, repo.event.description }), 407 .link = try std.fmt.allocPrint(aa, "a:/{s}:{s}", .{ data.user.name, repo.event.name }), 408 .bottom_label = if (repo.event.read_access == .private) "(private)" else "", 409 }); 410 if (data.repos_next_start) |next_start| { 411 var next = route; 412 next.user_repos.start = next_start; 413 try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try next.toUrl(&arena)}) }); 414 } 415 try list.setItems(allocator, items.items); 416 417 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 418 } 419 420 // search results start in the box so the term can be refined right away 421 box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; 422 return .{ .box = box, .data = data, .session = session }; 423 } 424 425 pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { 426 self.box.deinit(allocator); 427 } 428 429 pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 430 self.clearGrid(); 431 // clear the incoming min height so the list scrolls within what's left 432 try self.box.build(allocator, .{ 433 .min_size = .{ .width = null, .height = null }, 434 .max_size = constraint.max_size, 435 }, root_focus); 436 } 437 438 pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 439 const direction = inp.vertDirection(key); 440 if (self.headerActive()) { 441 const search_box = self.searchBox(); 442 if (direction == .down) return root_focus.setFocus(self.listScroll().getFocus().id); 443 if (key == .enter) { 444 const text = try search_box.text(allocator); 445 defer allocator.free(text); 446 return self.submit(text); 447 } 448 return search_box.input(allocator, key, root_focus); 449 } 450 // up from the first row reaches the search box 451 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 452 try self.listScroll().input(allocator, key, root_focus); 453 } 454 455 // the repos narrowed to `text` from the first window. an empty box on an 456 // unsearched page has nothing to clear. 457 fn submit(self: *ReposView, text: []const u8) !void { 458 if (text.len == 0 and self.data.repos_search == null) return; 459 const route = reposRoute(self.data.user.name) orelse return; 460 try self.session.navigate(route.withSearch(text) orelse return); 461 } 462 463 fn searchBox(self: *ReposView) *ui.widget.SearchBox { 464 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 465 } 466 467 fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { 468 return &self.box.children.values()[list_index].widget.flow_box_scroll; 469 } 470 471 fn headerActive(self: *ReposView) bool { 472 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 473 } 474 475 pub fn clearGrid(self: *ReposView) void { 476 self.box.clearGrid(); 477 } 478 479 pub fn getGrid(self: ReposView) ?Grid { 480 return self.box.getGrid(); 481 } 482 483 pub fn getFocus(self: *ReposView) *Focus { 484 return self.box.getFocus(); 485 } 486 487 // up leaves the tab from the search box 488 pub fn atTop(self: *ReposView) bool { 489 return self.headerActive(); 490 } 491 492 pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { 493 root_focus.setFocus(self.searchBox().getFocus().id); 494 } 495 }; 496 497 // the first window of `name`'s repos 498 fn reposRoute(name: []const u8) ?ui.RoutablePage { 499 return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } }; 500 } 501