╭ 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: i64, 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 handle: ui.RepoHandle, 48 // the clear-history confirmation stands in for the list 49 clear: bool = false, 50 51 // no history and no failure outside the clear form, which shows the empty message 52 fn isEmpty(self: *const Self, session: *ui.Session) bool { 53 return !self.clear and self.items.len == 0 and (session.data.undo_failure orelse self.failure) == null; 54 } 55 56 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, handle: ui.RepoHandle) !Self { 57 const aa = arena.allocator(); 58 const DB = rp.Repo(.xit, opts).DB; 59 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 60 const count = try history.count(); 61 if (selected) |index| if (index >= count) return error.NotFound; 62 var items: std.ArrayList(Item) = .empty; 63 var remaining = if (selected) |index| index + 1 else count; 64 var buffer: [opts.max_read_size]u8 = undefined; 65 66 // an undo above the page discards transactions on it, so the walk starts 67 // at the newest transaction. an undo discards everything from the one it 68 // restored to down to the row below itself, so walking down, that row is 69 // all the walk has to carry. the rows it discarded are skipped, since a 70 // discarded undo discards nothing itself 71 var undone_from: ?u64 = null; 72 var scanned = count; 73 while (scanned > remaining) { 74 scanned -= 1; 75 if (undone_from) |from| if (scanned >= from) { 76 scanned = from; 77 continue; 78 }; 79 const record = try xit.undo.read(opts, try repo.core.momentAt(scanned), &buffer); 80 if (try undoneFrom(arena, record)) |from| undone_from = from; 81 } 82 83 while (remaining > 0 and items.items.len < page_size) { 84 remaining -= 1; 85 const moment = try repo.core.momentAt(remaining); 86 const record = try xit.undo.read(opts, moment, &buffer); 87 const row_undone = if (undone_from) |from| remaining >= from else false; 88 if (!row_undone) { 89 if (try undoneFrom(arena, record)) |from| undone_from = from; 90 } 91 const detail = if (record) |value| try eventDetail(opts, arena, &repo.core, haxy_moment, identity, value, moment, remaining) else null; 92 const shown = detail orelse Detail{ 93 .action = if (record) |value| try aa.dupe(u8, value.action_kind) else "unknown", 94 .description = try formatRecord(opts, arena, &repo.core, record), 95 }; 96 const route = ui.RoutablePage.repoUndoRoute(identity, remaining) orelse return error.RouteTooLong; 97 const url = try route.toUrl(arena); 98 try items.append(aa, .{ 99 .index = remaining, 100 .action = shown.action, 101 .description = shown.description, 102 .buttons = shown.buttons, 103 .timestamp = if (record) |value| value.timestamp else -1, 104 .link = try aa.print("ai:{s}", .{url}), 105 .form = try aa.print("form:{s}/undo", .{url}), 106 .undone = row_undone, 107 }); 108 } 109 return .{ .identity = try aa.dupe(u8, identity), .handle = handle, .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; 110 } 111 112 const Detail = struct { 113 action: []const u8, 114 description: []const u8, 115 buttons: []const DetailButton = &.{}, 116 }; 117 118 // every haxy event transaction shares one action, and the events it wrote are 119 // named by the moment's index rather than by the record 120 fn eventDetail( 121 comptime opts: rp.RepoOpts(.xit), 122 arena: *std.heap.ArenaAllocator, 123 core: *rp.Repo(.xit, opts).Core, 124 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 125 identity: []const u8, 126 record: xit.undo.Record, 127 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 128 history_index: u64, 129 ) !?Detail { 130 const aa = arena.allocator(); 131 if (std.mem.eql(u8, record.action_kind, undo_action_kind)) { 132 // an undo names a transaction earlier than itself, so the chain 133 // below always moves down the history and ends 134 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, record.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 135 error.OutOfMemory => return err, 136 else => return null, 137 }; 138 defer parsed.deinit(); 139 const button = try undoneButton(opts, arena, core, haxy_moment, identity, parsed.value); 140 return .{ .action = undo_action_kind, .description = button.text, .buttons = try aa.dupe(DetailButton, &.{button}) }; 141 } 142 if (std.mem.eql(u8, record.action_kind, evt.merge_undo_action)) return .{ .action = "merge events", .description = "merged the events ref from a remote" }; 143 if (std.mem.eql(u8, record.action_kind, pch.merge_undo_action)) return .{ .action = "merge patch", .description = "merged a patch into its target branch" }; 144 if (std.mem.eql(u8, record.action_kind, fork.undo_action)) return .{ .action = "fork", .description = "created a fork to draft a patch in" }; 145 if (std.mem.eql(u8, record.action_kind, pch.mergeability_undo_action)) return .{ .action = "mergeability", .description = "rechecked whether the open patches still merge cleanly" }; 146 if (std.mem.eql(u8, record.action_kind, push.undo_action)) { 147 // a push consumes the events it carried in its own transaction 148 var buttons: std.ArrayList(DetailButton) = .empty; 149 if (try pushUser(arena, haxy_moment, record.payload)) |button| try buttons.append(aa, button); 150 const count = try momentEventCount(opts, core, moment, history_index) orelse 0; 151 if (count > 0) try buttons.append(aa, try eventsButton(arena, identity, history_index, count)); 152 try pushRefs(arena, identity, record.payload, &buttons); 153 return .{ .action = "push", .description = "received a push", .buttons = buttons.items }; 154 } 155 if (!std.mem.eql(u8, record.action_kind, evt.undo_action)) return null; 156 157 const count = try momentEventCount(opts, core, moment, history_index) orelse return .{ .action = "events", .description = "updated the event database" }; 158 // a transaction that consumed only merge commits indexed no events of its own 159 if (count == 0) return .{ .action = "events", .description = "merged the event history" }; 160 const buttons = try aa.dupe(DetailButton, &.{try eventsButton(arena, identity, history_index, count)}); 161 return .{ 162 .action = try aa.print("events ({d})", .{count}), 163 .description = "updated the event database", 164 .buttons = buttons, 165 }; 166 } 167 168 // xit formats the transactions haxy has no name of its own for 169 fn formatRecord(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, core: *rp.Repo(.xit, opts).Core, record: ?xit.undo.Record) ![]const u8 { 170 const value = record orelse return "no undo record"; 171 var description: std.Io.Writer.Allocating = .init(arena.allocator()); 172 try xit.undo.format(opts, core, arena.child_allocator, value, &description.writer); 173 return description.written(); 174 } 175 176 // the transaction an undo restored, as a button that reads like its own row. 177 // the error set is explicit because this and `eventDetail` call each other 178 fn undoneButton( 179 comptime opts: rp.RepoOpts(.xit), 180 arena: *std.heap.ArenaAllocator, 181 core: *rp.Repo(.xit, opts).Core, 182 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 183 identity: []const u8, 184 undo: xit.undo.Undo, 185 ) anyerror!DetailButton { 186 var buffer: [opts.max_read_size]u8 = undefined; 187 const target = try xit.undo.undoneTarget(opts, core, arena.child_allocator, undo, &buffer); 188 189 // the detail reads the event count out of the transaction's own moment 190 const moment = try core.momentAt(target.index); 191 const detail = if (target.record) |value| try eventDetail(opts, arena, core, haxy_moment, identity, value, moment, target.index) else null; 192 const route = ui.RoutablePage.repoUndoRoute(identity, target.index) orelse return error.RouteTooLong; 193 return .{ 194 .label = if (target.redo) " what was redone " else " what was undone ", 195 .text = if (detail) |value| value.action else try formatRecord(opts, arena, core, target.record), 196 .link = try arena.allocator().print("a:{s}", .{try route.toUrl(arena)}), 197 }; 198 } 199 200 // the page reads the transaction's own moment, so the url names the 201 // transaction rather than the state it produced 202 fn eventsButton(arena: *std.heap.ArenaAllocator, identity: []const u8, history_index: u64, count: u64) !DetailButton { 203 const aa = arena.allocator(); 204 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", history_index) orelse return error.RouteTooLong; 205 return .{ 206 .label = " events ", 207 .text = try aa.print("view events ({d})", .{count}), 208 .link = try aa.print("a:{s}", .{try route.toUrl(arena)}), 209 }; 210 } 211 212 // a button per ref the push moved, naming what it did to it: a changed branch 213 // links to the commits it added, anything still there to the ref itself 214 fn pushRefs(arena: *std.heap.ArenaAllocator, identity: []const u8, payload: []const u8, buttons: *std.ArrayList(DetailButton)) !void { 215 const head_prefix = "refs/heads/"; 216 const tag_prefix = "refs/tags/"; 217 const Payload = struct { 218 const Ref = struct { name: []const u8, old: []const u8, new: []const u8 }; 219 refs: []const Ref = &.{}, 220 }; 221 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 222 error.OutOfMemory => return err, 223 else => return, 224 }; 225 defer parsed.deinit(); 226 227 const aa = arena.allocator(); 228 for (parsed.value.refs) |ref| { 229 // anything that is neither a head nor a tag is skipped 230 const head = std.mem.startsWith(u8, ref.name, head_prefix); 231 const prefix = if (head) head_prefix else tag_prefix; 232 if (!std.mem.startsWith(u8, ref.name, prefix)) continue; 233 const name = ref.name[prefix.len..]; 234 235 // a removed ref has nothing left to link to. a branch that was created 236 // has no range to show either: what it added is whatever the other 237 // refs did not already reach, which a base cannot express 238 const removed = ref.new.len == 0; 239 const created = ref.old.len == 0; 240 const route = if (removed) 241 null 242 else if (head and !created) 243 ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, ref.old) orelse continue 244 else 245 ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; 246 247 const action = if (removed) "removed" else if (created) "created" else "changed"; 248 try buttons.append(aa, .{ 249 .label = try aa.print(" {s} {s} ", .{ if (head) "branch" else "tag", action }), 250 .text = try aa.dupe(u8, name), 251 .link = if (route) |value| try aa.print("a:{s}", .{try value.toUrl(arena)}) else null, 252 }); 253 } 254 } 255 256 // the push record stores the email of the user whose key the server accepted. 257 // resolving it names them as they are now, and leaves the email when no user 258 // holds it, which is also what local mode sees with no admin db to read 259 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 260 const Payload = struct { author: struct { email: []const u8 } }; 261 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 262 error.OutOfMemory => return err, 263 else => return null, 264 }; 265 defer parsed.deinit(); 266 return switch (try ui.Author.initFromEmail(haxy_moment, arena, parsed.value.author.email)) { 267 .unknown => null, 268 .email => |email| .{ .label = " user ", .text = email }, 269 .user_name => |name| .{ .label = " user ", .text = name, .link = try ui.userLink(arena, name) }, 270 }; 271 } 272 273 // how many events the transaction wrote: zero when it consumed nothing, null 274 // when its state cannot be read at all 275 fn momentEventCount( 276 comptime opts: rp.RepoOpts(.xit), 277 core: *rp.Repo(.xit, opts).Core, 278 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 279 history_index: u64, 280 ) !?u64 { 281 const DB = rp.Repo(.xit, opts).DB; 282 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 283 const index = try momentIndex(opts, haxy_moment) orelse return null; 284 285 // a transaction that consumed nothing inherits the moment it was cloned 286 // from, whose events belong to the transaction that wrote it 287 inherited: { 288 if (history_index == 0) break :inherited; 289 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 290 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 291 } 292 293 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 294 return try ids.count(); 295 } 296 297 // the oldest transaction `record` discarded, or null when it is not an undo 298 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 299 const value = record orelse return null; 300 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 301 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 302 error.OutOfMemory => return err, 303 else => return null, 304 }; 305 defer parsed.deinit(); 306 return parsed.value.index; 307 } 308 309 // the moment's own position in the haxy history 310 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 311 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 312 return try cursor.readUint(); 313 } 314 315 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 316 if (source.repo_kind != .xit) return error.NotFound; 317 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 318 defer any_repo.deinit(io, allocator); 319 switch (any_repo) { 320 inline else => |*repo| try repo.undo(io, index), 321 } 322 } 323 324 // discard every earlier transaction by compacting the database. the patch 325 // revisions events point at are unreachable from the refs, so they are named 326 // as extra roots to survive the collection. 327 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 328 if (source.repo_kind != .xit) return error.NotFound; 329 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 330 defer any_repo.deinit(io, allocator); 331 switch (any_repo) { 332 inline else => |*repo| { 333 const opts = repo.self_repo_opts; 334 // a repo that has consumed no events has no revisions to protect 335 const roots = if (evt.currentMoment(opts, repo)) |moment| 336 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 337 else |_| 338 &.{}; 339 defer allocator.free(roots); 340 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 341 }, 342 } 343 } 344 345 // keep action failures on the current tab without rebuilding a stale route. 346 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 347 perform(allocator, session, target) catch |err| { 348 session.data.undo_failure = @errorName(err); 349 return; 350 }; 351 session.data.undo_failure = null; 352 } 353 354 // both terminal hosts resolve and authorize the request afresh before opening 355 // the repository; the web handler uses its matching http authorization path. 356 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 357 const identity = target.name.slice(); 358 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 359 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 360 const io = session.io orelse return error.NotFound; 361 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 362 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 363 } 364 365 // the repo an owner may act on, or null when they may not 366 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 367 if (try session.authorize(identity, .owner, null) == null) return null; 368 if (session.local) |local| return local; 369 const admin_repo = session.admin_repo orelse return error.NotFound; 370 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 371 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 372 const io = session.io orelse return error.NotFound; 373 const users_dir = session.users_dir orelse return error.NotFound; 374 const found = try evt.readRepoByOwnerAndName(io, session.page_arena.child_allocator, session.page_arena, moment, users_dir, pair.owner, pair.name, null) orelse return error.NotFound; 375 return .{ .path = try evt.repoPath(session.page_arena.allocator(), users_dir, &try evt.parseEventId(&found.repo.event.user_id), &found.event_id), .repo_kind = .xit }; 376 } 377 378 pub const View = struct { 379 box: wgt.Box(ui.Widget), 380 data: *const Self, 381 session: *ui.Session, 382 detailed_index: ?usize = null, 383 shown_failure: ?[]const u8 = null, 384 385 const list_max_width = 20; 386 const detail_min_width = 40; 387 const header_index = 0; 388 const content_index = 1; 389 // the undo button and timestamp stay; everything below them is rebuilt 390 const fixed_rows = 2; 391 392 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 393 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 394 errdefer outer.deinit(allocator); 395 396 // the sub header carries the tab's only action 397 { 398 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 399 errdefer header.deinit(allocator); 400 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 401 const link = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}); 402 try addText(allocator, &header, "clear undo history", link, .single); 403 header.getFocus().child_id = header.children.keys()[0]; 404 // a row taller than the header, leaving a blank line beneath it 405 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 406 } 407 408 // the clear form, or the empty message when there is no history or failure to show 409 if (data.clear or data.isEmpty(session)) { 410 var center = if (data.clear) try initClearForm(allocator, data, session) else try ui.widget.initEmpty(allocator); 411 errdefer center.deinit(allocator); 412 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 413 // the empty message has nothing to focus, so focus rests on the sub-header 414 outer.getFocus().child_id = outer.children.keys()[if (data.clear) content_index else header_index]; 415 return .{ .box = outer, .data = data, .session = session }; 416 } 417 418 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 419 errdefer box.deinit(allocator); 420 { 421 var scroll = blk: { 422 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert, .stretch = true }); 423 errdefer rows.deinit(allocator); 424 for (data.items) |item| { 425 try addText(allocator, &rows, item.action, item.link, .hidden); 426 // a transaction a later undo discarded says so on its border 427 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label.text = "(undone)"; 428 } 429 if (data.next) |next| { 430 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 431 try addText(allocator, &rows, "next →", try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 432 } 433 // the failure row, filled in by build 434 if (data.items.len == 0) try addText(allocator, &rows, "", null, .hidden); 435 rows.getFocus().child_id = rows.children.keys()[0]; 436 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 437 }; 438 errdefer scroll.deinit(allocator); 439 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 } }); 440 } 441 // the detail scroll, below the way back to a hidden list 442 { 443 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 444 errdefer frame.deinit(allocator); 445 try ui.widget.addListBack(allocator, &frame, "← back to undo list"); 446 var scroll = blk: { 447 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 448 errdefer details.deinit(allocator); 449 if (data.items.len > 0) { 450 try addText(allocator, &details, "", null, .single); 451 try addText(allocator, &details, "", null, .single); 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 frame.getFocus().child_id = scroll.getFocus().id; 459 try frame.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 460 try box.children.put(allocator, frame.getFocus().id, .{ .widget = .{ .box = frame }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 461 } 462 box.getFocus().child_id = box.children.keys()[if (data.items.len > 0) 1 else 0]; 463 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 464 outer.getFocus().child_id = outer.children.keys()[content_index]; 465 return .{ .box = outer, .data = data, .session = session }; 466 } 467 468 // the same shape the thread views use to confirm a removal 469 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 470 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .round_corners = true, .direction = .vert }); 471 errdefer box.deinit(allocator); 472 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 473 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 474 475 var prompt = try wgt.Text.init(allocator, "are you sure?"); 476 errdefer prompt.deinit(allocator); 477 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 478 479 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 480 errdefer button.deinit(allocator); 481 button.getFocus().mode = .all; 482 button.getFocus().kind = if (data.handle.canUndo()) .{ .custom = "submit" } else .text_box; 483 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 484 box.getFocus().child_id = button.getFocus().id; 485 486 return ui.widget.Center.init(allocator, .{ .box = box }); 487 } 488 489 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.Border) !void { 490 var row = try wgt.TextBox.init(allocator, text, .{ .border = border, .round_corners = true, .wrap_kind = .word }); 491 errdefer row.deinit(allocator); 492 row.getFocus().mode = .all; 493 if (link) |value| row.getFocus().kind = .{ .custom = value }; 494 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 495 } 496 497 // a labeled box, added as the selected action needs it 498 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 499 try addText(allocator, box, text, link, .single); 500 box.children.values()[box.children.count() - 1].widget.text_box.options.top_label.text = label; 501 } 502 503 // the terminal hosts run the action after rendering; the web renderer 504 // posts the form instead, and wasm has no repository to act on 505 fn requestUndo(self: *View, route: ui.RoutablePage) void { 506 if (builtin.target.cpu.arch == .wasm32) return; 507 if (!self.session.is_terminal or self.session.host_request != null) return; 508 self.session.host_request = .{ .undo = route.repo_undo }; 509 } 510 511 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 512 return &self.box.children.values()[header_index].widget.box; 513 } 514 fn headerActive(self: *View) bool { 515 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 516 } 517 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 518 return &self.box.children.values()[content_index].widget.box; 519 } 520 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 521 return &self.contentBox().children.values()[0].widget.scroll; 522 } 523 fn detailFrame(self: *View) *wgt.Box(ui.Widget) { 524 return &self.contentBox().children.values()[1].widget.box; 525 } 526 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 527 return &self.detailFrame().children.values()[ui.widget.list_pane_index].widget.scroll; 528 } 529 fn selected(self: *View) ?usize { 530 const rows = &self.listScroll().child.box; 531 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 532 return if (index < self.data.items.len) index else null; 533 } 534 fn detailActive(self: *View) bool { 535 return self.contentBox().getFocus().child_id == self.detailFrame().getFocus().id; 536 } 537 538 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 539 const index = self.selected() orelse return; 540 const failure = self.session.data.undo_failure orelse self.data.failure; 541 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 542 const details = &self.detailScroll().child.box; 543 while (details.children.count() > fixed_rows) { 544 var entry = details.children.pop() orelse break; 545 entry.value.widget.deinit(allocator); 546 } 547 const item = self.data.items[index]; 548 const enabled = self.data.handle.canUndo() and item.index > 0; 549 550 // the fixed rows are filled first, since adding the ones below 551 // invalidates every pointer into the box 552 { 553 const button = &details.children.values()[0].widget.text_box; 554 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"); 555 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 556 } 557 const timestamp = try ui.timestamp.format(self.session.page_arena.allocator(), item.timestamp, try self.session.nowSeconds()); 558 try details.children.values()[1].widget.text_box.setContent(allocator, timestamp.relative); 559 details.children.values()[1].widget.text_box.options.bottom_label.text = timestamp.exact; 560 561 // some actions put buttons where the description would go 562 if (item.buttons.len == 0) { 563 try addLabeled(allocator, details, " description ", item.description, null); 564 } else for (item.buttons) |detail_button| { 565 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 566 } 567 if (failure) |message| try addText(allocator, details, try self.session.page_arena.allocator().print("error: {s}", .{message}), null, .single); 568 self.shown_failure = failure; 569 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 570 details.getFocus().child_id = details.children.keys()[0]; 571 self.detailScroll().x = 0; 572 self.detailScroll().y = 0; 573 self.detailScroll().getFocus().version +%= 1; 574 ui.widget.setListBackLink(self.detailFrame(), item.link); 575 self.detailed_index = index; 576 } 577 578 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 579 self.clearGrid(); 580 // the clear form or the empty message stands in for the list 581 if (self.data.clear or self.data.isEmpty(self.session)) 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 // an empty list only shows to report its failure 586 const failure = self.session.data.undo_failure orelse self.data.failure orelse unreachable; 587 if (!std.meta.eql(self.shown_failure, failure)) { 588 try rows.children.values()[0].widget.text_box.setContent(allocator, try self.session.page_arena.allocator().print("error: {s}", .{failure})); 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 ui.widget.showListBack(self.detailFrame(), !both_fit); 595 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 596 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 597 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 598 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 599 try self.box.build(allocator, constraint, root_focus); 600 } 601 602 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 603 // the sub header sits above whichever content the route selects. 604 // scrolling crosses the boundary the same way the arrow keys do 605 const direction = inp.vertDirection(key); 606 if (self.headerActive()) { 607 // the empty message has nothing to focus 608 if (direction == .down and !self.data.isEmpty(self.session)) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 609 return; 610 } 611 if (self.data.clear) { 612 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 613 return; 614 } 615 // the way back to the list, by enter or a click 616 if (self.detailActive()) if (ui.widget.listBackId(self.detailFrame())) |id| { 617 if (inp.activated(root_focus, id, key)) return self.focusList(root_focus); 618 if (root_focus.grandchild_id == id) return switch (direction) { 619 .up => root_focus.setFocus(self.headerBox().getFocus().id), 620 .down => root_focus.setFocus(self.detailScroll().getFocus().id), 621 .none => if (key == .arrow_left) self.focusList(root_focus), 622 }; 623 if (direction == .up and self.contentAtTop()) return root_focus.setFocus(id); 624 }; 625 if (direction == .up and self.contentAtTop()) { 626 root_focus.setFocus(self.headerBox().getFocus().id); 627 return; 628 } 629 try self.refreshDetail(allocator); 630 if (self.detailActive()) { 631 const details = &self.detailScroll().child.box; 632 if (self.selected()) |index| { 633 if (inp.activated(root_focus, details.children.keys()[0], key) and self.data.handle.canUndo() and self.data.items[index].index > 0) { 634 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 635 return; 636 } 637 } 638 if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| { 639 ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 640 } else if (key == .arrow_left) { 641 self.focusList(root_focus); 642 } 643 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 644 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 645 } else switch (key) { 646 .arrow_right, .enter => if (self.selected() != null) self.focusDetail(root_focus), 647 // when the window is too narrow to lay out the detail pane beside 648 // the list, a click on a row opens it like enter. the detail was 649 // swapped to the just-selected row above. 650 .mouse => |mouse| if (self.selected() != null and self.contentBox().children.values()[1].rect == null and ui.widget.clickOnSelectedRow(&self.listScroll().child.box, root_focus, mouse)) { 651 self.focusDetail(root_focus); 652 }, 653 else => {}, 654 } 655 } 656 657 // enter the detail pane, landing on the way back to the list when it shows 658 fn focusDetail(self: *View, root_focus: *Focus) void { 659 if (ui.widget.listBackId(self.detailFrame()) != null) return ui.widget.focusListBack(self.detailFrame(), root_focus); 660 root_focus.setFocus(self.detailScroll().getFocus().id); 661 } 662 663 fn focusList(self: *View, root_focus: *Focus) void { 664 root_focus.setFocus(self.listScroll().getFocus().id); 665 } 666 667 // the confirm button submits the form the web renderer posts 668 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 669 const center = &self.box.children.values()[content_index].widget.center; 670 const button_id = center.child.box.getFocus().child_id orelse return; 671 if (!inp.activated(root_focus, button_id, key) or !self.data.handle.canUndo()) return; 672 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 673 } 674 675 fn contentAtTop(self: *View) bool { 676 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 677 const rows = &scroll.child.box; 678 const id = rows.getFocus().child_id orelse return true; 679 return rows.children.getIndex(id) == 0 and scroll.y == 0; 680 } 681 682 // moving up from the sub header returns to the page's tabs 683 pub fn atTop(self: *View) bool { 684 return self.headerActive(); 685 } 686 687 // arriving from the page's tabs lands on the sub header, not the content 688 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 689 root_focus.setFocus(self.headerBox().getFocus().id); 690 return true; 691 } 692 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 693 self.box.deinit(allocator); 694 } 695 pub fn clearGrid(self: *View) void { 696 self.box.clearGrid(); 697 } 698 pub fn getGrid(self: View) ?Grid { 699 return self.box.getGrid(); 700 } 701 pub fn getFocus(self: *View) *Focus { 702 return self.box.getFocus(); 703 } 704 }; 705