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