╭ 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 inp = @import("../input.zig"); 6 const xit = @import("xit"); 7 const rp = xit.repo; 8 const wgt = xit.xitui.widget; 9 const layout = xit.xitui.layout; 10 const Key = xit.xitui.input.Key; 11 const Grid = xit.xitui.grid.Grid; 12 const Focus = xit.xitui.focus.Focus; 13 const Self = @This(); 14 15 pub const page_size = 20; 16 pub const Item = struct { 17 index: u64, 18 action: []const u8, 19 description: []const u8, 20 link: []const u8, 21 form: []const u8, 22 }; 23 24 identity: []const u8, 25 count: u64, 26 items: []const Item, 27 next: ?u64, 28 29 pub fn init(comptime opts: rp.RepoOpts(.xit), arena: *std.heap.ArenaAllocator, repo: *rp.Repo(.xit, opts), identity: []const u8, selected: ?u64) !Self { 30 const aa = arena.allocator(); 31 const DB = rp.Repo(.xit, opts).DB; 32 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 33 const count = try history.count(); 34 if (selected) |index| if (index >= count) return error.NotFound; 35 var items: std.ArrayList(Item) = .empty; 36 var remaining = if (selected) |index| index + 1 else count; 37 var buffer: [opts.max_read_size]u8 = undefined; 38 while (remaining > 0 and items.items.len < page_size) { 39 remaining -= 1; 40 const cursor = try history.getCursor(remaining) orelse return error.TransactionNotFound; 41 const moment = try DB.HashMap(.read_only).init(cursor); 42 var record = try xit.undo.read(opts, moment, &buffer); 43 if (record) |*value| { 44 if (std.mem.eql(u8, value.action, "update_branch_patch")) value.action = "update_patch"; 45 } 46 var description: std.Io.Writer.Allocating = .init(aa); 47 if (record) |value| { 48 try xit.undo.format(opts, &repo.core.db, arena.child_allocator, value, &description.writer); 49 try description.writer.print("\n\n{s}", .{try formatTimestamp(aa, value.timestamp)}); 50 } else { 51 try description.writer.writeAll("(empty description)\n\ntimestamp unavailable"); 52 } 53 const route = ui.RoutablePage.repoUndoRoute(identity, remaining) orelse return error.RouteTooLong; 54 const url = try route.toUrl(arena); 55 try items.append(aa, .{ 56 .index = remaining, 57 .action = if (record) |value| try aa.dupe(u8, value.action) else "(empty description)", 58 .description = description.written(), 59 .link = try std.fmt.allocPrint(aa, "ai:{s}", .{url}), 60 .form = try std.fmt.allocPrint(aa, "form:{s}/undo", .{url}), 61 }); 62 } 63 return .{ .identity = try aa.dupe(u8, identity), .count = count, .items = items.items, .next = if (remaining > 0) remaining - 1 else null }; 64 } 65 66 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 67 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 68 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 69 const year = seconds.getEpochDay().calculateYearDay(); 70 const month = year.calculateMonthDay(); 71 const day = seconds.getDaySeconds(); 72 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 73 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() }); 74 } 75 76 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 77 if (source.repo_kind != .xit) return error.NotFound; 78 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 79 defer any_repo.deinit(io, allocator); 80 switch (any_repo) { 81 inline else => |*repo| try repo.undo(io, index), 82 } 83 } 84 85 // both terminal hosts resolve and authorize the request afresh before opening 86 // the repository; the web handler uses its matching http authorization path. 87 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 88 const index = target.index orelse return error.InvalidHistoryIndex; 89 const identity = target.name.slice(); 90 if (try session.authorize(identity, .write) == null) return error.Forbidden; 91 const io = session.io orelse return error.NotFound; 92 const source = session.local orelse blk: { 93 const admin_repo = session.admin_repo orelse return error.NotFound; 94 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 95 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 96 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; 97 const hex = std.fmt.bytesToHex(found.event_id, .lower); 98 break :blk ui.RepoSource{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ session.repos_dir orelse return error.NotFound, &hex }), .repo_kind = .xit }; 99 }; 100 try execute(io, allocator, source, index); 101 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 102 } 103 104 pub const View = struct { 105 box: wgt.Box(ui.Widget), 106 data: *const Self, 107 session: *ui.Session, 108 detailed_index: ?usize = null, 109 110 const list_max_width = 20; 111 const detail_min_width = 40; 112 113 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 114 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 115 errdefer box.deinit(allocator); 116 { 117 var scroll = blk: { 118 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 119 errdefer rows.deinit(allocator); 120 for (data.items) |item| try addText(allocator, &rows, item.action, item.link, .hidden); 121 if (data.next) |next| { 122 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 123 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 124 } 125 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 126 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 127 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 128 }; 129 errdefer scroll.deinit(allocator); 130 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 } }); 131 } 132 { 133 var scroll = blk: { 134 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 135 errdefer details.deinit(allocator); 136 if (data.items.len > 0) { 137 try addText(allocator, &details, "", null, .single); 138 try addText(allocator, &details, "", null, .single); 139 details.getFocus().child_id = details.children.keys()[0]; 140 } 141 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 142 }; 143 errdefer scroll.deinit(allocator); 144 scroll.getFocus().mode = .mouse; 145 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 146 } 147 box.getFocus().child_id = box.children.keys()[0]; 148 return .{ .box = box, .data = data, .session = session }; 149 } 150 151 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 152 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .rounded_corners = true, .wrap_kind = .word }); 153 errdefer row.deinit(allocator); 154 row.getFocus().mode = .all; 155 if (link) |value| row.getFocus().kind = .{ .custom = value }; 156 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 157 } 158 159 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 160 return &self.box.children.values()[0].widget.scroll; 161 } 162 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 163 return &self.box.children.values()[1].widget.scroll; 164 } 165 fn selected(self: *View) ?usize { 166 const rows = &self.listScroll().child.box; 167 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 168 return if (index < self.data.items.len) index else null; 169 } 170 fn detailActive(self: *View) bool { 171 return self.box.getFocus().child_id == self.detailScroll().getFocus().id; 172 } 173 174 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 175 const index = self.selected() orelse return; 176 if (self.detailed_index == index) return; 177 const details = &self.detailScroll().child.box; 178 const item = self.data.items[index]; 179 const button = &details.children.values()[0].widget.text_box; 180 const description = &details.children.values()[1].widget.text_box; 181 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"); 182 try description.setContent(allocator, item.description); 183 details.getFocus().kind = if (item.index > 0) .{ .custom = item.form } else .container; 184 button.getFocus().kind = if (item.index > 0) .{ .custom = "submit" } else .text_box; 185 details.getFocus().child_id = details.children.keys()[0]; 186 self.detailScroll().x = 0; 187 self.detailScroll().y = 0; 188 self.detailScroll().getFocus().version +%= 1; 189 self.detailed_index = index; 190 } 191 192 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 193 self.clearGrid(); 194 try self.refreshDetail(allocator); 195 const rows = &self.listScroll().child.box; 196 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; 197 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 198 self.box.children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 199 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 200 self.box.children.values()[1].min_size = .{ .width = width, .height = null }; 201 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 202 try self.box.build(allocator, constraint, root_focus); 203 } 204 205 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 206 try self.refreshDetail(allocator); 207 if (self.detailActive()) { 208 const details = &self.detailScroll().child.box; 209 if (self.selected()) |index| { 210 const button_id = details.children.keys()[0]; 211 const activated = switch (key) { 212 .enter => root_focus.grandchild_id == button_id, 213 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 214 else => false, 215 }; 216 if (activated and self.data.items[index].index > 0) { 217 if (builtin.target.cpu.arch != .wasm32 and self.session.is_terminal and self.session.host_request == null) { 218 const route = ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong; 219 self.session.host_request = .{ .undo = route.repo_undo }; 220 } 221 return; 222 } 223 } 224 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); 225 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 226 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 227 } else switch (key) { 228 .arrow_right, .enter => if (self.selected() != null) { 229 root_focus.setFocus(self.detailScroll().getFocus().id); 230 }, 231 else => {}, 232 } 233 } 234 235 pub fn atTop(self: *View) bool { 236 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 237 const rows = &scroll.child.box; 238 const id = rows.getFocus().child_id orelse return true; 239 return rows.children.getIndex(id) == 0 and scroll.y == 0; 240 } 241 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 242 self.box.deinit(allocator); 243 } 244 pub fn clearGrid(self: *View) void { 245 self.box.clearGrid(); 246 } 247 pub fn getGrid(self: View) ?Grid { 248 return self.box.getGrid(); 249 } 250 pub fn getFocus(self: *View) *Focus { 251 return self.box.getFocus(); 252 } 253 }; 254