╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
233 const name = ref.name[prefix.len..]; 234 235 // a removed ref has nothing left to link to. a branch that was created 236 // has no range to show either: what it added is whatever the other 237 // refs did not already reach, which a base cannot express 238 const removed = ref.new.len == 0; 239 const created = ref.old.len == 0; 240 const route = if (removed) 241 null 242 else if (head and !created) 243 ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, ref.old) orelse continue 244 else 245 ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; 246 247 const action = if (removed) "removed" else if (created) "created" else "changed"; 248 try buttons.append(aa, .{ 249 .label = try aa.print(" {s} {s} ", .{ if (head) "branch" else "tag", action }), 250 .text = try aa.dupe(u8, name), 251 .link = if (route) |value| try aa.print("a:{s}", .{try value.toUrl(arena)}) else null, 252 }); 253 } 254 } 255 256 // the push record stores the email of the user whose key the server accepted. 257 // resolving it names them as they are now, and leaves the email when no user 258 // holds it, which is also what local mode sees with no admin db to read 259 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 260 const Payload = struct { author: struct { email: []const u8 } }; 261 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 262 error.OutOfMemory => return err, 263 else => return null, 264 }; 265 defer parsed.deinit(); 266 return switch (try ui.Author.initFromEmail(haxy_moment, arena, parsed.value.author.email)) { 267 .unknown => null, 268 .email => |email| .{ .label = " user ", .text = email }, 269 .user_name => |name| .{ .label = " user ", .text = name, .link = try ui.userLink(arena, name) }, 270 }; 271 } 272 273 // how many events the transaction wrote: zero when it consumed nothing, null 274 // when its state cannot be read at all 275 fn momentEventCount( 276 comptime opts: rp.RepoOpts(.xit), 277 core: *rp.Repo(.xit, opts).Core, 278 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 279 history_index: u64, 280 ) !?u64 { 281 const DB = rp.Repo(.xit, opts).DB; 282 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 283 const index = try momentIndex(opts, haxy_moment) orelse return null; 284 285 // a transaction that consumed nothing inherits the moment it was cloned 286 // from, whose events belong to the transaction that wrote it 287 inherited: { 288 if (history_index == 0) break :inherited; 289 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 290 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 291 } 292 293 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 294 return try ids.count(); 295 } 296 297 // the oldest transaction `record` discarded, or null when it is not an undo 298 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 299 const value = record orelse return null; 300 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 301 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 302 error.OutOfMemory => return err, 303 else => return null, 304 }; 305 defer parsed.deinit(); 306 return parsed.value.index; 307 } 308 309 // the moment's own position in the haxy history 310 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 311 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 312 return try cursor.readUint(); 313 } 314 315 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 316 if (source.repo_kind != .xit) return error.NotFound; 317 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 318 defer any_repo.deinit(io, allocator); 319 switch (any_repo) { 320 inline else => |*repo| try repo.undo(io, index), 321 } 322 } 323 324 // discard every earlier transaction by compacting the database. the patch 325 // revisions events point at are unreachable from the refs, so they are named 326 // as extra roots to survive the collection. 327 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 328 if (source.repo_kind != .xit) return error.NotFound; 329 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 330 defer any_repo.deinit(io, allocator); 331 switch (any_repo) { 332 inline else => |*repo| { 333 const opts = repo.self_repo_opts; 334 // a repo that has consumed no events has no revisions to protect 335 const roots = if (evt.currentMoment(opts, repo)) |moment| 336 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 337 else |_| 338 &.{}; 339 defer allocator.free(roots); 340 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 341 }, 342 } 343 } 344 345 // keep action failures on the current tab without rebuilding a stale route. 346 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 347 perform(allocator, session, target) catch |err| { 348 session.data.undo_failure = @errorName(err); 349 return; 350 }; 351 session.data.undo_failure = null; 352 } 353 354 // both terminal hosts resolve and authorize the request afresh before opening 355 // the repository; the web handler uses its matching http authorization path. 356 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 357 const identity = target.name.slice(); 358 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 359 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 360 const io = session.io orelse return error.NotFound; 361 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 362 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 363 } 364 365 // the repo an owner may act on, or null when they may not 366 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 367 if (try session.authorize(identity, .owner, null) == null) return null; 368 if (session.local) |local| return local; 369 const admin_repo = session.admin_repo orelse return error.NotFound; 370 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 371 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 372 const io = session.io orelse return error.NotFound; 373 const users_dir = session.users_dir orelse return error.NotFound; 374 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; 375 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 }; 376 } 377 378 pub const View = struct { 379 box: wgt.Box(ui.Widget), 380 data: *const Self, 381 session: *ui.Session, 382 detailed_index: ?usize = null, 383 shown_failure: ?[]const u8 = null, 384 385 const list_max_width = 20; 386 const detail_min_width = 40; 387 const header_index = 0; 388 const content_index = 1; 389 // the undo button and timestamp stay; everything below them is rebuilt 390 const fixed_rows = 2; 391 392 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 393 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 394 errdefer outer.deinit(allocator); 395 396 // the sub header carries the tab's only action 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 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 402 const link = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}); 403 const clear_label = "clear undo history"; 404 try addText(allocator, header, clear_label, link, .single); 405 // one line wide, so a narrow window scrolls the row instead of wrapping it 406 header.children.values()[0].min_size = .{ .width = clear_label.len + 2, .height = null }; 407 header.getFocus().child_id = header.children.keys()[0]; 408 // a row taller than the header, leaving a blank line beneath it 409 try outer.children.put(allocator, header_scroll.getFocus().id, .{ .widget = .{ .header_scroll = header_scroll }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 410 } 411 412 // the clear form, or the empty message when there is no history or failure to show 413 if (data.clear or data.isEmpty(session)) { 414 var center = if (data.clear) try initClearForm(allocator, data, session) else try ui.widget.initEmpty(allocator); 415 errdefer center.deinit(allocator); 416 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 417 // the empty message has nothing to focus, so focus rests on the sub-header 418 outer.getFocus().child_id = outer.children.keys()[if (data.clear) content_index else header_index]; 419 return .{ .box = outer, .data = data, .session = session }; 420 } 421 422 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 423 errdefer box.deinit(allocator); 424 { 425 var scroll = blk: { 426 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert, .stretch = true }); 427 errdefer rows.deinit(allocator); 428 for (data.items) |item| { 429 try addText(allocator, &rows, item.action, item.link, .hidden); 430 // a transaction a later undo discarded says so on its border 431 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label.text = "(undone)"; 432 } 433 if (data.next) |next| { 434 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 435 try addText(allocator, &rows, "next →", try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 436 } 437 // the failure row, filled in by build 438 if (data.items.len == 0) try addText(allocator, &rows, "", null, .hidden); 439 rows.getFocus().child_id = rows.children.keys()[0]; 440 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 441 }; 442 errdefer scroll.deinit(allocator); 443 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 } }); 444 } 445 // the detail scroll, below the way back to a hidden list 446 { 447 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 448 errdefer frame.deinit(allocator); 449 try ui.widget.addListBack(allocator, &frame, "← back to undo list"); 450 var scroll = blk: { 451 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 452 errdefer details.deinit(allocator); 453 if (data.items.len > 0) { 454 try addText(allocator, &details, "", null, .single); 455 try addText(allocator, &details, "", null, .single); 456 details.getFocus().child_id = details.children.keys()[0]; 457 } 458 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 459 }; 460 errdefer scroll.deinit(allocator); 461 scroll.getFocus().mode = .mouse; 462 frame.getFocus().child_id = scroll.getFocus().id; 463 try frame.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 464 try box.children.put(allocator, frame.getFocus().id, .{ .widget = .{ .box = frame }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 465 } 466 box.getFocus().child_id = box.children.keys()[if (data.items.len > 0) 1 else 0]; 467 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 468 outer.getFocus().child_id = outer.children.keys()[content_index]; 469 return .{ .box = outer, .data = data, .session = session }; 470 } 471 472 // the same shape the thread views use to confirm a removal 473 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 474 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .round_corners = true, .direction = .vert }); 475 errdefer box.deinit(allocator); 476 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 477 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 478 479 var prompt = try wgt.Text.init(allocator, "are you sure?"); 480 errdefer prompt.deinit(allocator); 481 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 482 483 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 484 errdefer button.deinit(allocator); 485 button.getFocus().mode = .all; 486 button.getFocus().kind = if (data.handle.canUndo()) .{ .custom = "submit" } else .text_box; 487 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 488 box.getFocus().child_id = button.getFocus().id; 489 490 return ui.widget.Center.init(allocator, .{ .box = box }); 491 } 492 493 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.Border) !void { 494 var row = try wgt.TextBox.init(allocator, text, .{ .border = border, .round_corners = true, .wrap_kind = .word }); 495 errdefer row.deinit(allocator); 496 row.getFocus().mode = .all; 497 if (link) |value| row.getFocus().kind = .{ .custom = value }; 498 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 499 } 500 501 // a labeled box, added as the selected action needs it 502 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 503 try addText(allocator, box, text, link, .single); 504 box.children.values()[box.children.count() - 1].widget.text_box.options.top_label.text = label; 505 } 506 507 // the terminal hosts run the action after rendering; the web renderer 508 // posts the form instead, and wasm has no repository to act on 509 fn requestUndo(self: *View, route: ui.RoutablePage) void { 510 if (builtin.target.cpu.arch == .wasm32) return; 511 if (!self.session.is_terminal or self.session.host_request != null) return; 512 self.session.host_request = .{ .undo = route.repo_undo }; 513 } 514 515 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 516 return self.box.children.values()[header_index].widget.header_scroll.row(); 517 } 518 fn headerActive(self: *View) bool { 519 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 520 } 521 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 522 return &self.box.children.values()[content_index].widget.box; 523 } 524 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 525 return &self.contentBox().children.values()[0].widget.scroll; 526 } 527 fn detailFrame(self: *View) *wgt.Box(ui.Widget) { 528 return &self.contentBox().children.values()[1].widget.box; 529 } 530 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 531 return &self.detailFrame().children.values()[ui.widget.list_pane_index].widget.scroll; 532 } 533 fn selected(self: *View) ?usize { 534 const rows = &self.listScroll().child.box; 535 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 536 return if (index < self.data.items.len) index else null; 537 } 538 fn detailActive(self: *View) bool { 539 return self.contentBox().getFocus().child_id == self.detailFrame().getFocus().id; 540 } 541 542 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 543 const index = self.selected() orelse return; 544 const failure = self.session.data.undo_failure orelse self.data.failure; 545 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 546 const details = &self.detailScroll().child.box; 547 while (details.children.count() > fixed_rows) { 548 var entry = details.children.pop() orelse break; 549 entry.value.widget.deinit(allocator); 550 } 551 const item = self.data.items[index]; 552 const enabled = self.data.handle.canUndo() and item.index > 0; 553 554 // the fixed rows are filled first, since adding the ones below 555 // invalidates every pointer into the box 556 { 557 const button = &details.children.values()[0].widget.text_box; 558 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"); 559 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 560 } 561 const timestamp = try ui.timestamp.format(self.session.page_arena.allocator(), item.timestamp, try self.session.nowSeconds()); 562 try details.children.values()[1].widget.text_box.setContent(allocator, timestamp.relative); 563 details.children.values()[1].widget.text_box.options.bottom_label.text = timestamp.exact; 564 565 // some actions put buttons where the description would go 566 if (item.buttons.len == 0) { 567 try addLabeled(allocator, details, " description ", item.description, null); 568 } else for (item.buttons) |detail_button| { 569 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 570 } 571 if (failure) |message| try addText(allocator, details, try self.session.page_arena.allocator().print("error: {s}", .{message}), null, .single); 572 self.shown_failure = failure; 573 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 574 details.getFocus().child_id = details.children.keys()[0]; 575 self.detailScroll().x = 0; 576 self.detailScroll().y = 0; 577 self.detailScroll().getFocus().version +%= 1; 578 ui.widget.setListBackLink(self.detailFrame(), item.link); 579 self.detailed_index = index; 580 } 581 582 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 583 self.clearGrid(); 584 // the clear form or the empty message stands in for the list 585 if (self.data.clear or self.data.isEmpty(self.session)) return try self.box.build(allocator, constraint, root_focus); 586 try self.refreshDetail(allocator); 587 const rows = &self.listScroll().child.box; 588 if (self.data.items.len == 0) { 589 // an empty list only shows to report its failure 590 const failure = self.session.data.undo_failure orelse self.data.failure orelse unreachable; 591 if (!std.meta.eql(self.shown_failure, failure)) { 592 try rows.children.values()[0].widget.text_box.setContent(allocator, try self.session.page_arena.allocator().print("error: {s}", .{failure})); 593 self.shown_failure = failure; 594 } 595 } 596 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 597 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 598 ui.widget.showListBack(self.detailFrame(), !both_fit); 599 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 600 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 601 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 602 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 603 try self.box.build(allocator, constraint, root_focus); 604 } 605 606 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 607 // the sub header sits above whichever content the route selects. 608 // scrolling crosses the boundary the same way the arrow keys do 609 const direction = inp.vertDirection(key); 610 if (self.headerActive()) { 611 // the empty message has nothing to focus 612 if (direction == .down and !self.data.isEmpty(self.session)) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 613 return; 614 } 615 if (self.data.clear) { 616 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 617 return; 618 } 619 // the way back to the list, by enter or a click 620 if (self.detailActive()) if (ui.widget.listBackId(self.detailFrame())) |id| { 621 if (inp.activated(root_focus, id, key)) return self.focusList(root_focus); 622 if (root_focus.grandchild_id == id) return switch (direction) { 623 .up => root_focus.setFocus(self.headerBox().getFocus().id), 624 .down => root_focus.setFocus(self.detailScroll().getFocus().id), 625 .none => if (key == .arrow_left) self.focusList(root_focus), 626 }; 627 if (direction == .up and self.contentAtTop()) return root_focus.setFocus(id); 628 }; 629 if (direction == .up and self.contentAtTop()) { 630 root_focus.setFocus(self.headerBox().getFocus().id); 631 return; 632 } 633 try self.refreshDetail(allocator); 634 if (self.detailActive()) { 635 const details = &self.detailScroll().child.box; 636 if (self.selected()) |index| { 637 if (inp.activated(root_focus, details.children.keys()[0], key) and self.data.handle.canUndo() and self.data.items[index].index > 0) { 638 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 639 return; 640 } 641 } 642 if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| { 643 ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 644 } else if (key == .arrow_left) { 645 self.focusList(root_focus); 646 } 647 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 648 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 649 } else switch (key) { 650 .arrow_right, .enter => if (self.selected() != null) self.focusDetail(root_focus), 651 // when the window is too narrow to lay out the detail pane beside 652 // the list, a click on a row opens it like enter. the detail was 653 // swapped to the just-selected row above. 654 .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)) { 655 self.focusDetail(root_focus); 656 }, 657 else => {}, 658 } 659 } 660 661 // enter the detail pane, landing on the way back to the list when it shows 662 fn focusDetail(self: *View, root_focus: *Focus) void { 663 if (ui.widget.listBackId(self.detailFrame()) != null) return ui.widget.focusListBack(self.detailFrame(), root_focus); 664 root_focus.setFocus(self.detailScroll().getFocus().id); 665 } 666 667 fn focusList(self: *View, root_focus: *Focus) void { 668 root_focus.setFocus(self.listScroll().getFocus().id); 669 } 670 671 // the confirm button submits the form the web renderer posts 672 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 673 const center = &self.box.children.values()[content_index].widget.center; 674 const button_id = center.child.box.getFocus().child_id orelse return; 675 if (!inp.activated(root_focus, button_id, key) or !self.data.handle.canUndo()) return; 676 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 677 } 678 679 fn contentAtTop(self: *View) bool { 680 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 681 const rows = &scroll.child.box; 682 const id = rows.getFocus().child_id orelse return true; 683 return rows.children.getIndex(id) == 0 and scroll.y == 0; 684 } 685 686 // moving up from the sub header returns to the page's tabs 687 pub fn atTop(self: *View) bool { 688 return self.headerActive(); 689 } 690 691 // arriving from the page's tabs lands on the sub header, not the content 692 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 693 root_focus.setFocus(self.headerBox().getFocus().id); 694 return true; 695 } 696 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 697 self.box.deinit(allocator); 698 } 699 pub fn clearGrid(self: *View) void { 700 self.box.clearGrid(); 701 } 702 pub fn getGrid(self: View) ?Grid { 703 return self.box.getGrid(); 704 } 705 pub fn getFocus(self: *View) *Focus { 706 return self.box.getFocus(); 707 } 708 }; 709