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