╭ 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 return .{ .action = "push", .description = "received a push", .buttons = buttons.items }; 152 } 153 if (!std.mem.eql(u8, record.action_kind, evt.undo_action)) return null; 154 155 const count = try momentEventCount(opts, core, moment, history_index) orelse return .{ .action = "events", .description = "updated the event database" }; 156 // a transaction that consumed only merge commits indexed no events of its own 157 if (count == 0) return .{ .action = "events", .description = "merged the event history" }; 158 const buttons = try aa.dupe(DetailButton, &.{try eventsButton(arena, identity, history_index, count)}); 159 return .{ 160 .action = try std.fmt.allocPrint(aa, "events ({d})", .{count}), 161 .description = "updated the event database", 162 .buttons = buttons, 163 }; 164 } 165 166 // xit formats the transactions haxy has no name of its own for 167 fn formatRecord(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, core: *rp.Repo(.xit, opts).Core, record: ?xit.undo.Record) ![]const u8 { 168 const value = record orelse return "no undo record"; 169 var description: std.Io.Writer.Allocating = .init(arena.allocator()); 170 try xit.undo.format(opts, core, arena.child_allocator, value, &description.writer); 171 return description.written(); 172 } 173 174 // the transaction an undo restored, as a button that reads like its own row. 175 // the error set is explicit because this and `eventDetail` call each other 176 fn undoneButton( 177 comptime opts: rp.RepoOpts(.xit), 178 arena: *std.heap.ArenaAllocator, 179 core: *rp.Repo(.xit, opts).Core, 180 haxy_moment: ?evt.AdminDB.HashMap(.read_only), 181 identity: []const u8, 182 undo: xit.undo.Undo, 183 ) anyerror!DetailButton { 184 var buffer: [opts.max_read_size]u8 = undefined; 185 const target = try xit.undo.undoneTarget(opts, core, arena.child_allocator, undo, &buffer); 186 187 // the detail reads the event count out of the transaction's own moment 188 const moment = try core.momentAt(target.index); 189 const detail = if (target.record) |value| try eventDetail(opts, arena, core, haxy_moment, identity, value, moment, target.index) else null; 190 const route = ui.RoutablePage.repoUndoRoute(identity, target.index) orelse return error.RouteTooLong; 191 return .{ 192 .label = if (target.redo) " what was redone " else " what was undone ", 193 .text = if (detail) |value| value.action else try formatRecord(opts, arena, core, target.record), 194 .link = try std.fmt.allocPrint(arena.allocator(), "a:{s}", .{try route.toUrl(arena)}), 195 }; 196 } 197 198 // the page reads the transaction's own moment, so the url names the 199 // transaction rather than the state it produced 200 fn eventsButton(arena: *std.heap.ArenaAllocator, identity: []const u8, history_index: u64, count: u64) !DetailButton { 201 const aa = arena.allocator(); 202 const route = ui.RoutablePage.repoEventsRoute(identity, .active, null, "", history_index) orelse return error.RouteTooLong; 203 return .{ 204 .label = " events ", 205 .text = try std.fmt.allocPrint(aa, "view events ({d})", .{count}), 206 .link = try std.fmt.allocPrint(aa, "a:{s}", .{try route.toUrl(arena)}), 207 }; 208 } 209 210 // the push record stores the email of the user whose key the server accepted. 211 // resolving it names them as they are now, and leaves the email when no user 212 // holds it, which is also what local mode sees with no admin db to read 213 fn pushUser(arena: *std.heap.ArenaAllocator, haxy_moment: ?evt.AdminDB.HashMap(.read_only), payload: []const u8) !?DetailButton { 214 const Payload = struct { author: ?struct { email: []const u8 } = null }; 215 const parsed = std.json.parseFromSlice(Payload, arena.child_allocator, payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 216 error.OutOfMemory => return err, 217 else => return null, 218 }; 219 defer parsed.deinit(); 220 const author = parsed.value.author orelse return null; 221 return switch (try ui.Author.initFromEmail(haxy_moment, arena, author.email)) { 222 .unknown => null, 223 .email => |email| .{ .label = " user ", .text = email }, 224 .user_name => |name| .{ .label = " user ", .text = name, .link = try ui.userLink(arena, name) }, 225 }; 226 } 227 228 // how many events the transaction wrote: zero when it consumed nothing, null 229 // when its state cannot be read at all 230 fn momentEventCount( 231 comptime opts: rp.RepoOpts(.xit), 232 core: *rp.Repo(.xit, opts).Core, 233 moment: rp.Repo(.xit, opts).DB.HashMap(.read_only), 234 history_index: u64, 235 ) !?u64 { 236 const DB = rp.Repo(.xit, opts).DB; 237 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 238 const index = try momentIndex(opts, haxy_moment) orelse return null; 239 240 // a transaction that consumed nothing inherits the moment it was cloned 241 // from, whose events belong to the transaction that wrote it 242 inherited: { 243 if (history_index == 0) break :inherited; 244 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 245 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 246 } 247 248 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 249 return try ids.count(); 250 } 251 252 // the oldest transaction `record` discarded, or null when it is not an undo 253 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 254 const value = record orelse return null; 255 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 256 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 257 error.OutOfMemory => return err, 258 else => return null, 259 }; 260 defer parsed.deinit(); 261 return parsed.value.index; 262 } 263 264 // the moment's own position in the haxy history 265 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 266 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 267 return try cursor.readUint(); 268 } 269 270 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 271 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 272 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 273 const year = seconds.getEpochDay().calculateYearDay(); 274 const month = year.calculateMonthDay(); 275 const day = seconds.getDaySeconds(); 276 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 277 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() }); 278 } 279 280 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 281 if (source.repo_kind != .xit) return error.NotFound; 282 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 283 defer any_repo.deinit(io, allocator); 284 switch (any_repo) { 285 inline else => |*repo| try repo.undo(io, index), 286 } 287 } 288 289 // discard every earlier transaction by compacting the database. the patch 290 // revisions events point at are unreachable from the refs, so they are named 291 // as extra roots to survive the collection. 292 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 293 if (source.repo_kind != .xit) return error.NotFound; 294 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 295 defer any_repo.deinit(io, allocator); 296 switch (any_repo) { 297 inline else => |*repo| { 298 const opts = repo.self_repo_opts; 299 // a repo that has consumed no events has no revisions to protect 300 const roots = if (evt.currentMoment(opts, repo)) |moment| 301 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 302 else |_| 303 &.{}; 304 defer allocator.free(roots); 305 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 306 }, 307 } 308 } 309 310 // keep action failures on the current tab without rebuilding a stale route. 311 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 312 perform(allocator, session, target) catch |err| { 313 session.data.undo_failure = @errorName(err); 314 return; 315 }; 316 session.data.undo_failure = null; 317 } 318 319 // both terminal hosts resolve and authorize the request afresh before opening 320 // the repository; the web handler uses its matching http authorization path. 321 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 322 const identity = target.name.slice(); 323 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 324 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 325 const io = session.io orelse return error.NotFound; 326 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 327 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 328 } 329 330 // the repo an owner may act on, or null when they may not 331 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 332 if (try session.authorize(identity, .owner) == null) return null; 333 if (session.local) |local| return local; 334 const admin_repo = session.admin_repo orelse return error.NotFound; 335 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 336 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 337 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; 338 const hex = std.fmt.bytesToHex(found.event_id, .lower); 339 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 340 } 341 342 pub const View = struct { 343 box: wgt.Box(ui.Widget), 344 data: *const Self, 345 session: *ui.Session, 346 detailed_index: ?usize = null, 347 shown_failure: ?[]const u8 = null, 348 349 const list_max_width = 20; 350 const detail_min_width = 40; 351 const header_index = 0; 352 const content_index = 1; 353 // the undo button and the timestamp; everything below them is rebuilt 354 const fixed_rows = 2; 355 356 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 357 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 358 errdefer outer.deinit(allocator); 359 360 // the sub header carries the tab's only action 361 { 362 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 363 errdefer header.deinit(allocator); 364 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 365 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 366 try addText(allocator, &header, "clear undo history", link, .single); 367 header.getFocus().child_id = header.children.keys()[0]; 368 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 369 } 370 371 if (data.clear) { 372 var center = try initClearForm(allocator, data, session); 373 errdefer center.deinit(allocator); 374 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 375 outer.getFocus().child_id = outer.children.keys()[content_index]; 376 return .{ .box = outer, .data = data, .session = session }; 377 } 378 379 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 380 errdefer box.deinit(allocator); 381 { 382 var scroll = blk: { 383 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 384 errdefer rows.deinit(allocator); 385 for (data.items) |item| { 386 try addText(allocator, &rows, item.action, item.link, .hidden); 387 // a transaction a later undo discarded says so on its border 388 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 389 } 390 if (data.next) |next| { 391 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 392 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 393 } 394 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 395 rows.getFocus().child_id = rows.children.keys()[0]; 396 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 397 }; 398 errdefer scroll.deinit(allocator); 399 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 } }); 400 } 401 { 402 var scroll = blk: { 403 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 404 errdefer details.deinit(allocator); 405 if (data.items.len > 0) { 406 try addText(allocator, &details, "", null, .single); 407 try addLabeled(allocator, &details, " timestamp ", "", null); 408 details.getFocus().child_id = details.children.keys()[0]; 409 } 410 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 411 }; 412 errdefer scroll.deinit(allocator); 413 scroll.getFocus().mode = .mouse; 414 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 415 } 416 box.getFocus().child_id = box.children.keys()[0]; 417 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 418 outer.getFocus().child_id = outer.children.keys()[content_index]; 419 return .{ .box = outer, .data = data, .session = session }; 420 } 421 422 // the same shape the thread views use to confirm a removal 423 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 424 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 425 errdefer box.deinit(allocator); 426 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 427 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 428 429 var prompt = try wgt.Text.init(allocator, "are you sure?"); 430 errdefer prompt.deinit(allocator); 431 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 432 433 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 434 errdefer button.deinit(allocator); 435 button.getFocus().mode = .all; 436 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 437 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 438 box.getFocus().child_id = button.getFocus().id; 439 440 return ui.widget.Center.init(allocator, .{ .box = box }); 441 } 442 443 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 444 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 445 errdefer row.deinit(allocator); 446 row.getFocus().mode = .all; 447 if (link) |value| row.getFocus().kind = .{ .custom = value }; 448 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 449 } 450 451 // a labeled row below the timestamp, added as the selected action needs it 452 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 453 try addText(allocator, box, text, link, .single); 454 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 455 } 456 457 // enter counts only on the focused button; a click counts anywhere on it 458 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 459 return switch (key) { 460 .enter => root_focus.grandchild_id == button_id, 461 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 462 else => false, 463 }; 464 } 465 466 // the terminal hosts run the action after rendering; the web renderer 467 // posts the form instead, and wasm has no repository to act on 468 fn requestUndo(self: *View, route: ui.RoutablePage) void { 469 if (builtin.target.cpu.arch == .wasm32) return; 470 if (!self.session.is_terminal or self.session.host_request != null) return; 471 self.session.host_request = .{ .undo = route.repo_undo }; 472 } 473 474 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 475 return &self.box.children.values()[header_index].widget.box; 476 } 477 fn headerActive(self: *View) bool { 478 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 479 } 480 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 481 return &self.box.children.values()[content_index].widget.box; 482 } 483 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 484 return &self.contentBox().children.values()[0].widget.scroll; 485 } 486 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 487 return &self.contentBox().children.values()[1].widget.scroll; 488 } 489 fn selected(self: *View) ?usize { 490 const rows = &self.listScroll().child.box; 491 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 492 return if (index < self.data.items.len) index else null; 493 } 494 fn detailActive(self: *View) bool { 495 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 496 } 497 498 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 499 const index = self.selected() orelse return; 500 const failure = self.session.data.undo_failure orelse self.data.failure; 501 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 502 const details = &self.detailScroll().child.box; 503 while (details.children.count() > fixed_rows) { 504 var entry = details.children.pop() orelse break; 505 entry.value.widget.deinit(allocator); 506 } 507 const item = self.data.items[index]; 508 const enabled = self.data.can_undo and item.index > 0; 509 510 // the fixed rows are filled first, since adding the ones below 511 // invalidates every pointer into the box 512 { 513 const button = &details.children.values()[0].widget.text_box; 514 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"); 515 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 516 } 517 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 518 519 // some actions put buttons where the description would go 520 if (item.buttons.len == 0) { 521 try addLabeled(allocator, details, " description ", item.description, null); 522 } else for (item.buttons) |detail_button| { 523 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 524 } 525 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 526 self.shown_failure = failure; 527 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 528 details.getFocus().child_id = details.children.keys()[0]; 529 self.detailScroll().x = 0; 530 self.detailScroll().y = 0; 531 self.detailScroll().getFocus().version +%= 1; 532 self.detailed_index = index; 533 } 534 535 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 536 self.clearGrid(); 537 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 538 try self.refreshDetail(allocator); 539 const rows = &self.listScroll().child.box; 540 if (self.data.items.len == 0) { 541 const failure = self.session.data.undo_failure orelse self.data.failure; 542 if (!std.meta.eql(self.shown_failure, failure)) { 543 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 544 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 545 self.shown_failure = failure; 546 } 547 } 548 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; 549 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 550 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 551 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 552 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 553 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 554 try self.box.build(allocator, constraint, root_focus); 555 } 556 557 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 558 // the sub header sits above whichever content the route selects. 559 // scrolling crosses the boundary the same way the arrow keys do 560 const direction = inp.vertDirection(key); 561 if (self.headerActive()) { 562 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 563 return; 564 } 565 if (self.data.clear) { 566 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 567 return; 568 } 569 if (direction == .up and self.contentAtTop()) { 570 root_focus.setFocus(self.headerBox().getFocus().id); 571 return; 572 } 573 try self.refreshDetail(allocator); 574 if (self.detailActive()) { 575 const details = &self.detailScroll().child.box; 576 if (self.selected()) |index| { 577 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 578 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 579 return; 580 } 581 } 582 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); 583 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 584 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 585 } else switch (key) { 586 .arrow_right, .enter => if (self.selected() != null) { 587 root_focus.setFocus(self.detailScroll().getFocus().id); 588 }, 589 else => {}, 590 } 591 } 592 593 // the confirm button submits the form the web renderer posts 594 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 595 const center = &self.box.children.values()[content_index].widget.center; 596 const button_id = center.child.box.getFocus().child_id orelse return; 597 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 598 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 599 } 600 601 fn contentAtTop(self: *View) bool { 602 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 603 const rows = &scroll.child.box; 604 const id = rows.getFocus().child_id orelse return true; 605 return rows.children.getIndex(id) == 0 and scroll.y == 0; 606 } 607 608 // moving up from the sub header returns to the page's tabs 609 pub fn atTop(self: *View) bool { 610 return self.headerActive(); 611 } 612 613 // arriving from the page's tabs lands on the sub header, not the content 614 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 615 root_focus.setFocus(self.headerBox().getFocus().id); 616 return true; 617 } 618 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 619 self.box.deinit(allocator); 620 } 621 pub fn clearGrid(self: *View) void { 622 self.box.clearGrid(); 623 } 624 pub fn getGrid(self: View) ?Grid { 625 return self.box.getGrid(); 626 } 627 pub fn getFocus(self: *View) *Focus { 628 return self.box.getFocus(); 629 } 630 }; 631