╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────╮ │src/ui/Repo/Undo.zig│ ╰────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 75 } │ │ 76 return .{ .identity = try aa.dupe(u8, identity), .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; │ │ 77 +} │ │ 78 + │ │ 79 +const Detail = struct { │ │ 80 + action: []const u8, │ │ 81 + description: []const u8, │ │ 82 + events: ?EventsLink = null, │ │ 83 +}; │ │ 84 + │ │ 85 +// every haxy event transaction shares one action, and the events it wrote are │ │ 86 +// named by the moment's index rather than by the record │ │ 87 +fn eventDetail( │ │ 88 + comptime opts: rp.RepoOpts(.xit), │ │ 89 + arena: *std.heap.ArenaAllocator, │ │ 90 + identity: []const u8, │ │ 91 + record: xit.undo.UndoRecord, │ │ 92 + moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), │ │ 93 +) !?Detail { │ │ 94 + if (std.mem.eql(u8, record.action, evt.merge_undo_action)) return .{ .action = "merge events", .description = "merged the events ref from a remote" };│ │ 95 + if (!std.mem.eql(u8, record.action, evt.undo_action)) return null; │ │ 96 + │ │ 97 + const description = "updated the event database"; │ │ 98 + const batch = try momentBatch(opts, moment) orelse return .{ .action = "events", .description = description }; │ │ 99 + │ │100 + const aa = arena.allocator(); │ │101 + const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", batch.index) orelse return error.RouteTooLong; │ │102 + return .{ │ │103 + .action = try std.fmt.allocPrint(aa, "events ({d})", .{batch.count}), │ │104 + .description = description, │ │105 + .events = .{ │ │106 + .label = try std.fmt.allocPrint(aa, "view events ({d})", .{batch.count}), │ │107 + .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(arena)}), │ │108 + }, │ │109 + }; │ │110 +} │ │111 + │ │112 +const Batch = struct { index: u64, count: u64 }; │ │113 + │ │114 +// the events the transaction's moment wrote, or null when it wrote none │ │115 +fn momentBatch(comptime opts: rp.RepoOpts(.xit), moment: rp.Repo(.xit, opts).DB.HashMap(.read_only)) !?Batch { │ │116 + const DB = rp.Repo(.xit, opts).DB; │ │117 + const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; │ │118 + const index_cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; │ │119 + const moment_index = try index_cursor.readUint(); │ │120 + const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, moment_index) orelse return null; │ │121 + return .{ .index = moment_index, .count = try ids.count() }; │ │122 } │ │123 │ │124 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │140 } │ │141 } │ │142 │ │143 +// keep action failures on the current tab without rebuilding a stale route. │ │144 +pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void {│ │145 + perform(allocator, session, target) catch |err| { │ │146 + session.data.undo_failure = @errorName(err); │ │147 + return; │ │148 + }; │ │149 + session.data.undo_failure = null; │ │150 +} │ │151 + │ │152 // both terminal hosts resolve and authorize the request afresh before opening │ │153 // the repository; the web handler uses its matching http authorization path. │ │154 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │155 const index = target.index orelse return error.InvalidHistoryIndex; │ │156 const identity = target.name.slice(); │ │ 90 - if (try session.authorize(identity, .write) == null) return error.Forbidden;│ │157 + if (try session.authorize(identity, .owner) == null) return error.Forbidden;│ │158 const io = session.io orelse return error.NotFound; │ │159 const source = session.local orelse blk: { │ │160 const admin_repo = session.admin_repo orelse return error.NotFound; │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────╮ │173 data: *const Self, │ │174 session: *ui.Session, │ │175 detailed_index: ?usize = null, │ │176 + failure_id: ?usize = null, │ │177 + shown_failure: ?[]const u8 = null,│ │178 │ │179 const list_max_width = 20; │ │180 const detail_min_width = 40; │ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │203 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │204 errdefer details.deinit(allocator); │ │205 if (data.items.len > 0) { │ │206 + try addText(allocator, &details, "", null, .single); │ │207 try addText(allocator, &details, "", null, .single); │ │208 + details.children.values()[1].widget.text_box.options.label = " timestamp "; │ │209 try addText(allocator, &details, "", null, .single); │ │210 + details.children.values()[2].widget.text_box.options.label = " description "; │ │211 details.getFocus().child_id = details.children.keys()[0]; │ │212 } │ │213 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true });│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │245 │ │246 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { │ │247 const index = self.selected() orelse return; │ │176 - if (self.detailed_index == index) return; │ │248 + const failure = self.session.data.undo_failure orelse self.data.failure; │ │249 + if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; │ │250 const details = &self.detailScroll().child.box; │ │251 + if (self.failure_id) |id| { │ │252 + if (details.children.getPtr(id)) |child| child.widget.deinit(allocator); │ │253 + _ = details.children.orderedRemove(id); │ │254 + self.failure_id = null; │ │255 + } │ │256 const item = self.data.items[index]; │ │257 const button = &details.children.values()[0].widget.text_box; │ │180 - const description = &details.children.values()[1].widget.text_box; │ │258 + const description = &details.children.values()[2].widget.text_box; │ │259 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");│ │182 - try description.setContent(allocator, item.description); │ │183 - details.getFocus().kind = if (item.index > 0) .{ .custom = item.form } else .container; │ │184 - button.getFocus().kind = if (item.index > 0) .{ .custom = "submit" } else .text_box; │ │260 + // a transaction that wrote events links to them instead of describing itself │ │261 + if (item.events) |events| { │ │262 + try description.setContent(allocator, events.label); │ │263 + description.options.label = " events "; │ │264 + description.getFocus().kind = .{ .custom = events.link }; │ │265 + } else { │ │266 + try description.setContent(allocator, item.description); │ │267 + description.options.label = " description "; │ │268 + description.getFocus().kind = .text_box; │ │269 + } │ │270 + const enabled = self.data.can_undo and item.index > 0; │ │271 + button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; │ │272 + try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); │ │273 + if (failure) |message| { │ │274 + try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); │ │275 + self.failure_id = details.children.keys()[details.children.count() - 1]; │ │276 + } │ │277 + self.shown_failure = failure; │ │278 + details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; │ │279 details.getFocus().child_id = details.children.keys()[0]; │ │280 self.detailScroll().x = 0; │ │281 self.detailScroll().y = 0; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │287 self.clearGrid(); │ │288 try self.refreshDetail(allocator); │ │289 const rows = &self.listScroll().child.box; │ │290 + if (self.data.items.len == 0) { │ │291 + const failure = self.session.data.undo_failure orelse self.data.failure; │ │292 + if (!std.meta.eql(self.shown_failure, failure)) { │ │293 + const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; │ │294 + try rows.children.values()[0].widget.text_box.setContent(allocator, message); │ │295 + self.shown_failure = failure; │ │296 + } │ │297 + } │ │298 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;│ │299 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; │ │300 self.box.children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │315 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), │ │316 else => false, │ │317 }; │ │216 - if (activated and self.data.items[index].index > 0) { │ │318 + if (activated and self.data.can_undo and self.data.items[index].index > 0) { │ │319 if (builtin.target.cpu.arch != .wasm32 and self.session.is_terminal and self.session.host_request == null) { │ │320 const route = ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong;│ │321 self.session.host_request = .{ .undo = route.repo_undo }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1369 } │ │1370 │ │1371 const id_hex = std.fmt.bytesToHex(id, .lower); │ │1372 - const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; │ │1372 + const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex, null) orelse return;│ │1373 try self.session.navigate(route); │ │1374 } │ │1375 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2958 } │ │2959 │ │2960 const id_hex = std.fmt.bytesToHex(id, .lower); │ │2961 - const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; │ │2961 + const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex, null) orelse return;│ │2962 try self.session.navigate(route); │ │2963 } │ │2964 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯