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