╭ 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 pch = @import("../../patch.zig"); 6 const push = @import("../../push.zig"); 7 const fork = @import("../../fork.zig"); 8 const inp = @import("../input.zig"); 9 const xit = @import("xit"); 10 const rp = xit.repo; 11 const hash = xit.hash; 12 const wgt = xit.xitui.widget; 13 const layout = xit.xitui.layout; 14 const Key = xit.xitui.input.Key; 15 const Grid = xit.xitui.grid.Grid; 16 const Focus = xit.xitui.focus.Focus; 17 const Self = @This(); 18 19 pub const page_size = 20; 20 21 // the action kind xit writes when a transaction is undone 22 const undo_action_kind = @tagName(xit.undo.ActionKind.undo); 23 24 // stands in for the description, as a focusable box that may link somewhere 25 pub const DetailButton = struct { 26 label: []const u8, 27 text: []const u8, 28 link: ?[]const u8 = null, 29 }; 30 31 pub const Item = struct { 32 index: u64, 33 action: []const u8, 34 description: []const u8, 35 timestamp: []const u8, 36 link: []const u8, 37 form: []const u8, 38 buttons: []const DetailButton = &.{}, 39 undone: bool = false, 40 }; 41 42 identity: []const u8, 43 count: u64 = 0, 44 items: []const Item = &.{}, 45 next: ?u64 = null, 46 failure: ?[]const u8 = null, 47 can_undo: bool = false, 48 // the clear-history confirmation stands in for the list 49 clear: bool = false, 50 51 pub fn init(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, repo: *rp.Repo(.xit, opts), haxy_moment: ?evt.AdminDB.HashMap(.read_only), identity: []const u8, selected: ?u64) !Self { 52 const aa = arena.allocator(); 53 const DB = rp.Repo(.xit, opts).DB; 54 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 55 const count = try history.count(); 56 if (selected) |index| if (index >= count) return error.NotFound; 57 var items: std.ArrayList(Item) = .empty; 58 var remaining = if (selected) |index| index + 1 else count; 59 var buffer: [opts.max_read_size]u8 = undefined; 60 61 // an undo above the page discards transactions on it, so the walk starts 62 // at the newest transaction. an undo discards everything from the one it 63 // restored to down to the row below itself, so walking down, that row is 64 // all the walk has to carry. the rows it discarded are skipped, since a 65 // discarded undo discards nothing itself 66 var undone_from: ?u64 = null; 67 var scanned = count; 68 while (scanned > remaining) { 69 scanned -= 1; 70 if (undone_from) |from| if (scanned >= from) { 71 scanned = from; 72 continue; 73 }; 74 const record = try xit.undo.read(opts, try repo.core.momentAt(scanned), &buffer); 75 if (try undoneFrom(arena, record)) |from| undone_from = from; 76 } 77 78 while (remaining > 0 and items.items.len < page_size) { 79 remaining -= 1; 80 const moment = try repo.core.momentAt(remaining); 81 const record = try xit.undo.read(opts, moment, &buffer); 82 const row_undone = if (undone_from) |from| remaining >= from else false; 83 if (!row_undone) { 84 if (try undoneFrom(arena, record)) |from| undone_from = from; 85 } 86 const detail = if (record) |value| try eventDetail(opts, arena, &repo.core, haxy_moment, identity, value, moment, remaining) else null; 87 const shown = detail orelse Detail{ 88 .action = if (record) |value| try aa.dupe(u8, value.action_kind) else "unknown", 89 .description = try formatRecord(opts, arena, &repo.core, record), 90 }; 91 const route = ui.RoutablePage.repoUndoRoute(identity, remaining) orelse return error.RouteTooLong; 92 const url = try route.toUrl(arena); 93 try items.append(aa, .{ 94 .index = remaining, 95 .action = shown.action, 96 .description = shown.description, 97 .buttons = shown.buttons, 98 .timestamp = if (record) |value| try formatTimestamp(aa, value.timestamp) else "timestamp unavailable", 99 .link = try std.fmt.allocPrint(aa, "ai:{s}", .{url}), 100 .form = try std.fmt.allocPrint(aa, "form:{s}/undo", .{url}), 101 .undone = row_undone, 102 }); 103 } 104 return .{ .identity = try aa.dupe(u8, identity), .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; 105 } 106 107 const Detail = struct { 108 action: []const u8, 109 description: []const u8, 110 buttons: []const DetailButton = &.{}, 111 }; 112 113 // every haxy event transaction shares one action, and the events it wrote are 114 // named by the moment's index rather than by the record 115 fn eventDetail( 116 comptime opts: rp.RepoOpts(.xit), 117 arena: *std.heap.ArenaAllocator, 118 core: *rp.Repo(.xit, opts).Core, 119 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 120 identity: []const u8, 121 record: xit.undo.Record, 122 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 123 history_index: u64, 124 ) !?Detail { 125 const aa = arena.allocator(); 126 if (std.mem.eql(u8, record.action_kind, undo_action_kind)) { 127 // an undo names a transaction earlier than itself, so the chain 128 // below always moves down the history and ends 129 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, record.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 130 error.OutOfMemory => return err, 131 else => return null, 132 }; 133 defer parsed.deinit(); 134 const button = try undoneButton(opts, arena, core, haxy_moment, identity, parsed.value); 135 return .{ .action = undo_action_kind, .description = button.text, .buttons = try aa.dupe(DetailButton, &.{button}) }; 136 } 137 if (std.mem.eql(u8, record.action_kind, evt.merge_undo_action)) return .{ .action = "merge events", .description = "merged the events ref from a remote" }; 138 if (std.mem.eql(u8, record.action_kind, pch.merge_undo_action)) return .{ .action = "merge patch", .description = "merged a patch into its target branch" }; 139 if (std.mem.eql(u8, record.action_kind, fork.undo_action)) return .{ .action = "fork", .description = "created a fork to draft a patch in" }; 140 if (std.mem.eql(u8, record.action_kind, pch.mergeability_undo_action)) return .{ .action = "mergeability", .description = "rechecked whether the open patches still merge cleanly" }; 141 if (std.mem.eql(u8, record.action_kind, push.undo_action)) { 142 // a push consumes the events it carried in its own transaction 143 var buttons: std.ArrayList(DetailButton) = .empty; 144 if (try pushUser(arena, haxy_moment, record.payload)) |button| try buttons.append(aa, button); 145 const count = try momentEventCount(opts, core, moment, history_index) orelse 0; 146 if (count > 0) try buttons.append(aa, try eventsButton(arena, identity, history_index, count)); 147 try pushRefs(arena, identity, record.payload, &buttons); 148 return .{ .action = "push", .description = "received a push", .buttons = buttons.items }; 149 } 150 if (!std.mem.eql(u8, record.action_kind, evt.undo_action)) return null; 151 152 const count = try momentEventCount(opts, core, moment, history_index) orelse return .{ .action = "events", .description = "updated the event database" }; 153 // a transaction that consumed only merge commits indexed no events of its own 154 if (count == 0) return .{ .action = "events", .description = "merged the event history" }; 155 const buttons = try aa.dupe(DetailButton, &.{try eventsButton(arena, identity, history_index, count)}); 156 return .{ 157 .action = try std.fmt.allocPrint(aa, "events ({d})", .{count}), 158 .description = "updated the event database", 159 .buttons = buttons, 160 }; 161 } 162 163 // xit formats the transactions haxy has no name of its own for 164 fn formatRecord(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, core: *rp.Repo(.xit, opts).Core, record: ?xit.undo.Record) ![]const u8 { 165 const value = record orelse return "no undo record"; 166 var description: std.Io.Writer.Allocating = .init(arena.allocator()); 167 try xit.undo.format(opts, core, arena.child_allocator, value, &description.writer); 168 return description.written(); 169 } 170 171 // the transaction an undo restored, as a button that reads like its own row. 172 // the error set is explicit because this and `eventDetail` call each other 173 fn undoneButton( 174 comptime opts: rp.RepoOpts(.xit), 175 arena: *std.heap.ArenaAllocator, 176 core: *rp.Repo(.xit, opts).Core, 177 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 178 identity: []const u8, 179 undo: xit.undo.Undo, 180 ) anyerror!DetailButton { 181 var buffer: [opts.max_read_size]u8 = undefined; 182 const target = try xit.undo.undoneTarget(opts, core, arena.child_allocator, undo, &buffer); 183 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 io = session.io orelse return error.NotFound; 379 const repos_dir = session.repos_dir orelse return error.NotFound; 380 const found = try evt.readRepoByOwnerAndName(io, session.page_arena.child_allocator, session.page_arena, moment, repos_dir, pair.owner, pair.name) orelse return error.NotFound; 381 const hex = std.fmt.bytesToHex(found.event_id, .lower); 382 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ repos_dir, &hex }), .repo_kind = .xit }; 383 } 384 385 pub const View = struct { 386 box: wgt.Box(ui.Widget), 387 data: *const Self, 388 session: *ui.Session, 389 detailed_index: ?usize = null, 390 shown_failure: ?[]const u8 = null, 391 392 const list_max_width = 20; 393 const detail_min_width = 40; 394 const header_index = 0; 395 const content_index = 1; 396 // the undo button and the timestamp; everything below them is rebuilt 397 const fixed_rows = 2; 398 399 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 400 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 401 errdefer outer.deinit(allocator); 402 403 // the sub header carries the tab's only action 404 { 405 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 406 errdefer header.deinit(allocator); 407 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 408 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 409 try addText(allocator, &header, "clear undo history", link, .single); 410 header.getFocus().child_id = header.children.keys()[0]; 411 // a row taller than the header, leaving a blank line beneath it 412 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 413 } 414 415 if (data.clear) { 416 var center = try initClearForm(allocator, data, session); 417 errdefer center.deinit(allocator); 418 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 419 outer.getFocus().child_id = outer.children.keys()[content_index]; 420 return .{ .box = outer, .data = data, .session = session }; 421 } 422 423 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 424 errdefer box.deinit(allocator); 425 { 426 var scroll = blk: { 427 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 428 errdefer rows.deinit(allocator); 429 for (data.items) |item| { 430 try addText(allocator, &rows, item.action, item.link, .hidden); 431 // a transaction a later undo discarded says so on its border 432 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 433 } 434 if (data.next) |next| { 435 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 436 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 437 } 438 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 439 rows.getFocus().child_id = rows.children.keys()[0]; 440 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 441 }; 442 errdefer scroll.deinit(allocator); 443 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 444 } 445 { 446 var scroll = blk: { 447 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 448 errdefer details.deinit(allocator); 449 if (data.items.len > 0) { 450 try addText(allocator, &details, "", null, .single); 451 try addLabeled(allocator, &details, " timestamp ", "", null); 452 details.getFocus().child_id = details.children.keys()[0]; 453 } 454 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 455 }; 456 errdefer scroll.deinit(allocator); 457 scroll.getFocus().mode = .mouse; 458 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 459 } 460 box.getFocus().child_id = box.children.keys()[0]; 461 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 462 outer.getFocus().child_id = outer.children.keys()[content_index]; 463 return .{ .box = outer, .data = data, .session = session }; 464 } 465 466 // the same shape the thread views use to confirm a removal 467 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 468 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .round_corners = true, .direction = .vert }); 469 errdefer box.deinit(allocator); 470 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 471 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 472 473 var prompt = try wgt.Text.init(allocator, "are you sure?"); 474 errdefer prompt.deinit(allocator); 475 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 476 477 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .round_corners = true, .wrap_kind = .none }); 478 errdefer button.deinit(allocator); 479 button.getFocus().mode = .all; 480 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 481 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 482 box.getFocus().child_id = button.getFocus().id; 483 484 return ui.widget.Center.init(allocator, .{ .box = box }); 485 } 486 487 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 488 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .round_corners = true, .wrap_kind = .word }); 489 errdefer row.deinit(allocator); 490 row.getFocus().mode = .all; 491 if (link) |value| row.getFocus().kind = .{ .custom = value }; 492 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 493 } 494 495 // a labeled row below the timestamp, added as the selected action needs it 496 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 497 try addText(allocator, box, text, link, .single); 498 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 499 } 500 501 // enter counts only on the focused button; a click counts anywhere on it 502 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 503 return switch (key) { 504 .enter => root_focus.grandchild_id == button_id, 505 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 506 else => false, 507 }; 508 } 509 510 // the terminal hosts run the action after rendering; the web renderer 511 // posts the form instead, and wasm has no repository to act on 512 fn requestUndo(self: *View, route: ui.RoutablePage) void { 513 if (builtin.target.cpu.arch == .wasm32) return; 514 if (!self.session.is_terminal or self.session.host_request != null) return; 515 self.session.host_request = .{ .undo = route.repo_undo }; 516 } 517 518 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 519 return &self.box.children.values()[header_index].widget.box; 520 } 521 fn headerActive(self: *View) bool { 522 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 523 } 524 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 525 return &self.box.children.values()[content_index].widget.box; 526 } 527 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 528 return &self.contentBox().children.values()[0].widget.scroll; 529 } 530 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 531 return &self.contentBox().children.values()[1].widget.scroll; 532 } 533 fn selected(self: *View) ?usize { 534 const rows = &self.listScroll().child.box; 535 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 536 return if (index < self.data.items.len) index else null; 537 } 538 fn detailActive(self: *View) bool { 539 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 540 } 541 542 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 543 const index = self.selected() orelse return; 544 const failure = self.session.data.undo_failure orelse self.data.failure; 545 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 546 const details = &self.detailScroll().child.box; 547 while (details.children.count() > fixed_rows) { 548 var entry = details.children.pop() orelse break; 549 entry.value.widget.deinit(allocator); 550 } 551 const item = self.data.items[index]; 552 const enabled = self.data.can_undo and item.index > 0; 553 554 // the fixed rows are filled first, since adding the ones below 555 // invalidates every pointer into the box 556 { 557 const button = &details.children.values()[0].widget.text_box; 558 try button.setContent(allocator, if (item.index == 0) "initial state cannot be undone" else if (item.index == self.data.count - 1) "undo this" else "undo this and all above it"); 559 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 560 } 561 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 562 563 // some actions put buttons where the description would go 564 if (item.buttons.len == 0) { 565 try addLabeled(allocator, details, " description ", item.description, null); 566 } else for (item.buttons) |detail_button| { 567 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 568 } 569 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 570 self.shown_failure = failure; 571 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 572 details.getFocus().child_id = details.children.keys()[0]; 573 self.detailScroll().x = 0; 574 self.detailScroll().y = 0; 575 self.detailScroll().getFocus().version +%= 1; 576 self.detailed_index = index; 577 } 578 579 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 580 self.clearGrid(); 581 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 582 try self.refreshDetail(allocator); 583 const rows = &self.listScroll().child.box; 584 if (self.data.items.len == 0) { 585 const failure = self.session.data.undo_failure orelse self.data.failure; 586 if (!std.meta.eql(self.shown_failure, failure)) { 587 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 588 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 589 self.shown_failure = failure; 590 } 591 } 592 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 593 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 594 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 595 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 596 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 597 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 598 try self.box.build(allocator, constraint, root_focus); 599 } 600 601 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 602 // the sub header sits above whichever content the route selects. 603 // scrolling crosses the boundary the same way the arrow keys do 604 const direction = inp.vertDirection(key); 605 if (self.headerActive()) { 606 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 607 return; 608 } 609 if (self.data.clear) { 610 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 611 return; 612 } 613 if (direction == .up and self.contentAtTop()) { 614 root_focus.setFocus(self.headerBox().getFocus().id); 615 return; 616 } 617 try self.refreshDetail(allocator); 618 if (self.detailActive()) { 619 const details = &self.detailScroll().child.box; 620 if (self.selected()) |index| { 621 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 622 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 623 return; 624 } 625 } 626 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); 627 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 628 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 629 } else switch (key) { 630 .arrow_right, .enter => if (self.selected() != null) { 631 root_focus.setFocus(self.detailScroll().getFocus().id); 632 }, 633 else => {}, 634 } 635 } 636 637 // the confirm button submits the form the web renderer posts 638 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 639 const center = &self.box.children.values()[content_index].widget.center; 640 const button_id = center.child.box.getFocus().child_id orelse return; 641 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 642 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 643 } 644 645 fn contentAtTop(self: *View) bool { 646 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 647 const rows = &scroll.child.box; 648 const id = rows.getFocus().child_id orelse return true; 649 return rows.children.getIndex(id) == 0 and scroll.y == 0; 650 } 651 652 // moving up from the sub header returns to the page's tabs 653 pub fn atTop(self: *View) bool { 654 return self.headerActive(); 655 } 656 657 // arriving from the page's tabs lands on the sub header, not the content 658 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 659 root_focus.setFocus(self.headerBox().getFocus().id); 660 return true; 661 } 662 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 663 self.box.deinit(allocator); 664 } 665 pub fn clearGrid(self: *View) void { 666 self.box.clearGrid(); 667 } 668 pub fn getGrid(self: View) ?Grid { 669 return self.box.getGrid(); 670 } 671 pub fn getFocus(self: *View) *Focus { 672 return self.box.getFocus(); 673 } 674 }; 675