╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const ui = @import("../../ui.zig"); 4 const evt = @import("../../event.zig"); 5 const inp = @import("../input.zig"); 6 const xit = @import("xit"); 7 const rp = xit.repo; 8 const hash = xit.hash; 9 const wgt = xit.xitui.widget; 10 const layout = xit.xitui.layout; 11 const Key = xit.xitui.input.Key; 12 const Grid = xit.xitui.grid.Grid; 13 const Focus = xit.xitui.focus.Focus; 14 const Self = @This(); 15 16 pub const page_size = 20; 17 18 // replaces the description for a transaction that wrote events 19 pub const EventsLink = struct { 20 label: []const u8, 21 link: []const u8, 22 }; 23 24 pub const Item = struct { 25 index: u64, 26 action: []const u8, 27 description: []const u8, 28 timestamp: []const u8, 29 link: []const u8, 30 form: []const u8, 31 events: ?EventsLink = null, 32 }; 33 34 identity: []const u8, 35 count: u64 = 0, 36 items: []const Item = &.{}, 37 next: ?u64 = null, 38 failure: ?[]const u8 = null, 39 can_undo: bool = false, 40 // the clear-history confirmation stands in for the list 41 clear: bool = false, 42 43 pub fn init(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, repo: *rp.Repo(.xit, opts), identity: []const u8, selected: ?u64) !Self { 44 const aa = arena.allocator(); 45 const DB = rp.Repo(.xit, opts).DB; 46 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 47 const count = try history.count(); 48 if (selected) |index| if (index >= count) return error.NotFound; 49 var items: std.ArrayList(Item) = .empty; 50 var remaining = if (selected) |index| index + 1 else count; 51 var buffer: [opts.max_read_size]u8 = undefined; 52 while (remaining > 0 and items.items.len < page_size) { 53 remaining -= 1; 54 const cursor = try history.getCursor(remaining) orelse return error.TransactionNotFound; 55 const moment = try DB.HashMap(.read_only).init(cursor); 56 const record = try xit.undo.read(opts, moment, &buffer); 57 var description: std.Io.Writer.Allocating = .init(aa); 58 const detail = if (record) |value| try eventDetail(opts, arena, identity, value, moment) else null; 59 if (detail) |value| { 60 try description.writer.writeAll(value.description); 61 } else if (record) |value| { 62 try xit.undo.format(opts, &repo.core.db, arena.child_allocator, value, &description.writer); 63 } else { 64 try description.writer.writeAll("no undo record"); 65 } 66 const route = ui.RoutablePage.repoUndoRoute(identity, remaining) orelse return error.RouteTooLong; 67 const url = try route.toUrl(arena); 68 try items.append(aa, .{ 69 .index = remaining, 70 .action = if (detail) |value| value.action else if (record) |value| try aa.dupe(u8, value.action) else "unknown", 71 .description = description.written(), 72 .events = if (detail) |value| value.events else null, 73 .timestamp = if (record) |value| try formatTimestamp(aa, value.timestamp) else "timestamp unavailable", 74 .link = try std.fmt.allocPrint(aa, "ai:{s}", .{url}), 75 .form = try std.fmt.allocPrint(aa, "form:{s}/undo", .{url}), 76 }); 77 } 78 return .{ .identity = try aa.dupe(u8, identity), .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; 79 } 80 81 const Detail = struct { 82 action: []const u8, 83 description: []const u8, 84 events: ?EventsLink = null, 85 }; 86 87 // every haxy event transaction shares one action, and the events it wrote are 88 // named by the moment's index rather than by the record 89 fn eventDetail( 90 comptime opts: rp.RepoOpts(.xit), 91 arena: *std.heap.ArenaAllocator, 92 identity: []const u8, 93 record: xit.undo.UndoRecord, 94 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 95 ) !?Detail { 96 if (std.mem.eql(u8, record.action, evt.merge_undo_action)) return .{ .action = "merge events", .description = "merged the events ref from a remote" }; 97 if (!std.mem.eql(u8, record.action, evt.undo_action)) return null; 98 99 const description = "updated the event database"; 100 const batch = try momentBatch(opts, moment) orelse return .{ .action = "events", .description = description }; 101 102 const aa = arena.allocator(); 103 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", batch.index) orelse return error.RouteTooLong; 104 return .{ 105 .action = try std.fmt.allocPrint(aa, "events ({d})", .{batch.count}), 106 .description = description, 107 .events = .{ 108 .label = try std.fmt.allocPrint(aa, "view events ({d})", .{batch.count}), 109 .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(arena)}), 110 }, 111 }; 112 } 113 114 const Batch = struct { index: u64, count: u64 }; 115 116 // the events the transaction's moment wrote, or null when it wrote none 117 fn momentBatch(comptime opts: rp.RepoOpts(.xit), moment: rp.Repo(.xit, opts).DB.HashMap(.read_only)) !?Batch { 118 const DB = rp.Repo(.xit, opts).DB; 119 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 120 const index_cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 121 const moment_index = try index_cursor.readUint(); 122 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, moment_index) orelse return null; 123 return .{ .index = moment_index, .count = try ids.count() }; 124 } 125 126 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 127 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 128 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 129 const year = seconds.getEpochDay().calculateYearDay(); 130 const month = year.calculateMonthDay(); 131 const day = seconds.getDaySeconds(); 132 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 133 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() }); 134 } 135 136 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 137 if (source.repo_kind != .xit) return error.NotFound; 138 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 139 defer any_repo.deinit(io, allocator); 140 switch (any_repo) { 141 inline else => |*repo| try repo.undo(io, index), 142 } 143 } 144 145 // discard every earlier transaction by compacting the database. the patch 146 // revisions events point at are unreachable from the refs, so they are named 147 // as extra roots to survive the collection. 148 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 149 if (source.repo_kind != .xit) return error.NotFound; 150 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 151 defer any_repo.deinit(io, allocator); 152 switch (any_repo) { 153 inline else => |*repo| { 154 const opts = repo.self_repo_opts; 155 // a repo that has consumed no events has no revisions to protect 156 const roots = if (evt.currentMoment(opts, repo)) |moment| 157 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 158 else |_| 159 &.{}; 160 defer allocator.free(roots); 161 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 162 }, 163 } 164 } 165 166 // keep action failures on the current tab without rebuilding a stale route. 167 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 168 perform(allocator, session, target) catch |err| { 169 session.data.undo_failure = @errorName(err); 170 return; 171 }; 172 session.data.undo_failure = null; 173 } 174 175 // both terminal hosts resolve and authorize the request afresh before opening 176 // the repository; the web handler uses its matching http authorization path. 177 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 178 const identity = target.name.slice(); 179 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 180 const io = session.io orelse return error.NotFound; 181 if (target.clear) 182 try clearHistory(io, allocator, source) 183 else 184 try execute(io, allocator, source, target.index orelse return error.InvalidHistoryIndex); 185 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 186 } 187 188 // the repo an owner may act on, or null when they may not 189 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 190 if (try session.authorize(identity, .owner) == null) return null; 191 if (session.local) |local| return local; 192 const admin_repo = session.admin_repo orelse return error.NotFound; 193 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 194 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 195 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; 196 const hex = std.fmt.bytesToHex(found.event_id, .lower); 197 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 198 } 199 200 pub const View = struct { 201 box: wgt.Box(ui.Widget), 202 data: *const Self, 203 session: *ui.Session, 204 detailed_index: ?usize = null, 205 failure_id: ?usize = null, 206 shown_failure: ?[]const u8 = null, 207 208 const list_max_width = 20; 209 const detail_min_width = 40; 210 const header_index = 0; 211 const content_index = 1; 212 213 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 214 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 215 errdefer outer.deinit(allocator); 216 217 // the sub header carries the tab's only action 218 { 219 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 220 errdefer header.deinit(allocator); 221 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 222 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 223 try addText(allocator, &header, "clear undo history", link, .single); 224 header.getFocus().child_id = header.children.keys()[0]; 225 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 226 } 227 228 if (data.clear) { 229 var center = try initClearForm(allocator, data, session); 230 errdefer center.deinit(allocator); 231 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 232 outer.getFocus().child_id = outer.children.keys()[content_index]; 233 return .{ .box = outer, .data = data, .session = session }; 234 } 235 236 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 237 errdefer box.deinit(allocator); 238 { 239 var scroll = blk: { 240 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 241 errdefer rows.deinit(allocator); 242 for (data.items) |item| try addText(allocator, &rows, item.action, item.link, .hidden); 243 if (data.next) |next| { 244 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 245 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 246 } 247 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 248 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 249 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 250 }; 251 errdefer scroll.deinit(allocator); 252 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 } }); 253 } 254 { 255 var scroll = blk: { 256 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 257 errdefer details.deinit(allocator); 258 if (data.items.len > 0) { 259 try addText(allocator, &details, "", null, .single); 260 try addText(allocator, &details, "", null, .single); 261 details.children.values()[1].widget.text_box.options.label = " timestamp "; 262 try addText(allocator, &details, "", null, .single); 263 details.children.values()[2].widget.text_box.options.label = " description "; 264 details.getFocus().child_id = details.children.keys()[0]; 265 } 266 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 267 }; 268 errdefer scroll.deinit(allocator); 269 scroll.getFocus().mode = .mouse; 270 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 271 } 272 box.getFocus().child_id = box.children.keys()[0]; 273 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 274 outer.getFocus().child_id = outer.children.keys()[content_index]; 275 return .{ .box = outer, .data = data, .session = session }; 276 } 277 278 // the same shape the thread views use to confirm a removal 279 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 280 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 281 errdefer box.deinit(allocator); 282 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 283 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 284 285 var prompt = try wgt.Text.init(allocator, "are you sure?"); 286 errdefer prompt.deinit(allocator); 287 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 288 289 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 290 errdefer button.deinit(allocator); 291 button.getFocus().mode = .all; 292 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 293 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 294 box.getFocus().child_id = button.getFocus().id; 295 296 return ui.widget.Center.init(allocator, .{ .box = box }); 297 } 298 299 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 300 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 301 errdefer row.deinit(allocator); 302 row.getFocus().mode = .all; 303 if (link) |value| row.getFocus().kind = .{ .custom = value }; 304 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 305 } 306 307 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 308 return &self.box.children.values()[header_index].widget.box; 309 } 310 fn headerActive(self: *View) bool { 311 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 312 } 313 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 314 return &self.box.children.values()[content_index].widget.box; 315 } 316 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 317 return &self.contentBox().children.values()[0].widget.scroll; 318 } 319 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 320 return &self.contentBox().children.values()[1].widget.scroll; 321 } 322 fn selected(self: *View) ?usize { 323 const rows = &self.listScroll().child.box; 324 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 325 return if (index < self.data.items.len) index else null; 326 } 327 fn detailActive(self: *View) bool { 328 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 329 } 330 331 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 332 const index = self.selected() orelse return; 333 const failure = self.session.data.undo_failure orelse self.data.failure; 334 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 335 const details = &self.detailScroll().child.box; 336 if (self.failure_id) |id| { 337 if (details.children.getPtr(id)) |child| child.widget.deinit(allocator); 338 _ = details.children.orderedRemove(id); 339 self.failure_id = null; 340 } 341 const item = self.data.items[index]; 342 const button = &details.children.values()[0].widget.text_box; 343 const description = &details.children.values()[2].widget.text_box; 344 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"); 345 // a transaction that wrote events links to them instead of describing itself 346 if (item.events) |events| { 347 try description.setContent(allocator, events.label); 348 description.options.label = " events "; 349 description.getFocus().kind = .{ .custom = events.link }; 350 } else { 351 try description.setContent(allocator, item.description); 352 description.options.label = " description "; 353 description.getFocus().kind = .text_box; 354 } 355 const enabled = self.data.can_undo and item.index > 0; 356 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 357 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 358 if (failure) |message| { 359 try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 360 self.failure_id = details.children.keys()[details.children.count() - 1]; 361 } 362 self.shown_failure = failure; 363 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 364 details.getFocus().child_id = details.children.keys()[0]; 365 self.detailScroll().x = 0; 366 self.detailScroll().y = 0; 367 self.detailScroll().getFocus().version +%= 1; 368 self.detailed_index = index; 369 } 370 371 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 372 self.clearGrid(); 373 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 374 try self.refreshDetail(allocator); 375 const rows = &self.listScroll().child.box; 376 if (self.data.items.len == 0) { 377 const failure = self.session.data.undo_failure orelse self.data.failure; 378 if (!std.meta.eql(self.shown_failure, failure)) { 379 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 380 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 381 self.shown_failure = failure; 382 } 383 } 384 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; 385 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 386 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 387 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 388 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 389 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 390 try self.box.build(allocator, constraint, root_focus); 391 } 392 393 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 394 // the sub header sits above whichever content the route selects 395 if (self.headerActive()) { 396 if (key == .arrow_down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 397 return; 398 } 399 if (self.data.clear) { 400 if (key == .arrow_up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 401 return; 402 } 403 if (key == .arrow_up and self.contentAtTop()) { 404 root_focus.setFocus(self.headerBox().getFocus().id); 405 return; 406 } 407 try self.refreshDetail(allocator); 408 if (self.detailActive()) { 409 const details = &self.detailScroll().child.box; 410 if (self.selected()) |index| { 411 const button_id = details.children.keys()[0]; 412 const activated = switch (key) { 413 .enter => root_focus.grandchild_id == button_id, 414 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 415 else => false, 416 }; 417 if (activated and self.data.can_undo and self.data.items[index].index > 0) { 418 if (builtin.target.cpu.arch != .wasm32 and self.session.is_terminal and self.session.host_request == null) { 419 const route = ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong; 420 self.session.host_request = .{ .undo = route.repo_undo }; 421 } 422 return; 423 } 424 } 425 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); 426 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 427 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 428 } else switch (key) { 429 .arrow_right, .enter => if (self.selected() != null) { 430 root_focus.setFocus(self.detailScroll().getFocus().id); 431 }, 432 else => {}, 433 } 434 } 435 436 // the confirm button submits the form the web renderer posts 437 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 438 const center = &self.box.children.values()[content_index].widget.center; 439 const button_id = center.child.box.getFocus().child_id orelse return; 440 const activated = switch (key) { 441 .enter => root_focus.grandchild_id == button_id, 442 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 443 else => false, 444 }; 445 if (!activated or !self.data.can_undo) return; 446 if (builtin.target.cpu.arch != .wasm32 and self.session.is_terminal and self.session.host_request == null) { 447 const route = ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong; 448 self.session.host_request = .{ .undo = route.repo_undo }; 449 } 450 } 451 452 fn contentAtTop(self: *View) bool { 453 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 454 const rows = &scroll.child.box; 455 const id = rows.getFocus().child_id orelse return true; 456 return rows.children.getIndex(id) == 0 and scroll.y == 0; 457 } 458 459 // moving up from the sub header returns to the page's tabs 460 pub fn atTop(self: *View) bool { 461 return self.headerActive(); 462 } 463 464 // arriving from the page's tabs lands on the sub header, not the content 465 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 466 root_focus.setFocus(self.headerBox().getFocus().id); 467 return true; 468 } 469 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 470 self.box.deinit(allocator); 471 } 472 pub fn clearGrid(self: *View) void { 473 self.box.clearGrid(); 474 } 475 pub fn getGrid(self: View) ?Grid { 476 return self.box.getGrid(); 477 } 478 pub fn getFocus(self: *View) *Focus { 479 return self.box.getFocus(); 480 } 481 }; 482