╭ 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 find = @import("../../find.zig"); 9 const srch_cmmt = @import("../../search_commit.zig"); 10 const inp = @import("../input.zig"); 11 const xit = @import("xit"); 12 const rp = xit.repo; 13 const hash = xit.hash; 14 const wgt = xit.xitui.widget; 15 const layout = xit.xitui.layout; 16 const Key = xit.xitui.input.Key; 17 const Grid = xit.xitui.grid.Grid; 18 const Focus = xit.xitui.focus.Focus; 19 const Self = @This(); 20 21 pub const page_size = 20; 22 23 // the action kind xit writes when a transaction is undone 24 const undo_action_kind = @tagName(xit.undo.ActionKind.undo); 25 26 // stands in for the description, as a focusable box that may link somewhere 27 pub const DetailButton = struct { 28 label: []const u8, 29 text: []const u8, 30 link: ?[]const u8 = null, 31 }; 32 33 pub const Item = struct { 34 index: u64, 35 action: []const u8, 36 description: []const u8, 37 timestamp: []const u8, 38 link: []const u8, 39 form: []const u8, 40 buttons: []const DetailButton = &.{}, 41 undone: bool = false, 42 }; 43 44 identity: []const u8, 45 count: u64 = 0, 46 items: []const Item = &.{}, 47 next: ?u64 = null, 48 failure: ?[]const u8 = null, 49 can_undo: bool = false, 50 // the clear-history confirmation stands in for the list 51 clear: bool = false, 52 53 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 { 54 const aa = arena.allocator(); 55 const DB = rp.Repo(.xit, opts).DB; 56 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 57 const count = try history.count(); 58 if (selected) |index| if (index >= count) return error.NotFound; 59 var items: std.ArrayList(Item) = .empty; 60 var remaining = if (selected) |index| index + 1 else count; 61 var buffer: [opts.max_read_size]u8 = undefined; 62 63 // an undo above the page discards transactions on it, so the walk starts 64 // at the newest transaction. an undo discards everything from the one it 65 // restored to down to the row below itself, so walking down, that row is 66 // all the walk has to carry. the rows it discarded are skipped, since a 67 // discarded undo discards nothing itself 68 var undone_from: ?u64 = null; 69 var scanned = count; 70 while (scanned > remaining) { 71 scanned -= 1; 72 if (undone_from) |from| if (scanned >= from) { 73 scanned = from; 74 continue; 75 }; 76 const record = try xit.undo.read(opts, try repo.core.momentAt(scanned), &buffer); 77 if (try undoneFrom(arena, record)) |from| undone_from = from; 78 } 79 80 while (remaining > 0 and items.items.len < page_size) { 81 remaining -= 1; 82 const moment = try repo.core.momentAt(remaining); 83 const record = try xit.undo.read(opts, moment, &buffer); 84 const row_undone = if (undone_from) |from| remaining >= from else false; 85 if (!row_undone) { 86 if (try undoneFrom(arena, record)) |from| undone_from = from; 87 } 88 const detail = if (record) |value| try eventDetail(opts, arena, &repo.core, haxy_moment, identity, value, moment, remaining) else null; 89 const shown = detail orelse Detail{ 90 .action = if (record) |value| try aa.dupe(u8, value.action_kind) else "unknown", 91 .description = try formatRecord(opts, arena, &repo.core, record), 92 }; 93 const route = ui.RoutablePage.repoUndoRoute(identity, remaining) orelse return error.RouteTooLong; 94 const url = try route.toUrl(arena); 95 try items.append(aa, .{ 96 .index = remaining, 97 .action = shown.action, 98 .description = shown.description, 99 .buttons = shown.buttons, 100 .timestamp = if (record) |value| try formatTimestamp(aa, value.timestamp) else "timestamp unavailable", 101 .link = try std.fmt.allocPrint(aa, "ai:{s}", .{url}), 102 .form = try std.fmt.allocPrint(aa, "form:{s}/undo", .{url}), 103 .undone = row_undone, 104 }); 105 } 106 return .{ .identity = try aa.dupe(u8, identity), .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; 107 } 108 109 const Detail = struct { 110 action: []const u8, 111 description: []const u8, 112 buttons: []const DetailButton = &.{}, 113 }; 114 115 // every haxy event transaction shares one action, and the events it wrote are 116 // named by the moment's index rather than by the record 117 fn eventDetail( 118 comptime opts: rp.RepoOpts(.xit), 119 arena: *std.heap.ArenaAllocator, 120 core: *rp.Repo(.xit, opts).Core, 121 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 122 identity: []const u8, 123 record: xit.undo.Record, 124 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 125 history_index: u64, 126 ) !?Detail { 127 const aa = arena.allocator(); 128 if (std.mem.eql(u8, record.action_kind, undo_action_kind)) { 129 // an undo names a transaction earlier than itself, so the chain 130 // below always moves down the history and ends 131 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, record.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 132 error.OutOfMemory => return err, 133 else => return null, 134 }; 135 defer parsed.deinit(); 136 const button = try undoneButton(opts, arena, core, haxy_moment, identity, parsed.value); 137 return .{ .action = undo_action_kind, .description = button.text, .buttons = try aa.dupe(DetailButton, &.{button}) }; 138 } 139 if (std.mem.eql(u8, record.action_kind, evt.merge_undo_action)) return .{ .action = "merge events", .description = "merged the events ref from a remote" }; 140 if (std.mem.eql(u8, record.action_kind, pch.merge_undo_action)) return .{ .action = "merge patch", .description = "merged a patch into its target branch" }; 141 if (std.mem.eql(u8, record.action_kind, fork.undo_action)) return .{ .action = "fork", .description = "created a fork to draft a patch in" }; 142 if (std.mem.eql(u8, record.action_kind, pch.mergeability_undo_action)) return .{ .action = "mergeability", .description = "rechecked whether the open patches still merge cleanly" }; 143 if (std.mem.eql(u8, record.action_kind, find.undo_action)) return .{ .action = "file index", .description = "reindexed the files on a branch" }; 144 if (std.mem.eql(u8, record.action_kind, srch_cmmt.undo_action)) return .{ .action = "commit index", .description = "reindexed the commits on a branch" }; 145 if (std.mem.eql(u8, record.action_kind, push.undo_action)) { 146 // a push consumes the events it carried in its own transaction 147 var buttons: std.ArrayList(DetailButton) = .empty; 148 if (try pushUser(arena, haxy_moment, record.payload)) |button| try buttons.append(aa, button); 149 const count = try momentEventCount(opts, core, moment, history_index) orelse 0; 150 if (count > 0) try buttons.append(aa, try eventsButton(arena, identity, history_index, count)); 151 try pushRefs(arena, identity, record.payload, &buttons); 152 return .{ .action = "push", .description = "received a push", .buttons = buttons.items }; 153 } 154 if (!std.mem.eql(u8, record.action_kind, evt.undo_action)) return null; 155 156 const count = try momentEventCount(opts, core, moment, history_index) orelse return .{ .action = "events", .description = "updated the event database" }; 157 // a transaction that consumed only merge commits indexed no events of its own 158 if (count == 0) return .{ .action = "events", .description = "merged the event history" }; 159 const buttons = try aa.dupe(DetailButton, &.{try eventsButton(arena, identity, history_index, count)}); 160 return .{ 161 .action = try std.fmt.allocPrint(aa, "events ({d})", .{count}), 162 .description = "updated the event database", 163 .buttons = buttons, 164 }; 165 } 166 167 // xit formats the transactions haxy has no name of its own for 168 fn formatRecord(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, core: *rp.Repo(.xit, opts).Core, record: ?xit.undo.Record) ![]const u8 { 169 const value = record orelse return "no undo record"; 170 var description: std.Io.Writer.Allocating = .init(arena.allocator()); 171 try xit.undo.format(opts, core, arena.child_allocator, value, &description.writer); 172 return description.written(); 173 } 174 175 // the transaction an undo restored, as a button that reads like its own row. 176 // the error set is explicit because this and `eventDetail` call each other 177 fn undoneButton( 178 comptime opts: rp.RepoOpts(.xit), 179 arena: *std.heap.ArenaAllocator, 180 core: *rp.Repo(.xit, opts).Core, 181 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 182 identity: []const u8, 183 undo: xit.undo.Undo, 184 ) anyerror!DetailButton { 185 var buffer: [opts.max_read_size]u8 = undefined; 186 const target = try xit.undo.undoneTarget(opts, core, arena.child_allocator, undo, &buffer); 187 188 // the detail reads the event count out of the transaction's own moment 189 const moment = try core.momentAt(target.index); 190 const detail = if (target.record) |value| try eventDetail(opts, arena, core, haxy_moment, identity, value, moment, target.index) else null; 191 const route = ui.RoutablePage.repoUndoRoute(identity, target.index) orelse return error.RouteTooLong; 192 return .{ 193 .label = if (target.redo) " what was redone " else " what was undone ", 194 .text = if (detail) |value| value.action else try formatRecord(opts, arena, core, target.record), 195 .link = try std.fmt.allocPrint(arena.allocator(), "a:{s}", .{try route.toUrl(arena)}), 196 }; 197 } 198 199 // the page reads the transaction's own moment, so the url names the 200 // transaction rather than the state it produced 201 fn eventsButton(arena: *std.heap.ArenaAllocator, identity: []const u8, history_index: u64, count: u64) !DetailButton { 202 const aa = arena.allocator(); 203 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", history_index) orelse return error.RouteTooLong; 204 return .{ 205 .label = " events ", 206 .text = try std.fmt.allocPrint(aa, "view events ({d})", .{count}), 207 .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(arena)}), 208 }; 209 } 210 211 // a button per ref the push moved, naming what it did to it: a changed branch 212 // links to the commits it added, anything still there to the ref itself 213 fn pushRefs(arena: *std.heap.ArenaAllocator, identity: []const u8, payload: []const u8, buttons: *std.ArrayList(DetailButton)) !void { 214 const head_prefix = "refs/heads/"; 215 const tag_prefix = "refs/tags/"; 216 const Payload = struct { 217 const Ref = struct { name: []const u8, old: []const u8, new: []const u8 }; 218 refs: []const Ref = &.{}, 219 }; 220 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 221 error.OutOfMemory => return err, 222 else => return, 223 }; 224 defer parsed.deinit(); 225 226 const aa = arena.allocator(); 227 for (parsed.value.refs) |ref| { 228 // anything that is neither a head nor a tag is skipped 229 const head = std.mem.startsWith(u8, ref.name, head_prefix); 230 const prefix = if (head) head_prefix else tag_prefix; 231 if (!std.mem.startsWith(u8, ref.name, prefix)) continue; 232 const name = ref.name[prefix.len..]; 233 234 // a removed ref has nothing left to link to. a branch that was created 235 // has no range to show either: what it added is whatever the other 236 // refs did not already reach, which a base cannot express 237 const removed = ref.new.len == 0; 238 const created = ref.old.len == 0; 239 const route = if (removed) 240 null 241 else if (head and !created) 242 ui.RoutablePage.repoCommitsRoute(identity, .object, ref.new, 0, "", ref.old) orelse continue 243 else 244 ui.RoutablePage.repoFilesRoute(identity, if (head) .branch else .tag, try ui.urlEncodeRef(aa, name), "", 0) orelse continue; 245 246 const action = if (removed) "removed" else if (created) "created" else "changed"; 247 try buttons.append(aa, .{ 248 .label = try std.fmt.allocPrint(aa, " {s} {s} ", .{ if (head) "branch" else "tag", action }), 249 .text = try aa.dupe(u8, name), 250 .link = if (route) |value| try std.fmt.allocPrint(aa, "a:{s}", .{try value.toUrl(arena)}) else null, 251 }); 252 } 253 } 254 255 // the push record stores the email of the user whose key the server accepted. 256 // resolving it names them as they are now, and leaves the email when no user 257 // holds it, which is also what local mode sees with no admin db to read 258 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 259 const Payload = struct { author: ?struct { email: []const u8 } = null }; 260 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 261 error.OutOfMemory => return err, 262 else => return null, 263 }; 264 defer parsed.deinit(); 265 const author = parsed.value.author orelse return null; 266 return switch (try ui.Author.initFromEmail(haxy_moment, arena, 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 formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 316 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 317 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 318 const year = seconds.getEpochDay().calculateYearDay(); 319 const month = year.calculateMonthDay(); 320 const day = seconds.getDaySeconds(); 321 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 322 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() }); 323 } 324 325 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 326 if (source.repo_kind != .xit) return error.NotFound; 327 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 328 defer any_repo.deinit(io, allocator); 329 switch (any_repo) { 330 inline else => |*repo| try repo.undo(io, index), 331 } 332 } 333 334 // discard every earlier transaction by compacting the database. the patch 335 // revisions events point at are unreachable from the refs, so they are named 336 // as extra roots to survive the collection. 337 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 338 if (source.repo_kind != .xit) return error.NotFound; 339 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 340 defer any_repo.deinit(io, allocator); 341 switch (any_repo) { 342 inline else => |*repo| { 343 const opts = repo.self_repo_opts; 344 // a repo that has consumed no events has no revisions to protect 345 const roots = if (evt.currentMoment(opts, repo)) |moment| 346 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 347 else |_| 348 &.{}; 349 defer allocator.free(roots); 350 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 351 }, 352 } 353 } 354 355 // keep action failures on the current tab without rebuilding a stale route. 356 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 357 perform(allocator, session, target) catch |err| { 358 session.data.undo_failure = @errorName(err); 359 return; 360 }; 361 session.data.undo_failure = null; 362 } 363 364 // both terminal hosts resolve and authorize the request afresh before opening 365 // the repository; the web handler uses its matching http authorization path. 366 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 367 const identity = target.name.slice(); 368 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 369 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 370 const io = session.io orelse return error.NotFound; 371 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 372 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 373 } 374 375 // the repo an owner may act on, or null when they may not 376 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 377 if (try session.authorize(identity, .owner) == null) return null; 378 if (session.local) |local| return local; 379 const admin_repo = session.admin_repo orelse return error.NotFound; 380 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 381 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 382 const found = try evt.Repo.readByOwnerAndName(evt.AdminDB, evt.admin_repo_opts.hash, moment, session.page_arena, pair.owner, pair.name) orelse return error.NotFound; 383 const hex = std.fmt.bytesToHex(found.event_id, .lower); 384 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 385 } 386 387 pub const View = struct { 388 box: wgt.Box(ui.Widget), 389 data: *const Self, 390 session: *ui.Session, 391 detailed_index: ?usize = null, 392 shown_failure: ?[]const u8 = null, 393 394 const list_max_width = 20; 395 const detail_min_width = 40; 396 const header_index = 0; 397 const content_index = 1; 398 // the undo button and the timestamp; everything below them is rebuilt 399 const fixed_rows = 2; 400 401 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 402 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 403 errdefer outer.deinit(allocator); 404 405 // the sub header carries the tab's only action 406 { 407 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 408 errdefer header.deinit(allocator); 409 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 410 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 411 try addText(allocator, &header, "clear undo history", link, .single); 412 header.getFocus().child_id = header.children.keys()[0]; 413 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 414 } 415 416 if (data.clear) { 417 var center = try initClearForm(allocator, data, session); 418 errdefer center.deinit(allocator); 419 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 420 outer.getFocus().child_id = outer.children.keys()[content_index]; 421 return .{ .box = outer, .data = data, .session = session }; 422 } 423 424 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 425 errdefer box.deinit(allocator); 426 { 427 var scroll = blk: { 428 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 429 errdefer rows.deinit(allocator); 430 for (data.items) |item| { 431 try addText(allocator, &rows, item.action, item.link, .hidden); 432 // a transaction a later undo discarded says so on its border 433 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 434 } 435 if (data.next) |next| { 436 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 437 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 438 } 439 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 440 rows.getFocus().child_id = rows.children.keys()[0]; 441 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 442 }; 443 errdefer scroll.deinit(allocator); 444 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 } }); 445 } 446 { 447 var scroll = blk: { 448 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 449 errdefer details.deinit(allocator); 450 if (data.items.len > 0) { 451 try addText(allocator, &details, "", null, .single); 452 try addLabeled(allocator, &details, " timestamp ", "", null); 453 details.getFocus().child_id = details.children.keys()[0]; 454 } 455 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 456 }; 457 errdefer scroll.deinit(allocator); 458 scroll.getFocus().mode = .mouse; 459 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 460 } 461 box.getFocus().child_id = box.children.keys()[0]; 462 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 463 outer.getFocus().child_id = outer.children.keys()[content_index]; 464 return .{ .box = outer, .data = data, .session = session }; 465 } 466 467 // the same shape the thread views use to confirm a removal 468 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 469 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 470 errdefer box.deinit(allocator); 471 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 472 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 473 474 var prompt = try wgt.Text.init(allocator, "are you sure?"); 475 errdefer prompt.deinit(allocator); 476 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 477 478 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 479 errdefer button.deinit(allocator); 480 button.getFocus().mode = .all; 481 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 482 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 483 box.getFocus().child_id = button.getFocus().id; 484 485 return ui.widget.Center.init(allocator, .{ .box = box }); 486 } 487 488 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 489 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 490 errdefer row.deinit(allocator); 491 row.getFocus().mode = .all; 492 if (link) |value| row.getFocus().kind = .{ .custom = value }; 493 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 494 } 495 496 // a labeled row below the timestamp, added as the selected action needs it 497 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 498 try addText(allocator, box, text, link, .single); 499 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 500 } 501 502 // enter counts only on the focused button; a click counts anywhere on it 503 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 504 return switch (key) { 505 .enter => root_focus.grandchild_id == button_id, 506 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 507 else => false, 508 }; 509 } 510 511 // the terminal hosts run the action after rendering; the web renderer 512 // posts the form instead, and wasm has no repository to act on 513 fn requestUndo(self: *View, route: ui.RoutablePage) void { 514 if (builtin.target.cpu.arch == .wasm32) return; 515 if (!self.session.is_terminal or self.session.host_request != null) return; 516 self.session.host_request = .{ .undo = route.repo_undo }; 517 } 518 519 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 520 return &self.box.children.values()[header_index].widget.box; 521 } 522 fn headerActive(self: *View) bool { 523 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 524 } 525 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 526 return &self.box.children.values()[content_index].widget.box; 527 } 528 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 529 return &self.contentBox().children.values()[0].widget.scroll; 530 } 531 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 532 return &self.contentBox().children.values()[1].widget.scroll; 533 } 534 fn selected(self: *View) ?usize { 535 const rows = &self.listScroll().child.box; 536 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 537 return if (index < self.data.items.len) index else null; 538 } 539 fn detailActive(self: *View) bool { 540 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 541 } 542 543 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 544 const index = self.selected() orelse return; 545 const failure = self.session.data.undo_failure orelse self.data.failure; 546 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 547 const details = &self.detailScroll().child.box; 548 while (details.children.count() > fixed_rows) { 549 var entry = details.children.pop() orelse break; 550 entry.value.widget.deinit(allocator); 551 } 552 const item = self.data.items[index]; 553 const enabled = self.data.can_undo and item.index > 0; 554 555 // the fixed rows are filled first, since adding the ones below 556 // invalidates every pointer into the box 557 { 558 const button = &details.children.values()[0].widget.text_box; 559 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"); 560 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 561 } 562 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 563 564 // some actions put buttons where the description would go 565 if (item.buttons.len == 0) { 566 try addLabeled(allocator, details, " description ", item.description, null); 567 } else for (item.buttons) |detail_button| { 568 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 569 } 570 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 571 self.shown_failure = failure; 572 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 573 details.getFocus().child_id = details.children.keys()[0]; 574 self.detailScroll().x = 0; 575 self.detailScroll().y = 0; 576 self.detailScroll().getFocus().version +%= 1; 577 self.detailed_index = index; 578 } 579 580 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 581 self.clearGrid(); 582 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 583 try self.refreshDetail(allocator); 584 const rows = &self.listScroll().child.box; 585 if (self.data.items.len == 0) { 586 const failure = self.session.data.undo_failure orelse self.data.failure; 587 if (!std.meta.eql(self.shown_failure, failure)) { 588 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 589 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 590 self.shown_failure = failure; 591 } 592 } 593 for (rows.children.keys(), rows.children.values()) |id, *child| child.widget.text_box.options.border_style = if (rows.getFocus().child_id == id) .single else .hidden; 594 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 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 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 608 return; 609 } 610 if (self.data.clear) { 611 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 612 return; 613 } 614 if (direction == .up and self.contentAtTop()) { 615 root_focus.setFocus(self.headerBox().getFocus().id); 616 return; 617 } 618 try self.refreshDetail(allocator); 619 if (self.detailActive()) { 620 const details = &self.detailScroll().child.box; 621 if (self.selected()) |index| { 622 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 623 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 624 return; 625 } 626 } 627 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); 628 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 629 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 630 } else switch (key) { 631 .arrow_right, .enter => if (self.selected() != null) { 632 root_focus.setFocus(self.detailScroll().getFocus().id); 633 }, 634 else => {}, 635 } 636 } 637 638 // the confirm button submits the form the web renderer posts 639 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 640 const center = &self.box.children.values()[content_index].widget.center; 641 const button_id = center.child.box.getFocus().child_id orelse return; 642 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 643 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 644 } 645 646 fn contentAtTop(self: *View) bool { 647 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 648 const rows = &scroll.child.box; 649 const id = rows.getFocus().child_id orelse return true; 650 return rows.children.getIndex(id) == 0 and scroll.y == 0; 651 } 652 653 // moving up from the sub header returns to the page's tabs 654 pub fn atTop(self: *View) bool { 655 return self.headerActive(); 656 } 657 658 // arriving from the page's tabs lands on the sub header, not the content 659 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 660 root_focus.setFocus(self.headerBox().getFocus().id); 661 return true; 662 } 663 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 664 self.box.deinit(allocator); 665 } 666 pub fn clearGrid(self: *View) void { 667 self.box.clearGrid(); 668 } 669 pub fn getGrid(self: View) ?Grid { 670 return self.box.getGrid(); 671 } 672 pub fn getFocus(self: *View) *Focus { 673 return self.box.getFocus(); 674 } 675 }; 676