╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
348 } 349 350 // both terminal hosts resolve and authorize the request afresh before opening 351 // the repository; the web handler uses its matching http authorization path. 352 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 353 const identity = target.name.slice(); 354 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 355 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 356 const io = session.io orelse return error.NotFound; 357 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 358 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 359 } 360 361 // the repo an owner may act on, or null when they may not 362 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 363 if (try session.authorize(identity, .owner, null) == null) return null; 364 if (session.local) |local| return local; 365 const admin_repo = session.admin_repo orelse return error.NotFound; 366 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 367 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 368 const io = session.io orelse return error.NotFound; 369 const users_dir = session.users_dir orelse return error.NotFound; 370 const found = try evt.readRepoByOwnerAndName(io, session.page_arena.child_allocator, session.page_arena, moment, users_dir, pair.owner, pair.name, null) orelse return error.NotFound; 371 return .{ .path = try evt.repoPath(session.page_arena.allocator(), users_dir, found.repo.event.user_id, &found.event_id), .repo_kind = .xit }; 372 } 373 374 pub const View = struct { 375 box: wgt.Box(ui.Widget), 376 data: *const Self, 377 session: *ui.Session, 378 detailed_index: ?usize = null, 379 shown_failure: ?[]const u8 = null, 380 381 const list_max_width = 20; 382 const detail_min_width = 40; 383 const header_index = 0; 384 const content_index = 1; 385 // the undo button and timestamp stay; everything below them is rebuilt 386 const fixed_rows = 2; 387 388 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 389 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 390 errdefer outer.deinit(allocator); 391 392 // the sub header carries the tab's only action 393 { 394 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 395 errdefer header.deinit(allocator); 396 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 397 const link = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}); 398 try addText(allocator, &header, "clear undo history", link, .single); 399 header.getFocus().child_id = header.children.keys()[0]; 400 // a row taller than the header, leaving a blank line beneath it 401 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 402 } 403 404 if (data.clear) { 405 var center = try initClearForm(allocator, data, session); 406 errdefer center.deinit(allocator); 407 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 408 outer.getFocus().child_id = outer.children.keys()[content_index]; 409 return .{ .box = outer, .data = data, .session = session }; 410 } 411 412 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 413 errdefer box.deinit(allocator); 414 { 415 var scroll = blk: { 416 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert, .stretch = true }); 417 errdefer rows.deinit(allocator); 418 for (data.items) |item| { 419 try addText(allocator, &rows, item.action, item.link, .hidden); 420 // a transaction a later undo discarded says so on its border 421 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label.text = "(undone)"; 422 } 423 if (data.next) |next| { 424 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 425 try addText(allocator, &rows, "next →", try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 426 } 427 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 428 rows.getFocus().child_id = rows.children.keys()[0]; 429 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 430 }; 431 errdefer scroll.deinit(allocator); 432 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 433 } 434 // the detail scroll, below the way back to a hidden list 435 { 436 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 437 errdefer frame.deinit(allocator); 438 try ui.widget.addListBack(allocator, &frame, "← back to undo list"); 439 var scroll = blk: { 440 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 441 errdefer details.deinit(allocator); 442 if (data.items.len > 0) { 443 try addText(allocator, &details, "", null, .single); 444 try addText(allocator, &details, "", null, .single); 445 details.getFocus().child_id = details.children.keys()[0]; 446 } 447 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 448 }; 449 errdefer scroll.deinit(allocator); 450 scroll.getFocus().mode = .mouse; 451 frame.getFocus().child_id = scroll.getFocus().id; 452 try frame.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 453 try box.children.put(allocator, frame.getFocus().id, .{ .widget = .{ .box = frame }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 454 } 455 box.getFocus().child_id = box.children.keys()[if (data.items.len > 0) 1 else 0]; 456 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 457 outer.getFocus().child_id = outer.children.keys()[content_index]; 458 return .{ .box = outer, .data = data, .session = session }; 459 } 460 461 // the same shape the thread views use to confirm a removal 462 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 463 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .round_corners = true, .direction = .vert }); 464 errdefer box.deinit(allocator); 465 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 466 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 467 468 var prompt = try wgt.Text.init(allocator, "are you sure?"); 469 errdefer prompt.deinit(allocator); 470 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 471 472 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 473 errdefer button.deinit(allocator); 474 button.getFocus().mode = .all; 475 button.getFocus().kind = if (data.handle.canUndo()) .{ .custom = "submit" } else .text_box; 476 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 477 box.getFocus().child_id = button.getFocus().id; 478 479 return ui.widget.Center.init(allocator, .{ .box = box }); 480 } 481 482 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.Border) !void { 483 var row = try wgt.TextBox.init(allocator, text, .{ .border = border, .round_corners = true, .wrap_kind = .word }); 484 errdefer row.deinit(allocator); 485 row.getFocus().mode = .all; 486 if (link) |value| row.getFocus().kind = .{ .custom = value }; 487 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 488 } 489 490 // a labeled box, added as the selected action needs it 491 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 492 try addText(allocator, box, text, link, .single); 493 box.children.values()[box.children.count() - 1].widget.text_box.options.top_label.text = label; 494 } 495 496 // the terminal hosts run the action after rendering; the web renderer 497 // posts the form instead, and wasm has no repository to act on 498 fn requestUndo(self: *View, route: ui.RoutablePage) void { 499 if (builtin.target.cpu.arch == .wasm32) return; 500 if (!self.session.is_terminal or self.session.host_request != null) return; 501 self.session.host_request = .{ .undo = route.repo_undo }; 502 } 503 504 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 505 return &self.box.children.values()[header_index].widget.box; 506 } 507 fn headerActive(self: *View) bool { 508 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 509 } 510 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 511 return &self.box.children.values()[content_index].widget.box; 512 } 513 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 514 return &self.contentBox().children.values()[0].widget.scroll; 515 } 516 fn detailFrame(self: *View) *wgt.Box(ui.Widget) { 517 return &self.contentBox().children.values()[1].widget.box; 518 } 519 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 520 return &self.detailFrame().children.values()[ui.widget.list_pane_index].widget.scroll; 521 } 522 fn selected(self: *View) ?usize { 523 const rows = &self.listScroll().child.box; 524 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 525 return if (index < self.data.items.len) index else null; 526 } 527 fn detailActive(self: *View) bool { 528 return self.contentBox().getFocus().child_id == self.detailFrame().getFocus().id; 529 } 530 531 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 532 const index = self.selected() orelse return; 533 const failure = self.session.data.undo_failure orelse self.data.failure; 534 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 535 const details = &self.detailScroll().child.box; 536 while (details.children.count() > fixed_rows) { 537 var entry = details.children.pop() orelse break; 538 entry.value.widget.deinit(allocator); 539 } 540 const item = self.data.items[index]; 541 const enabled = self.data.handle.canUndo() and item.index > 0; 542 543 // the fixed rows are filled first, since adding the ones below 544 // invalidates every pointer into the box 545 { 546 const button = &details.children.values()[0].widget.text_box; 547 try button.setContent(allocator, if (item.index == 0) "initial state cannot be undone" else if (item.index == self.data.count - 1) "undo this" else "undo this and all above it"); 548 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 549 } 550 const timestamp = try ui.timestamp.format(self.session.page_arena.allocator(), item.timestamp, try self.session.nowSeconds()); 551 try details.children.values()[1].widget.text_box.setContent(allocator, timestamp.relative); 552 details.children.values()[1].widget.text_box.options.bottom_label.text = timestamp.exact; 553 554 // some actions put buttons where the description would go 555 if (item.buttons.len == 0) { 556 try addLabeled(allocator, details, " description ", item.description, null); 557 } else for (item.buttons) |detail_button| { 558 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 559 } 560 if (failure) |message| try addText(allocator, details, try self.session.page_arena.allocator().print("error: {s}", .{message}), null, .single); 561 self.shown_failure = failure; 562 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 563 details.getFocus().child_id = details.children.keys()[0]; 564 self.detailScroll().x = 0; 565 self.detailScroll().y = 0; 566 self.detailScroll().getFocus().version +%= 1; 567 ui.widget.setListBackLink(self.detailFrame(), item.link); 568 self.detailed_index = index; 569 } 570 571 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 572 self.clearGrid(); 573 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 574 try self.refreshDetail(allocator); 575 const rows = &self.listScroll().child.box; 576 if (self.data.items.len == 0) { 577 const failure = self.session.data.undo_failure orelse self.data.failure; 578 if (!std.meta.eql(self.shown_failure, failure)) { 579 const message = if (failure) |name| try self.session.page_arena.allocator().print("error: {s}", .{name}) else "no undo history"; 580 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 581 self.shown_failure = failure; 582 } 583 } 584 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 585 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 586 ui.widget.showListBack(self.detailFrame(), !both_fit); 587 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 588 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 589 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 590 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 591 try self.box.build(allocator, constraint, root_focus); 592 } 593 594 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 595 // the sub header sits above whichever content the route selects. 596 // scrolling crosses the boundary the same way the arrow keys do 597 const direction = inp.vertDirection(key); 598 if (self.headerActive()) { 599 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 600 return; 601 } 602 if (self.data.clear) { 603 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 604 return; 605 } 606 // the way back to the list, by enter or a click 607 if (self.detailActive()) if (ui.widget.listBackId(self.detailFrame())) |id| { 608 if (inp.activated(root_focus, id, key)) return self.focusList(root_focus); 609 if (root_focus.grandchild_id == id) return switch (direction) { 610 .up => root_focus.setFocus(self.headerBox().getFocus().id), 611 .down => root_focus.setFocus(self.detailScroll().getFocus().id), 612 .none => if (key == .arrow_left) self.focusList(root_focus), 613 }; 614 if (direction == .up and self.contentAtTop()) return root_focus.setFocus(id); 615 }; 616 if (direction == .up and self.contentAtTop()) { 617 root_focus.setFocus(self.headerBox().getFocus().id); 618 return; 619 } 620 try self.refreshDetail(allocator); 621 if (self.detailActive()) { 622 const details = &self.detailScroll().child.box; 623 if (self.selected()) |index| { 624 if (inp.activated(root_focus, details.children.keys()[0], key) and self.data.handle.canUndo() and self.data.items[index].index > 0) { 625 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 626 return; 627 } 628 } 629 if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| { 630 ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 631 } else if (key == .arrow_left) { 632 self.focusList(root_focus); 633 } 634 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 635 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 636 } else switch (key) { 637 .arrow_right, .enter => if (self.selected() != null) self.focusDetail(root_focus), 638 // when the window is too narrow to lay out the detail pane beside 639 // the list, a click on a row opens it like enter. the detail was 640 // swapped to the just-selected row above. 641 .mouse => |mouse| if (self.selected() != null and self.contentBox().children.values()[1].rect == null and ui.widget.clickOnSelectedRow(&self.listScroll().child.box, root_focus, mouse)) { 642 self.focusDetail(root_focus); 643 }, 644 else => {}, 645 } 646 } 647 648 // enter the detail pane, landing on the way back to the list when it shows 649 fn focusDetail(self: *View, root_focus: *Focus) void { 650 if (ui.widget.listBackId(self.detailFrame()) != null) return ui.widget.focusListBack(self.detailFrame(), root_focus); 651 root_focus.setFocus(self.detailScroll().getFocus().id); 652 } 653 654 fn focusList(self: *View, root_focus: *Focus) void { 655 root_focus.setFocus(self.listScroll().getFocus().id); 656 } 657 658 // the confirm button submits the form the web renderer posts 659 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 660 const center = &self.box.children.values()[content_index].widget.center; 661 const button_id = center.child.box.getFocus().child_id orelse return; 662 if (!inp.activated(root_focus, button_id, key) or !self.data.handle.canUndo()) return; 663 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 664 } 665 666 fn contentAtTop(self: *View) bool { 667 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 668 const rows = &scroll.child.box; 669 const id = rows.getFocus().child_id orelse return true; 670 return rows.children.getIndex(id) == 0 and scroll.y == 0; 671 } 672 673 // moving up from the sub header returns to the page's tabs 674 pub fn atTop(self: *View) bool { 675 return self.headerActive(); 676 } 677 678 // arriving from the page's tabs lands on the sub header, not the content 679 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 680 root_focus.setFocus(self.headerBox().getFocus().id); 681 return true; 682 } 683 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 684 self.box.deinit(allocator); 685 } 686 pub fn clearGrid(self: *View) void { 687 self.box.clearGrid(); 688 } 689 pub fn getGrid(self: View) ?Grid { 690 return self.box.getGrid(); 691 } 692 pub fn getFocus(self: *View) *Focus { 693 return self.box.getFocus(); 694 } 695 }; 696