╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ User.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
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()) == null) 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()) == null) 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 == null) continue; 138 // patches that are off take their drafts with them 139 if (target.repo.event.patch_role == null) continue; 140 const target_repo = target.repo; 141 142 var title: []const u8 = "(unavailable)"; 143 const path = try fork.forkPath(arena.allocator(), users_dir, &user_id, &fork_id); 144 if (rp.AnyRepo(.xit, .{}).open(io, gpa, .{ .path = path, .require_repo_root = true })) |opened| { 145 var any_fork = opened; 146 defer any_fork.deinit(io, gpa); 147 switch (any_fork) { 148 inline else => |*fork_repo| if (evt.currentMoment(fork_repo.self_repo_opts, fork_repo)) |fork_moment| { 149 const kind = fork_repo.self_repo_opts.hash; 150 if (try evt.Patch.readById(evt.EventDB(kind), kind, fork_moment, arena, &fork_id)) |patch| title = patch.event.title; 151 } else |_| {}, 152 } 153 } else |_| {} 154 try forks.append(arena.allocator(), .{ 155 .id = try arena.allocator().dupe(u8, &fork_id_hex), 156 .target = target_repo.event, 157 .title = title, 158 }); 159 } 160 forks_next_start = if (end < count) end else null; 161 } 162 } 163 164 return .{ 165 .header = try Header.init(arena, user.event.name), 166 .user = evt.project(evt.User.Public, user.event), 167 .repos = repos.items, 168 .repos_start = repos_start, 169 .repos_next_start = repos_next_start, 170 .repos_search = search, 171 .forks = forks.items, 172 .forks_start = forks_start, 173 .forks_next_start = forks_next_start, 174 .quit = Quit.init(), 175 }; 176 } 177 178 pub const View = struct { 179 box: wgt.Box(ui.Widget), 180 181 const header_index: usize = 0; 182 const stack_index: usize = 1; 183 184 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 185 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .round_corners = true, .direction = .vert }); 186 errdefer box.deinit(allocator); 187 188 // build the header first so we can grab the repos-tab id for the user 189 // view (it focuses there after login). 190 { 191 var header_view = try Header.View.init(allocator, &data.header, session); 192 errdefer header_view.deinit(allocator); 193 try box.children.put(allocator, header_view.getFocus().id, .{ .widget = .{ .user_header = header_view }, .rect = null, .min_size = null }); 194 } 195 196 { 197 var stack = try wgt.Stack(ui.Widget).init(allocator); 198 errdefer stack.deinit(allocator); 199 200 // repos list — the default tab 201 { 202 var repos_view = try ReposView.init(allocator, data, session); 203 errdefer repos_view.deinit(allocator); 204 try stack.children.put(allocator, repos_view.getFocus().id, .{ .user_repos = repos_view }); 205 } 206 207 // forks list 208 { 209 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 210 errdefer list.deinit(allocator); 211 212 var item_arena = std.heap.ArenaAllocator.init(allocator); 213 defer item_arena.deinit(); 214 const aa = item_arena.allocator(); 215 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 216 if (data.forks_start > 0) 217 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:/{s}/forks/start:{d}", .{ data.user.name, data.forks_start -| page_size }) }); 218 for (data.forks) |fork_item| { 219 // the fork route names the forker 220 const identity = try aa.print("{s}:{s}", .{ data.user.name, fork_item.target.name }); 221 const route = ui.RoutablePage.forkPatchRoute(identity, fork_item.id) orelse return error.RouteTooLong; 222 try items.append(aa, .{ 223 .text = try aa.print("{s} - {s}", .{ fork_item.target.name, fork_item.title }), 224 .link = try aa.print("a:{s}", .{try route.toUrl(session.page_arena)}), 225 .bottom_label = if (fork_item.target.read_access == .private) "(private)" else "", 226 }); 227 } 228 if (data.forks_next_start) |next_start| 229 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:/{s}/forks/start:{d}", .{ data.user.name, next_start }) }); 230 try list.setItems(allocator, items.items); 231 try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); 232 } 233 234 // the header shows new repo with a login and new user without 235 // one, so keep the stack's children 1:1 with the tabs 236 if (session.data.user_id != null) { 237 const route = ui.RoutablePage{ .user_repo_new = ui.RoutablePage.Array(evt.User.name_max_len).from(data.user.name) orelse return error.RouteTooLong }; 238 var new_repo_view = try ui.NewRepo.View.init(allocator, session, route); 239 errdefer new_repo_view.deinit(allocator); 240 try stack.children.put(allocator, new_repo_view.getFocus().id, .{ .new_repo = new_repo_view }); 241 } else { 242 const route = ui.RoutablePage{ .user_user_new = ui.RoutablePage.Array(evt.User.name_max_len).from(data.user.name) orelse return error.RouteTooLong }; 243 var new_user_view = try ui.NewUser.View.init(allocator, session, route); 244 errdefer new_user_view.deinit(allocator); 245 try stack.children.put(allocator, new_user_view.getFocus().id, .{ .new_user = new_user_view }); 246 } 247 248 { 249 var user_view = try ui.UserSettings.initView(allocator, session); 250 errdefer user_view.deinit(allocator); 251 try stack.children.put(allocator, user_view.getFocus().id, user_view); 252 } 253 254 if (session.is_terminal) { 255 var quit_view = try Quit.View.init(allocator, session); 256 errdefer quit_view.deinit(allocator); 257 try stack.children.put(allocator, quit_view.getFocus().id, .{ .quit = quit_view }); 258 } 259 260 try box.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 261 } 262 263 var self = View{ .box = box }; 264 // search results open in the tab's own search box 265 self.getFocus().child_id = box.children.keys()[if (data.repos_search != null) stack_index else header_index]; 266 return self; 267 } 268 269 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 270 self.box.deinit(allocator); 271 } 272 273 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 274 self.clearGrid(); 275 const header = &self.box.children.values()[header_index].widget.user_header; 276 const stack = &self.box.children.values()[stack_index].widget.stack; 277 278 // each header tab maps 1:1 to a stack child by position 279 if (header.getSelectedIndex()) |index| 280 stack.getFocus().child_id = stack.children.keys()[index]; 281 try self.box.build(allocator, constraint, root_focus); 282 } 283 284 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 285 const stack = &self.box.children.values()[stack_index].widget.stack; 286 if (self.getFocus().child_id) |child_id| { 287 if (self.box.children.getIndex(child_id)) |current_index| { 288 const child = &self.box.children.values()[current_index].widget; 289 var index = current_index; 290 291 const direction = inp.vertDirection(key); 292 293 switch (direction) { 294 .up => { 295 switch (child.*) { 296 .user_header => { 297 try child.input(allocator, key, root_focus); 298 }, 299 .stack => { 300 if (child.stack.getSelected()) |selected_widget| { 301 if (selected_widget.atTop(root_focus)) { 302 index = header_index; 303 } else { 304 try child.input(allocator, key, root_focus); 305 } 306 } 307 }, 308 else => {}, 309 } 310 }, 311 .down => { 312 switch (child.*) { 313 .user_header => { 314 if (stack.getSelected()) |selected_widget| switch (selected_widget.*) { 315 .user_repos => |*v| return v.focusHeader(root_focus), 316 // an empty list has nothing to focus 317 .flow_box_scroll => |*v| if (v.isEmpty()) return, 318 else => {}, 319 }; 320 index = stack_index; 321 }, 322 .stack => { 323 try child.input(allocator, key, root_focus); 324 }, 325 else => {}, 326 } 327 }, 328 .none => { 329 try child.input(allocator, key, root_focus); 330 }, 331 } 332 333 if (index != current_index) { 334 root_focus.setFocus(self.box.children.keys()[index]); 335 } 336 } 337 } 338 } 339 340 pub fn clearGrid(self: *View) void { 341 self.box.clearGrid(); 342 } 343 344 pub fn getGrid(self: View) ?Grid { 345 return self.box.getGrid(); 346 } 347 348 pub fn getFocus(self: *View) *Focus { 349 return self.box.getFocus(); 350 } 351 }; 352 353 pub const ReposView = struct { 354 // a vertical box: the search sub-header above the scrolling list. focus 355 // points at the header's box or the list's selected row. 356 box: wgt.Box(ui.Widget), 357 data: *const Self, 358 session: *ui.Session, 359 360 const header_index = 0; 361 const list_index = 1; 362 363 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ReposView { 364 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 365 errdefer box.deinit(allocator); 366 367 { 368 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 369 errdefer header.deinit(allocator); 370 { 371 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); 372 errdefer search_box.deinit(allocator); 373 header.getFocus().child_id = search_box.getFocus().id; 374 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 375 } 376 // the spacer keeps the box at its own width 377 { 378 var spacer = try ui.widget.Spacer.init(allocator); 379 errdefer spacer.deinit(allocator); 380 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 381 } 382 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 383 } 384 385 { 386 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 387 errdefer list.deinit(allocator); 388 389 var arena = std.heap.ArenaAllocator.init(allocator); 390 defer arena.deinit(); 391 const aa = arena.allocator(); 392 393 const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; 394 395 // a leading "previous" row off the first window, one row per repo, 396 // then a trailing "next" row when more remain. each window row 397 // navigates to the adjacent window of this user's repos. 398 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 399 if (data.repos_start > 0) { 400 var prev = route; 401 prev.user_repos.start = data.repos_start -| page_size; 402 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 403 } 404 for (data.repos) |repo| 405 // clicking a repo opens its page; the "a:" prefix makes the web 406 // renderer emit an <a href="/alice:foo"> anchor. 407 try items.append(aa, .{ 408 .text = if (repo.event.description.len == 0) repo.event.name else try aa.print("{s} - {s}", .{ repo.event.name, repo.event.description }), 409 .link = try aa.print("a:/{s}:{s}", .{ data.user.name, repo.event.name }), 410 .bottom_label = if (repo.event.read_access == .private) "(private)" else "", 411 }); 412 if (data.repos_next_start) |next_start| { 413 var next = route; 414 next.user_repos.start = next_start; 415 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 416 } 417 try list.setItems(allocator, items.items); 418 419 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 420 } 421 422 // search results start in the box so the term can be refined right away 423 box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; 424 return .{ .box = box, .data = data, .session = session }; 425 } 426 427 pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { 428 self.box.deinit(allocator); 429 } 430 431 pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 432 self.clearGrid(); 433 // clear the incoming min height so the list scrolls within what's left 434 try self.box.build(allocator, .{ 435 .min_size = .{ .width = null, .height = null }, 436 .max_size = constraint.max_size, 437 }, root_focus); 438 } 439 440 pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 441 const direction = inp.vertDirection(key); 442 if (self.headerActive()) { 443 const search_box = self.searchBox(); 444 if (direction == .down) return root_focus.setFocus(self.listScroll().getFocus().id); 445 if (key == .enter) { 446 const text = try search_box.text(allocator); 447 defer allocator.free(text); 448 return self.submit(text); 449 } 450 return search_box.input(allocator, key, root_focus); 451 } 452 // up from the first row reaches the search box 453 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 454 try self.listScroll().input(allocator, key, root_focus); 455 } 456 457 // the repos narrowed to `text` from the first window. an empty box on an 458 // unsearched page has nothing to clear. 459 fn submit(self: *ReposView, text: []const u8) !void { 460 if (text.len == 0 and self.data.repos_search == null) return; 461 const route = reposRoute(self.data.user.name) orelse return; 462 try self.session.navigate(route.withSearch(text) orelse return); 463 } 464 465 fn searchBox(self: *ReposView) *ui.widget.SearchBox { 466 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 467 } 468 469 fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { 470 return &self.box.children.values()[list_index].widget.flow_box_scroll; 471 } 472 473 fn headerActive(self: *ReposView) bool { 474 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 475 } 476 477 pub fn clearGrid(self: *ReposView) void { 478 self.box.clearGrid(); 479 } 480 481 pub fn getGrid(self: ReposView) ?Grid { 482 return self.box.getGrid(); 483 } 484 485 pub fn getFocus(self: *ReposView) *Focus { 486 return self.box.getFocus(); 487 } 488 489 // up leaves the tab from the search box 490 pub fn atTop(self: *ReposView) bool { 491 return self.headerActive(); 492 } 493 494 pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { 495 root_focus.setFocus(self.searchBox().getFocus().id); 496 } 497 }; 498 499 // the first window of `name`'s repos 500 fn reposRoute(name: []const u8) ?ui.RoutablePage { 501 return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } }; 502 } 503