╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
190 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 191 else |_| 192 &.{}; 193 defer allocator.free(roots); 194 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 195 }, 196 } 197 } 198 199 // keep action failures on the current tab without rebuilding a stale route. 200 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 201 perform(allocator, session, target) catch |err| { 202 session.data.undo_failure = @errorName(err); 203 return; 204 }; 205 session.data.undo_failure = null; 206 } 207 208 // both terminal hosts resolve and authorize the request afresh before opening 209 // the repository; the web handler uses its matching http authorization path. 210 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 211 const identity = target.name.slice(); 212 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 213 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 214 const io = session.io orelse return error.NotFound; 215 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 216 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 217 } 218 219 // the repo an owner may act on, or null when they may not 220 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 221 if (try session.authorize(identity, .owner) == null) return null; 222 if (session.local) |local| return local; 223 const admin_repo = session.admin_repo orelse return error.NotFound; 224 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 225 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 226 const found = try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, session.page_arena, pair.owner, pair.name) orelse return error.NotFound; 227 const hex = std.fmt.bytesToHex(found.event_id, .lower); 228 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 229 } 230 231 pub const View = struct { 232 box: wgt.Box(ui.Widget), 233 data: *const Self, 234 session: *ui.Session, 235 detailed_index: ?usize = null, 236 failure_id: ?usize = null, 237 shown_failure: ?[]const u8 = null, 238 239 const list_max_width = 20; 240 const detail_min_width = 40; 241 const header_index = 0; 242 const content_index = 1; 243 244 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 245 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 246 errdefer outer.deinit(allocator); 247 248 // the sub header carries the tab's only action 249 { 250 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 251 errdefer header.deinit(allocator); 252 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 253 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 254 try addText(allocator, &header, "clear undo history", link, .single); 255 header.getFocus().child_id = header.children.keys()[0]; 256 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 257 } 258 259 if (data.clear) { 260 var center = try initClearForm(allocator, data, session); 261 errdefer center.deinit(allocator); 262 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 263 outer.getFocus().child_id = outer.children.keys()[content_index]; 264 return .{ .box = outer, .data = data, .session = session }; 265 } 266 267 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 268 errdefer box.deinit(allocator); 269 { 270 var scroll = blk: { 271 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 272 errdefer rows.deinit(allocator); 273 for (data.items) |item| try addText(allocator, &rows, item.action, item.link, .hidden); 274 if (data.next) |next| { 275 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 276 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 277 } 278 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 279 rows.getFocus().child_id = rows.children.keys()[0]; 280 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 281 }; 282 errdefer scroll.deinit(allocator); 283 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 } }); 284 } 285 { 286 var scroll = blk: { 287 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 288 errdefer details.deinit(allocator); 289 if (data.items.len > 0) { 290 try addText(allocator, &details, "", null, .single); 291 try addText(allocator, &details, "", null, .single); 292 details.children.values()[1].widget.text_box.options.label = " timestamp "; 293 try addText(allocator, &details, "", null, .single); 294 details.children.values()[2].widget.text_box.options.label = " description "; 295 details.getFocus().child_id = details.children.keys()[0]; 296 } 297 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 298 }; 299 errdefer scroll.deinit(allocator); 300 scroll.getFocus().mode = .mouse; 301 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 302 } 303 box.getFocus().child_id = box.children.keys()[0]; 304 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 305 outer.getFocus().child_id = outer.children.keys()[content_index]; 306 return .{ .box = outer, .data = data, .session = session }; 307 } 308 309 // the same shape the thread views use to confirm a removal 310 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 311 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 312 errdefer box.deinit(allocator); 313 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 314 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 315 316 var prompt = try wgt.Text.init(allocator, "are you sure?"); 317 errdefer prompt.deinit(allocator); 318 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 319 320 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 321 errdefer button.deinit(allocator); 322 button.getFocus().mode = .all; 323 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 324 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 325 box.getFocus().child_id = button.getFocus().id; 326 327 return ui.widget.Center.init(allocator, .{ .box = box }); 328 } 329 330 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 331 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 332 errdefer row.deinit(allocator); 333 row.getFocus().mode = .all; 334 if (link) |value| row.getFocus().kind = .{ .custom = value }; 335 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 336 } 337 338 // enter counts only on the focused button; a click counts anywhere on it 339 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 340 return switch (key) { 341 .enter => root_focus.grandchild_id == button_id, 342 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 343 else => false, 344 }; 345 } 346 347 // the terminal hosts run the action after rendering; the web renderer 348 // posts the form instead, and wasm has no repository to act on 349 fn requestUndo(self: *View, route: ui.RoutablePage) void { 350 if (builtin.target.cpu.arch == .wasm32) return; 351 if (!self.session.is_terminal or self.session.host_request != null) return; 352 self.session.host_request = .{ .undo = route.repo_undo }; 353 } 354 355 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 356 return &self.box.children.values()[header_index].widget.box; 357 } 358 fn headerActive(self: *View) bool { 359 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 360 } 361 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 362 return &self.box.children.values()[content_index].widget.box; 363 } 364 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 365 return &self.contentBox().children.values()[0].widget.scroll; 366 } 367 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 368 return &self.contentBox().children.values()[1].widget.scroll; 369 } 370 fn selected(self: *View) ?usize { 371 const rows = &self.listScroll().child.box; 372 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 373 return if (index < self.data.items.len) index else null; 374 } 375 fn detailActive(self: *View) bool { 376 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 377 } 378 379 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 380 const index = self.selected() orelse return; 381 const failure = self.session.data.undo_failure orelse self.data.failure; 382 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 383 const details = &self.detailScroll().child.box; 384 if (self.failure_id) |id| { 385 if (details.children.getPtr(id)) |child| child.widget.deinit(allocator); 386 _ = details.children.orderedRemove(id); 387 self.failure_id = null; 388 } 389 const item = self.data.items[index]; 390 const button = &details.children.values()[0].widget.text_box; 391 const description = &details.children.values()[2].widget.text_box; 392 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"); 393 // some actions put a button where the description would go 394 if (item.button) |detail_button| { 395 try description.setContent(allocator, detail_button.text); 396 description.options.label = detail_button.label; 397 description.getFocus().kind = if (detail_button.link) |link| .{ .custom = link } else .text_box; 398 } else { 399 try description.setContent(allocator, item.description); 400 description.options.label = " description "; 401 description.getFocus().kind = .text_box; 402 } 403 const enabled = self.data.can_undo and item.index > 0; 404 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 405 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 406 if (failure) |message| { 407 try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 408 self.failure_id = details.children.keys()[details.children.count() - 1]; 409 } 410 self.shown_failure = failure; 411 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 412 details.getFocus().child_id = details.children.keys()[0]; 413 self.detailScroll().x = 0; 414 self.detailScroll().y = 0; 415 self.detailScroll().getFocus().version +%= 1; 416 self.detailed_index = index; 417 } 418 419 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 420 self.clearGrid(); 421 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 422 try self.refreshDetail(allocator); 423 const rows = &self.listScroll().child.box; 424 if (self.data.items.len == 0) { 425 const failure = self.session.data.undo_failure orelse self.data.failure; 426 if (!std.meta.eql(self.shown_failure, failure)) { 427 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 428 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 429 self.shown_failure = failure; 430 } 431 } 432 for (rows.children.keys(), rows.children.values()) |id, *child| child.widget.text_box.options.border_style = if (rows.getFocus().child_id == id) .single else .hidden; 433 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 434 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 435 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 436 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 437 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 438 try self.box.build(allocator, constraint, root_focus); 439 } 440 441 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 442 // the sub header sits above whichever content the route selects. 443 // scrolling crosses the boundary the same way the arrow keys do 444 const direction = inp.vertDirection(key); 445 if (self.headerActive()) { 446 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 447 return; 448 } 449 if (self.data.clear) { 450 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 451 return; 452 } 453 if (direction == .up and self.contentAtTop()) { 454 root_focus.setFocus(self.headerBox().getFocus().id); 455 return; 456 } 457 try self.refreshDetail(allocator); 458 if (self.detailActive()) { 459 const details = &self.detailScroll().child.box; 460 if (self.selected()) |index| { 461 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 462 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 463 return; 464 } 465 } 466 if (key == .arrow_left) root_focus.setFocus(self.listScroll().getFocus().id) else if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 467 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 468 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 469 } else switch (key) { 470 .arrow_right, .enter => if (self.selected() != null) { 471 root_focus.setFocus(self.detailScroll().getFocus().id); 472 }, 473 else => {}, 474 } 475 } 476 477 // the confirm button submits the form the web renderer posts 478 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 479 const center = &self.box.children.values()[content_index].widget.center; 480 const button_id = center.child.box.getFocus().child_id orelse return; 481 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 482 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 483 } 484 485 fn contentAtTop(self: *View) bool { 486 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 487 const rows = &scroll.child.box; 488 const id = rows.getFocus().child_id orelse return true; 489 return rows.children.getIndex(id) == 0 and scroll.y == 0; 490 } 491 492 // moving up from the sub header returns to the page's tabs 493 pub fn atTop(self: *View) bool { 494 return self.headerActive(); 495 } 496 497 // arriving from the page's tabs lands on the sub header, not the content 498 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 499 root_focus.setFocus(self.headerBox().getFocus().id); 500 return true; 501 } 502 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 503 self.box.deinit(allocator); 504 } 505 pub fn clearGrid(self: *View) void { 506 self.box.clearGrid(); 507 } 508 pub fn getGrid(self: View) ?Grid { 509 return self.box.getGrid(); 510 } 511 pub fn getFocus(self: *View) *Focus { 512 return self.box.getFocus(); 513 } 514 }; 515