╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭ find file ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ ╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │ │ │http│ ssh │ │ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
168 const value = record orelse return "no undo record"; 169 var description: std.Io.Writer.Allocating = .init(arena.allocator()); 170 try xit.undo.format(opts, core, arena.child_allocator, value, &description.writer); 171 return description.written(); 172 } 173 174 // the transaction an undo restored, as a button that reads like its own row. 175 // the error set is explicit because this and `eventDetail` call each other 176 fn undoneButton( 177 comptime opts: rp.RepoOpts(.xit), 178 arena: *std.heap.ArenaAllocator, 179 core: *rp.Repo(.xit, opts).Core, 180 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 181 identity: []const u8, 182 undo: xit.undo.Undo, 183 ) anyerror!DetailButton { 184 var buffer: [opts.max_read_size]u8 = undefined; 185 const target = try xit.undo.undoneTarget(opts, core, arena.child_allocator, undo, &buffer); 186 187 // the detail reads the event count out of the transaction's own moment 188 const moment = try core.momentAt(target.index); 189 const detail = if (target.record) |value| try eventDetail(opts, arena, core, haxy_moment, identity, value, moment, target.index) else null; 190 const route = ui.RoutablePage.repoUndoRoute(identity, target.index) orelse return error.RouteTooLong; 191 return .{ 192 .label = if (target.redo) " what was redone " else " what was undone ", 193 .text = if (detail) |value| value.action else try formatRecord(opts, arena, core, target.record), 194 .link = try arena.allocator().print("a:{s}", .{try route.toUrl(arena)}), 195 }; 196 } 197 198 // the page reads the transaction's own moment, so the url names the 199 // transaction rather than the state it produced 200 fn eventsButton(arena: *std.heap.ArenaAllocator, identity: []const u8, history_index: u64, count: u64) !DetailButton { 201 const aa = arena.allocator(); 202 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", history_index) orelse return error.RouteTooLong; 203 return .{ 204 .label = " events ", 205 .text = try aa.print("view events ({d})", .{count}), 206 .link = try aa.print("a:{s}", .{try route.toUrl(arena)}), 207 }; 208 } 209 210 // a button per ref the push moved, naming what it did to it: a changed branch 211 // links to the commits it added, anything still there to the ref itself 212 fn pushRefs(arena: *std.heap.ArenaAllocator, identity: []const u8, payload: []const u8, buttons: *std.ArrayList(DetailButton)) !void { 213 const head_prefix = "refs/heads/"; 214 const tag_prefix = "refs/tags/"; 215 const Payload = struct { 216 const Ref = struct { name: []const u8, old: []const u8, new: []const u8 }; 217 refs: []const Ref = &.{}, 218 }; 219 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 220 error.OutOfMemory => return err, 221 else => return, 222 }; 223 defer parsed.deinit(); 224 225 const aa = arena.allocator(); 226 for (parsed.value.refs) |ref| { 227 // anything that is neither a head nor a tag is skipped 228 const head = std.mem.startsWith(u8, ref.name, head_prefix); 229 const prefix = if (head) head_prefix else tag_prefix; 230 if (!std.mem.startsWith(u8, ref.name, prefix)) continue; 231 const name = ref.name[prefix.len..]; 232 233 // a removed ref has nothing left to link to. a branch that was created 234 // has no range to show either: what it added is whatever the other 235 // refs did not already reach, which a base cannot express 236 const removed = ref.new.len == 0; 237 const created = ref.old.len == 0; 238 const route = if (removed) 239 null 240 else if (head and !created) 241 ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, ref.old) orelse continue 242 else 243 ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; 244 245 const action = if (removed) "removed" else if (created) "created" else "changed"; 246 try buttons.append(aa, .{ 247 .label = try aa.print(" {s} {s} ", .{ if (head) "branch" else "tag", action }), 248 .text = try aa.dupe(u8, name), 249 .link = if (route) |value| try aa.print("a:{s}", .{try value.toUrl(arena)}) else null, 250 }); 251 } 252 } 253 254 // the push record stores the email of the user whose key the server accepted. 255 // resolving it names them as they are now, and leaves the email when no user 256 // holds it 257 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 258 const Payload = struct { author: struct { email: []const u8 } }; 259 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 260 error.OutOfMemory => return err, 261 else => return null, 262 }; 263 defer parsed.deinit(); 264 return switch (try ui.Author.initFromEmail(haxy_moment, arena, parsed.value.author.email)) { 265 .unknown => null, 266 .email => |email| .{ .label = " user ", .text = email }, 267 .user_name => |name| .{ .label = " user ", .text = name, .link = try ui.userLink(arena, name) }, 268 }; 269 } 270 271 // how many events the transaction wrote: zero when it consumed nothing, null 272 // when its state cannot be read at all 273 fn momentEventCount( 274 comptime opts: rp.RepoOpts(.xit), 275 core: *rp.Repo(.xit, opts).Core, 276 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 277 history_index: u64, 278 ) !?u64 { 279 const DB = rp.Repo(.xit, opts).DB; 280 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 281 const index = try momentIndex(opts, haxy_moment) orelse return null; 282 283 // a transaction that consumed nothing inherits the moment it was cloned 284 // from, whose events belong to the transaction that wrote it 285 inherited: { 286 if (history_index == 0) break :inherited; 287 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 288 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 289 } 290 291 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 292 return try ids.count(); 293 } 294 295 // the oldest transaction `record` discarded, or null when it is not an undo 296 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 297 const value = record orelse return null; 298 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 299 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 300 error.OutOfMemory => return err, 301 else => return null, 302 }; 303 defer parsed.deinit(); 304 return parsed.value.index; 305 } 306 307 // the moment's own position in the haxy history 308 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 309 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 310 return try cursor.readUint(); 311 } 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