╭ 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 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 { 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| value.timestamp else -1, 99 .link = try aa.print("ai:{s}", .{url}), 100 .form = try aa.print("form:{s}/undo", .{url}), 101 .undone = row_undone, 102 }); 103 } 104 return .{ .identity = try aa.dupe(u8, identity), .handle = handle, .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 aa.print("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 arena.allocator().print("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 aa.print("view events ({d})", .{count}), 203 .link = try aa.print("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, 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 aa.print(" {s} {s} ", .{ if (head) "branch" else "tag", action }), 245 .text = try aa.dupe(u8, name), 246 .link = if (route) |value| try aa.print("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 execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 312 if (source.repo_kind != .xit) return error.NotFound; 313 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 314 defer any_repo.deinit(io, allocator); 315 switch (any_repo) { 316 inline else => |*repo| try repo.undo(io, index), 317 } 318 } 319 320 // discard every earlier transaction by compacting the database. the patch 321 // revisions events point at are unreachable from the refs, so they are named 322 // as extra roots to survive the collection. 323 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 324 if (source.repo_kind != .xit) return error.NotFound; 325 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 326 defer any_repo.deinit(io, allocator); 327 switch (any_repo) { 328 inline else => |*repo| { 329 const opts = repo.self_repo_opts; 330 // a repo that has consumed no events has no revisions to protect 331 const roots = if (evt.currentMoment(opts, repo)) |moment| 332 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 333 else |_| 334 &.{}; 335 defer allocator.free(roots); 336 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 337 }, 338 } 339 } 340 341 // keep action failures on the current tab without rebuilding a stale route. 342 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 343 perform(allocator, session, target) catch |err| { 344 session.data.undo_failure = @errorName(err); 345 return; 346 }; 347 session.data.undo_failure = null; 348 } 349 350 // both terminal hosts resolve and authorize the request afresh before opening 351 // the repository; the web handler uses its matching http authorization path. 352 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 353 const identity = target.name.slice(); 354 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 355 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 356 const io = session.io orelse return error.NotFound; 357 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 358 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 359 } 360 361 // the repo an owner may act on, or null when they may not 362 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 363 if (try session.authorize(identity, .owner, null) == null) return null; 364 if (session.local) |local| return local; 365 const admin_repo = session.admin_repo orelse return error.NotFound; 366 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 367 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 368 const io = session.io orelse return error.NotFound; 369 const users_dir = session.users_dir orelse return error.NotFound; 370 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; 371 return .{ .path = try evt.repoPath(session.page_arena.allocator(), users_dir, found.repo.event.user_id, &found.event_id), .repo_kind = .xit }; 372 } 373 374 pub const View = struct { 375 box: wgt.Box(ui.Widget), 376 data: *const Self, 377 session: *ui.Session, 378 detailed_index: ?usize = null, 379 shown_failure: ?[]const u8 = null, 380 381 const list_max_width = 20; 382 const detail_min_width = 40; 383 const header_index = 0; 384 const content_index = 1; 385 // the undo button and timestamp stay; everything below them is rebuilt 386 const fixed_rows = 2; 387 388 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 389 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 390 errdefer outer.deinit(allocator); 391 392 // the sub header carries the tab's only action 393 { 394 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 395 errdefer header.deinit(allocator); 396 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 397 const link = try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}); 398 try addText(allocator, &header, "clear undo history", link, .single); 399 header.getFocus().child_id = header.children.keys()[0]; 400 // a row taller than the header, leaving a blank line beneath it 401 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 402 } 403 404 if (data.clear) { 405 var center = try initClearForm(allocator, data, session); 406 errdefer center.deinit(allocator); 407 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 408 outer.getFocus().child_id = outer.children.keys()[content_index]; 409 return .{ .box = outer, .data = data, .session = session }; 410 } 411 412 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 413 errdefer box.deinit(allocator); 414 { 415 var scroll = blk: { 416 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert, .stretch = true }); 417 errdefer rows.deinit(allocator); 418 for (data.items) |item| { 419 try addText(allocator, &rows, item.action, item.link, .hidden); 420 // a transaction a later undo discarded says so on its border 421 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label.text = "(undone)"; 422 } 423 if (data.next) |next| { 424 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 425 try addText(allocator, &rows, "next →", try session.page_arena.allocator().print("a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 426 } 427 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 428 rows.getFocus().child_id = rows.children.keys()[0]; 429 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 430 }; 431 errdefer scroll.deinit(allocator); 432 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 } }); 433 } 434 // the detail scroll, below the way back to a hidden list 435 { 436 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 437 errdefer frame.deinit(allocator); 438 try ui.widget.addListBack(allocator, &frame, "← back to undo list"); 439 var scroll = blk: { 440 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 441 errdefer details.deinit(allocator); 442 if (data.items.len > 0) { 443 try addText(allocator, &details, "", null, .single); 444 try addText(allocator, &details, "", null, .single); 445 details.getFocus().child_id = details.children.keys()[0]; 446 } 447 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 448 }; 449 errdefer scroll.deinit(allocator); 450 scroll.getFocus().mode = .mouse; 451 frame.getFocus().child_id = scroll.getFocus().id; 452 try frame.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 453 try box.children.put(allocator, frame.getFocus().id, .{ .widget = .{ .box = frame }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 454 } 455 box.getFocus().child_id = box.children.keys()[if (data.items.len > 0) 1 else 0]; 456 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 457 outer.getFocus().child_id = outer.children.keys()[content_index]; 458 return .{ .box = outer, .data = data, .session = session }; 459 } 460 461 // the same shape the thread views use to confirm a removal 462 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 463 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .round_corners = true, .direction = .vert }); 464 errdefer box.deinit(allocator); 465 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 466 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 467 468 var prompt = try wgt.Text.init(allocator, "are you sure?"); 469 errdefer prompt.deinit(allocator); 470 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 471 472 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 473 errdefer button.deinit(allocator); 474 button.getFocus().mode = .all; 475 button.getFocus().kind = if (data.handle.canUndo()) .{ .custom = "submit" } else .text_box; 476 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 477 box.getFocus().child_id = button.getFocus().id; 478 479 return ui.widget.Center.init(allocator, .{ .box = box }); 480 } 481 482 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.Border) !void { 483 var row = try wgt.TextBox.init(allocator, text, .{ .border = border, .round_corners = true, .wrap_kind = .word }); 484 errdefer row.deinit(allocator); 485 row.getFocus().mode = .all; 486 if (link) |value| row.getFocus().kind = .{ .custom = value }; 487 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 488 } 489 490 // a labeled box, added as the selected action needs it 491 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 492 try addText(allocator, box, text, link, .single); 493 box.children.values()[box.children.count() - 1].widget.text_box.options.top_label.text = label; 494 } 495 496 // the terminal hosts run the action after rendering; the web renderer 497 // posts the form instead, and wasm has no repository to act on 498 fn requestUndo(self: *View, route: ui.RoutablePage) void { 499 if (builtin.target.cpu.arch == .wasm32) return; 500 if (!self.session.is_terminal or self.session.host_request != null) return; 501 self.session.host_request = .{ .undo = route.repo_undo }; 502 } 503 504 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 505 return &self.box.children.values()[header_index].widget.box; 506 } 507 fn headerActive(self: *View) bool { 508 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 509 } 510 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 511 return &self.box.children.values()[content_index].widget.box; 512 } 513 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 514 return &self.contentBox().children.values()[0].widget.scroll; 515 } 516 fn detailFrame(self: *View) *wgt.Box(ui.Widget) { 517 return &self.contentBox().children.values()[1].widget.box; 518 } 519 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 520 return &self.detailFrame().children.values()[ui.widget.list_pane_index].widget.scroll; 521 } 522 fn selected(self: *View) ?usize { 523 const rows = &self.listScroll().child.box; 524 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 525 return if (index < self.data.items.len) index else null; 526 } 527 fn detailActive(self: *View) bool { 528 return self.contentBox().getFocus().child_id == self.detailFrame().getFocus().id; 529 } 530 531 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 532 const index = self.selected() orelse return; 533 const failure = self.session.data.undo_failure orelse self.data.failure; 534 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 535 const details = &self.detailScroll().child.box; 536 while (details.children.count() > fixed_rows) { 537 var entry = details.children.pop() orelse break; 538 entry.value.widget.deinit(allocator); 539 } 540 const item = self.data.items[index]; 541 const enabled = self.data.handle.canUndo() and item.index > 0; 542 543 // the fixed rows are filled first, since adding the ones below 544 // invalidates every pointer into the box 545 { 546 const button = &details.children.values()[0].widget.text_box; 547 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"); 548 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 549 } 550 const timestamp = try ui.timestamp.format(self.session.page_arena.allocator(), item.timestamp, try self.session.nowSeconds()); 551 try details.children.values()[1].widget.text_box.setContent(allocator, timestamp.relative); 552 details.children.values()[1].widget.text_box.options.bottom_label.text = timestamp.exact; 553 554 // some actions put buttons where the description would go 555 if (item.buttons.len == 0) { 556 try addLabeled(allocator, details, " description ", item.description, null); 557 } else for (item.buttons) |detail_button| { 558 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 559 } 560 if (failure) |message| try addText(allocator, details, try self.session.page_arena.allocator().print("error: {s}", .{message}), null, .single); 561 self.shown_failure = failure; 562 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 563 details.getFocus().child_id = details.children.keys()[0]; 564 self.detailScroll().x = 0; 565 self.detailScroll().y = 0; 566 self.detailScroll().getFocus().version +%= 1; 567 ui.widget.setListBackLink(self.detailFrame(), item.link); 568 self.detailed_index = index; 569 } 570 571 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 572 self.clearGrid(); 573 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 574 try self.refreshDetail(allocator); 575 const rows = &self.listScroll().child.box; 576 if (self.data.items.len == 0) { 577 const failure = self.session.data.undo_failure orelse self.data.failure; 578 if (!std.meta.eql(self.shown_failure, failure)) { 579 const message = if (failure) |name| try self.session.page_arena.allocator().print("error: {s}", .{name}) else "no undo history"; 580 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 581 self.shown_failure = failure; 582 } 583 } 584 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 585 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 586 ui.widget.showListBack(self.detailFrame(), !both_fit); 587 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 588 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 589 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 590 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 591 try self.box.build(allocator, constraint, root_focus); 592 } 593 594 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 595 // the sub header sits above whichever content the route selects. 596 // scrolling crosses the boundary the same way the arrow keys do 597 const direction = inp.vertDirection(key); 598 if (self.headerActive()) { 599 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 600 return; 601 } 602 if (self.data.clear) { 603 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 604 return; 605 } 606 // the way back to the list, by enter or a click 607 if (self.detailActive()) if (ui.widget.listBackId(self.detailFrame())) |id| { 608 if (inp.activated(root_focus, id, key)) return self.focusList(root_focus); 609 if (root_focus.grandchild_id == id) return switch (direction) { 610 .up => root_focus.setFocus(self.headerBox().getFocus().id), 611 .down => root_focus.setFocus(self.detailScroll().getFocus().id), 612 .none => if (key == .arrow_left) self.focusList(root_focus), 613 }; 614 if (direction == .up and self.contentAtTop()) return root_focus.setFocus(id); 615 }; 616 if (direction == .up and self.contentAtTop()) { 617 root_focus.setFocus(self.headerBox().getFocus().id); 618 return; 619 } 620 try self.refreshDetail(allocator); 621 if (self.detailActive()) { 622 const details = &self.detailScroll().child.box; 623 if (self.selected()) |index| { 624 if (inp.activated(root_focus, details.children.keys()[0], key) and self.data.handle.canUndo() and self.data.items[index].index > 0) { 625 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 626 return; 627 } 628 } 629 if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| { 630 ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 631 } else if (key == .arrow_left) { 632 self.focusList(root_focus); 633 } 634 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 635 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 636 } else switch (key) { 637 .arrow_right, .enter => if (self.selected() != null) self.focusDetail(root_focus), 638 else => {}, 639 } 640 } 641 642 // enter the detail pane, landing on the way back to the list when it shows 643 fn focusDetail(self: *View, root_focus: *Focus) void { 644 if (ui.widget.listBackId(self.detailFrame()) != null) return ui.widget.focusListBack(self.detailFrame(), root_focus); 645 root_focus.setFocus(self.detailScroll().getFocus().id); 646 } 647 648 fn focusList(self: *View, root_focus: *Focus) void { 649 root_focus.setFocus(self.listScroll().getFocus().id); 650 } 651 652 // the confirm button submits the form the web renderer posts 653 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 654 const center = &self.box.children.values()[content_index].widget.center; 655 const button_id = center.child.box.getFocus().child_id orelse return; 656 if (!inp.activated(root_focus, button_id, key) or !self.data.handle.canUndo()) return; 657 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 658 } 659 660 fn contentAtTop(self: *View) bool { 661 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 662 const rows = &scroll.child.box; 663 const id = rows.getFocus().child_id orelse return true; 664 return rows.children.getIndex(id) == 0 and scroll.y == 0; 665 } 666 667 // moving up from the sub header returns to the page's tabs 668 pub fn atTop(self: *View) bool { 669 return self.headerActive(); 670 } 671 672 // arriving from the page's tabs lands on the sub header, not the content 673 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 674 root_focus.setFocus(self.headerBox().getFocus().id); 675 return true; 676 } 677 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 678 self.box.deinit(allocator); 679 } 680 pub fn clearGrid(self: *View) void { 681 self.box.clearGrid(); 682 } 683 pub fn getGrid(self: View) ?Grid { 684 return self.box.getGrid(); 685 } 686 pub fn getFocus(self: *View) *Focus { 687 return self.box.getFocus(); 688 } 689 }; 690