╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
184 // the detail reads the event count out of the transaction's own moment 185 const moment = try core.momentAt(target.index); 186 const detail = if (target.record) |value| try eventDetail(opts, arena, core, haxy_moment, identity, value, moment, target.index) else null; 187 const route = ui.RoutablePage.repoUndoRoute(identity, target.index) orelse return error.RouteTooLong; 188 return .{ 189 .label = if (target.redo) " what was redone " else " what was undone ", 190 .text = if (detail) |value| value.action else try formatRecord(opts, arena, core, target.record), 191 .link = try std.fmt.allocPrint(arena.allocator(), "a:{s}", .{try route.toUrl(arena)}), 192 }; 193 } 194 195 // the page reads the transaction's own moment, so the url names the 196 // transaction rather than the state it produced 197 fn eventsButton(arena: *std.heap.ArenaAllocator, identity: []const u8, history_index: u64, count: u64) !DetailButton { 198 const aa = arena.allocator(); 199 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", history_index) orelse return error.RouteTooLong; 200 return .{ 201 .label = " events ", 202 .text = try std.fmt.allocPrint(aa, "view events ({d})", .{count}), 203 .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(arena)}), 204 }; 205 } 206 207 // a button per ref the push moved, naming what it did to it: a changed branch 208 // links to the commits it added, anything still there to the ref itself 209 fn pushRefs(arena: *std.heap.ArenaAllocator, identity: []const u8, payload: []const u8, buttons: *std.ArrayList(DetailButton)) !void { 210 const head_prefix = "refs/heads/"; 211 const tag_prefix = "refs/tags/"; 212 const Payload = struct { 213 const Ref = struct { name: []const u8, old: []const u8, new: []const u8 }; 214 refs: []const Ref = &.{}, 215 }; 216 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 217 error.OutOfMemory => return err, 218 else => return, 219 }; 220 defer parsed.deinit(); 221 222 const aa = arena.allocator(); 223 for (parsed.value.refs) |ref| { 224 // anything that is neither a head nor a tag is skipped 225 const head = std.mem.startsWith(u8, ref.name, head_prefix); 226 const prefix = if (head) head_prefix else tag_prefix; 227 if (!std.mem.startsWith(u8, ref.name, prefix)) continue; 228 const name = ref.name[prefix.len..]; 229 230 // a removed ref has nothing left to link to. a branch that was created 231 // has no range to show either: what it added is whatever the other 232 // refs did not already reach, which a base cannot express 233 const removed = ref.new.len == 0; 234 const created = ref.old.len == 0; 235 const route = if (removed) 236 null 237 else if (head and !created) 238 ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, 0, "", ref.old) orelse continue 239 else 240 ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; 241 242 const action = if (removed) "removed" else if (created) "created" else "changed"; 243 try buttons.append(aa, .{ 244 .label = try std.fmt.allocPrint(aa, " {s} {s} ", .{ if (head) "branch" else "tag", action }), 245 .text = try aa.dupe(u8, name), 246 .link = if (route) |value| try std.fmt.allocPrint(aa, "a:{s}", .{try value.toUrl(arena)}) else null, 247 }); 248 } 249 } 250 251 // the push record stores the email of the user whose key the server accepted. 252 // resolving it names them as they are now, and leaves the email when no user 253 // holds it, which is also what local mode sees with no admin db to read 254 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 255 const Payload = struct { author: ?struct { email: []const u8 } = null }; 256 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 257 error.OutOfMemory => return err, 258 else => return null, 259 }; 260 defer parsed.deinit(); 261 const author = parsed.value.author orelse return null; 262 return switch (try ui.Author.initFromEmail(haxy_moment, arena, author.email)) { 263 .unknown => null, 264 .email => |email| .{ .label = " user ", .text = email }, 265 .user_name => |name| .{ .label = " user ", .text = name, .link = try ui.userLink(arena, name) }, 266 }; 267 } 268 269 // how many events the transaction wrote: zero when it consumed nothing, null 270 // when its state cannot be read at all 271 fn momentEventCount( 272 comptime opts: rp.RepoOpts(.xit), 273 core: *rp.Repo(.xit, opts).Core, 274 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 275 history_index: u64, 276 ) !?u64 { 277 const DB = rp.Repo(.xit, opts).DB; 278 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 279 const index = try momentIndex(opts, haxy_moment) orelse return null; 280 281 // a transaction that consumed nothing inherits the moment it was cloned 282 // from, whose events belong to the transaction that wrote it 283 inherited: { 284 if (history_index == 0) break :inherited; 285 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 286 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 287 } 288 289 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 290 return try ids.count(); 291 } 292 293 // the oldest transaction `record` discarded, or null when it is not an undo 294 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 295 const value = record orelse return null; 296 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 297 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 298 error.OutOfMemory => return err, 299 else => return null, 300 }; 301 defer parsed.deinit(); 302 return parsed.value.index; 303 } 304 305 // the moment's own position in the haxy history 306 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 307 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 308 return try cursor.readUint(); 309 } 310 311 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 312 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 313 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 314 const year = seconds.getEpochDay().calculateYearDay(); 315 const month = year.calculateMonthDay(); 316 const day = seconds.getDaySeconds(); 317 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 318 return std.fmt.allocPrint(aa, "{s} {d}, {d}, {d:0>2}:{d:0>2}:{d:0>2} UTC", .{ months[month.month.numeric() - 1], month.day_index + 1, year.year, day.getHoursIntoDay(), day.getMinutesIntoHour(), day.getSecondsIntoMinute() }); 319 } 320 321 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 322 if (source.repo_kind != .xit) return error.NotFound; 323 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 324 defer any_repo.deinit(io, allocator); 325 switch (any_repo) { 326 inline else => |*repo| try repo.undo(io, index), 327 } 328 } 329 330 // discard every earlier transaction by compacting the database. the patch 331 // revisions events point at are unreachable from the refs, so they are named 332 // as extra roots to survive the collection. 333 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 334 if (source.repo_kind != .xit) return error.NotFound; 335 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 336 defer any_repo.deinit(io, allocator); 337 switch (any_repo) { 338 inline else => |*repo| { 339 const opts = repo.self_repo_opts; 340 // a repo that has consumed no events has no revisions to protect 341 const roots = if (evt.currentMoment(opts, repo)) |moment| 342 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 343 else |_| 344 &.{}; 345 defer allocator.free(roots); 346 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 347 }, 348 } 349 } 350 351 // keep action failures on the current tab without rebuilding a stale route. 352 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 353 perform(allocator, session, target) catch |err| { 354 session.data.undo_failure = @errorName(err); 355 return; 356 }; 357 session.data.undo_failure = null; 358 } 359 360 // both terminal hosts resolve and authorize the request afresh before opening 361 // the repository; the web handler uses its matching http authorization path. 362 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 363 const identity = target.name.slice(); 364 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 365 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 366 const io = session.io orelse return error.NotFound; 367 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 368 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 369 } 370 371 // the repo an owner may act on, or null when they may not 372 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 373 if (try session.authorize(identity, .owner) == null) return null; 374 if (session.local) |local| return local; 375 const admin_repo = session.admin_repo orelse return error.NotFound; 376 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 377 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 378 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; 379 const hex = std.fmt.bytesToHex(found.event_id, .lower); 380 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 381 } 382 383 pub const View = struct { 384 box: wgt.Box(ui.Widget), 385 data: *const Self, 386 session: *ui.Session, 387 detailed_index: ?usize = null, 388 shown_failure: ?[]const u8 = null, 389 390 const list_max_width = 20; 391 const detail_min_width = 40; 392 const header_index = 0; 393 const content_index = 1; 394 // the undo button and the timestamp; everything below them is rebuilt 395 const fixed_rows = 2; 396 397 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 398 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 399 errdefer outer.deinit(allocator); 400 401 // the sub header carries the tab's only action 402 { 403 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 404 errdefer header.deinit(allocator); 405 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 406 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 407 try addText(allocator, &header, "clear undo history", link, .single); 408 header.getFocus().child_id = header.children.keys()[0]; 409 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 410 } 411 412 if (data.clear) { 413 var center = try initClearForm(allocator, data, session); 414 errdefer center.deinit(allocator); 415 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 416 outer.getFocus().child_id = outer.children.keys()[content_index]; 417 return .{ .box = outer, .data = data, .session = session }; 418 } 419 420 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 421 errdefer box.deinit(allocator); 422 { 423 var scroll = blk: { 424 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 425 errdefer rows.deinit(allocator); 426 for (data.items) |item| { 427 try addText(allocator, &rows, item.action, item.link, .hidden); 428 // a transaction a later undo discarded says so on its border 429 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 430 } 431 if (data.next) |next| { 432 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 433 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 434 } 435 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 436 rows.getFocus().child_id = rows.children.keys()[0]; 437 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 438 }; 439 errdefer scroll.deinit(allocator); 440 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 } }); 441 } 442 { 443 var scroll = blk: { 444 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 445 errdefer details.deinit(allocator); 446 if (data.items.len > 0) { 447 try addText(allocator, &details, "", null, .single); 448 try addLabeled(allocator, &details, " timestamp ", "", null); 449 details.getFocus().child_id = details.children.keys()[0]; 450 } 451 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 452 }; 453 errdefer scroll.deinit(allocator); 454 scroll.getFocus().mode = .mouse; 455 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 456 } 457 box.getFocus().child_id = box.children.keys()[0]; 458 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 459 outer.getFocus().child_id = outer.children.keys()[content_index]; 460 return .{ .box = outer, .data = data, .session = session }; 461 } 462 463 // the same shape the thread views use to confirm a removal 464 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 465 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 466 errdefer box.deinit(allocator); 467 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 468 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 469 470 var prompt = try wgt.Text.init(allocator, "are you sure?"); 471 errdefer prompt.deinit(allocator); 472 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 473 474 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 475 errdefer button.deinit(allocator); 476 button.getFocus().mode = .all; 477 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 478 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 479 box.getFocus().child_id = button.getFocus().id; 480 481 return ui.widget.Center.init(allocator, .{ .box = box }); 482 } 483 484 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 485 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 486 errdefer row.deinit(allocator); 487 row.getFocus().mode = .all; 488 if (link) |value| row.getFocus().kind = .{ .custom = value }; 489 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 490 } 491 492 // a labeled row below the timestamp, added as the selected action needs it 493 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 494 try addText(allocator, box, text, link, .single); 495 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 496 } 497 498 // enter counts only on the focused button; a click counts anywhere on it 499 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 500 return switch (key) { 501 .enter => root_focus.grandchild_id == button_id, 502 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 503 else => false, 504 }; 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.box; 517 } 518 fn headerActive(self: *View) bool { 519 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 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 detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 528 return &self.contentBox().children.values()[1].widget.scroll; 529 } 530 fn selected(self: *View) ?usize { 531 const rows = &self.listScroll().child.box; 532 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 533 return if (index < self.data.items.len) index else null; 534 } 535 fn detailActive(self: *View) bool { 536 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 537 } 538 539 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 540 const index = self.selected() orelse return; 541 const failure = self.session.data.undo_failure orelse self.data.failure; 542 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 543 const details = &self.detailScroll().child.box; 544 while (details.children.count() > fixed_rows) { 545 var entry = details.children.pop() orelse break; 546 entry.value.widget.deinit(allocator); 547 } 548 const item = self.data.items[index]; 549 const enabled = self.data.can_undo and item.index > 0; 550 551 // the fixed rows are filled first, since adding the ones below 552 // invalidates every pointer into the box 553 { 554 const button = &details.children.values()[0].widget.text_box; 555 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"); 556 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 557 } 558 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 559 560 // some actions put buttons where the description would go 561 if (item.buttons.len == 0) { 562 try addLabeled(allocator, details, " description ", item.description, null); 563 } else for (item.buttons) |detail_button| { 564 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 565 } 566 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 567 self.shown_failure = failure; 568 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 569 details.getFocus().child_id = details.children.keys()[0]; 570 self.detailScroll().x = 0; 571 self.detailScroll().y = 0; 572 self.detailScroll().getFocus().version +%= 1; 573 self.detailed_index = index; 574 } 575 576 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 577 self.clearGrid(); 578 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 579 try self.refreshDetail(allocator); 580 const rows = &self.listScroll().child.box; 581 if (self.data.items.len == 0) { 582 const failure = self.session.data.undo_failure orelse self.data.failure; 583 if (!std.meta.eql(self.shown_failure, failure)) { 584 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 585 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 586 self.shown_failure = failure; 587 } 588 } 589 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 590 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 591 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 592 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 593 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 594 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 595 try self.box.build(allocator, constraint, root_focus); 596 } 597 598 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 599 // the sub header sits above whichever content the route selects. 600 // scrolling crosses the boundary the same way the arrow keys do 601 const direction = inp.vertDirection(key); 602 if (self.headerActive()) { 603 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 604 return; 605 } 606 if (self.data.clear) { 607 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 608 return; 609 } 610 if (direction == .up and self.contentAtTop()) { 611 root_focus.setFocus(self.headerBox().getFocus().id); 612 return; 613 } 614 try self.refreshDetail(allocator); 615 if (self.detailActive()) { 616 const details = &self.detailScroll().child.box; 617 if (self.selected()) |index| { 618 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 619 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 620 return; 621 } 622 } 623 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); 624 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 625 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 626 } else switch (key) { 627 .arrow_right, .enter => if (self.selected() != null) { 628 root_focus.setFocus(self.detailScroll().getFocus().id); 629 }, 630 else => {}, 631 } 632 } 633 634 // the confirm button submits the form the web renderer posts 635 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 636 const center = &self.box.children.values()[content_index].widget.center; 637 const button_id = center.child.box.getFocus().child_id orelse return; 638 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 639 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 640 } 641 642 fn contentAtTop(self: *View) bool { 643 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 644 const rows = &scroll.child.box; 645 const id = rows.getFocus().child_id orelse return true; 646 return rows.children.getIndex(id) == 0 and scroll.y == 0; 647 } 648 649 // moving up from the sub header returns to the page's tabs 650 pub fn atTop(self: *View) bool { 651 return self.headerActive(); 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 root_focus.setFocus(self.headerBox().getFocus().id); 657 return true; 658 } 659 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 660 self.box.deinit(allocator); 661 } 662 pub fn clearGrid(self: *View) void { 663 self.box.clearGrid(); 664 } 665 pub fn getGrid(self: View) ?Grid { 666 return self.box.getGrid(); 667 } 668 pub fn getFocus(self: *View) *Focus { 669 return self.box.getFocus(); 670 } 671 }; 672