╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1865 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), │ │1866 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1867 + .enter => if (item) |comment| if (comment.removeFocused()) try self.removeComment(allocator, comment),│ │1868 + .mouse => |mouse| if (item) |comment| if (comment.remove_button_id) |id| { │ │1869 + if (inp.leftClickOn(root_focus, id, mouse)) try self.removeComment(allocator, comment); │ │1870 + }, │ │1871 else => return, │ │1872 } │ │1873 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │1874 │ │1875 + fn focusedComment(self: *View, index: usize, root_focus: *Focus) ?*Comment.Item { │ │1876 + const child_index = self.focusedDetailChild(index, root_focus) orelse return null;│ │1877 + return switch (self.detailInner(index).children.values()[child_index].widget) { │ │1878 + .repo_comment => |*comment| comment, │ │1879 + else => null, │ │1880 + }; │ │1881 + } │ │1882 + │ │1883 // page the detail scroll, then focus the first or last visible widget. │ │1884 fn pageDetail(self: *View, index: usize, root_focus: *Focus, delta: isize) void { │ │1885 if (!self.session.is_terminal) return; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2443 try self.session.navigate(route); │ │2444 } │ │2445 │ │2446 + fn removeIssue(self: *View, allocator: std.mem.Allocator, index: usize) !void { │ │2447 + const sel = self.detailed_index[index] orelse return; │ │2448 + const entry = self.window(index).issues[sel]; │ │2449 + │ │2450 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │2451 + _ = std.fmt.hexToBytes(&id_bytes, entry.id) catch return; │ │2452 + try self.removeEvent(allocator, .issue, id_bytes); │ │2453 + } │ │2454 + │ │2455 + fn removeComment(self: *View, allocator: std.mem.Allocator, comment: *Comment.Item) !void { │ │2456 + try self.removeEvent(allocator, .comment, comment.id); │ │2457 + } │ │2458 + │ │2459 + fn removeEvent(self: *View, allocator: std.mem.Allocator, kind: evt.EventKind, id: [evt.event_id_size]u8) !void { │ │2460 + if (comptime wasm) return; │ │2461 + const io = self.session.io orelse return; │ │2462 + const src = self.data.repo_source orelse return; │ │2463 + const author = (try self.session.eventAuthor()) orelse return; │ │2464 + │ │2465 + switch (src.repo_kind) { │ │2466 + inline else => |repo_kind| { │ │2467 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); │ │2468 + defer any_repo.deinit(io, allocator); │ │2469 + switch (any_repo) { │ │2470 + inline else => |*repo| try evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, kind, author),│ │2471 + } │ │2472 + }, │ │2473 + } │ │2474 + │ │2475 + const id_hex = std.fmt.bytesToHex(id, .lower); │ │2476 + const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, kind, &id_hex) orelse return; │ │2477 + try self.session.navigate(route); │ │2478 + } │ │2479 + │ │2480 // flip the shown issue's status by re-emitting its event, then reload the │ │2481 // page rooted at the issue so the view reflects the change. this is the │ │2482 // terminal path; the web posts the button's form to the status route. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │100 if (method == .POST) { │ │101 switch (host) { │ │102 .remote => |remote| { │ │103 - const PostRoute = enum { login, logout, ansi, new, edit, open, close, resolve, attach }; │ │103 + const PostRoute = enum { login, logout, ansi, new, edit, remove, open, close, resolve, attach };│ │104 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │105 const suffix = "/" ++ field.name; │ │106 if (std.mem.endsWith(u8, path, suffix)) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │111 .ansi => handleAnsi(io, request, allocator, base, remote.admin_repo_path, remote.session_store),│ │112 .new => handleNew(io, request, allocator, base, host), │ │113 .edit => handleEdit(io, request, allocator, base, host), │ │114 + .remove => handleRemove(io, request, allocator, base, host), │ │115 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │116 .close => handleIssueStatus(io, request, allocator, base, host, .closed), │ │117 .resolve => handleIssueResolve(io, request, allocator, base, host), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │121 } │ │122 }, │ │123 .local => |local| { │ │123 - const PostRoute = enum { new, edit, open, close, resolve, sync, attach }; │ │124 + const PostRoute = enum { new, edit, remove, open, close, resolve, sync, attach };│ │125 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │126 const suffix = "/" ++ field.name; │ │127 if (std.mem.endsWith(u8, path, suffix)) { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │129 return switch (@field(PostRoute, field.name)) { │ │130 .new => handleNew(io, request, allocator, base, host), │ │131 .edit => handleEdit(io, request, allocator, base, host), │ │132 + .remove => handleRemove(io, request, allocator, base, host), │ │133 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │134 .close => handleIssueStatus(io, request, allocator, base, host, .closed),│ │135 .resolve => handleIssueResolve(io, request, allocator, base, host), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │767 evt.currentMoment(repo_opts, repo)) catch return respondAttachmentNotFound(request); │ │768 │ │769 const record = (try evt.Attachment.readById(DB, repo_opts.hash, haxy_moment, &arena, id)) orelse return respondAttachmentNotFound(request);│ │768 - if (record.deleted) return respondAttachmentNotFound(request); │ │770 + if (record.removed) return respondAttachmentNotFound(request); │ │771 if (record.blob_oid.len != hash.hexLen(repo_opts.hash)) return respondAttachmentNotFound(request); │ │772 const blob_oid: *const [hash.hexLen(repo_opts.hash)]u8 = @ptrCast(record.blob_oid.ptr); │ │773 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1068 const head = base[0 .. base.len - id_len]; │ │1069 if (!std.mem.endsWith(u8, head, issues_infix)) return null; │ │1070 return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; │ │1071 +} │ │1072 + │ │1073 +const RemoveParts = struct { │ │1074 + repo_base: []const u8, │ │1075 + kind: evt.EventKind, │ │1076 + id: [evt.event_id_size]u8, │ │1077 +}; │ │1078 + │ │1079 +// split a remove url into the repo and event it names │ │1080 +fn removeParts(base: []const u8) ?RemoveParts { │ │1081 + if (attachmentRequest(base)) |attachment| { │ │1082 + const parent = attachParentParts(attachment.repo_base) orelse return null; │ │1083 + return .{ │ │1084 + .repo_base = parent.repo_base, │ │1085 + .kind = .attach, │ │1086 + .id = attachment.id, │ │1087 + }; │ │1088 + } │ │1089 + if (commentBaseParts(base)) |comment| if (comment.comment_id) |comment_id| return .{ │ │1090 + .repo_base = comment.repo_base, │ │1091 + .kind = .comment, │ │1092 + .id = comment_id, │ │1093 + }; │ │1094 + if (issueBaseParts(base)) |issue| return .{ │ │1095 + .repo_base = issue.repo_base, │ │1096 + .kind = .issue, │ │1097 + .id = issue.id_bytes, │ │1098 + }; │ │1099 + return null; │ │1100 +} │ │1101 + │ │1102 +// remove the issue, comment or attachment named by the form's url │ │1103 +fn handleRemove( │ │1104 + io: std.Io, │ │1105 + request: *std.http.Server.Request, │ │1106 + allocator: std.mem.Allocator, │ │1107 + base: []const u8, │ │1108 + host: Host, │ │1109 +) !void { │ │1110 + const parts = removeParts(base) orelse return respondRemoveNotFound(request); │ │1111 + │ │1112 + var author_arena = std.heap.ArenaAllocator.init(allocator); │ │1113 + defer author_arena.deinit(); │ │1114 + const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); │ │1115 + │ │1116 + switch (host) { │ │1117 + .remote => |remote| { │ │1118 + const repo_prefix = "/repo/"; │ │1119 + if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return respondRemoveNotFound(request); │ │1120 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │1121 + defer allocator.free(repos_dir); │ │1122 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { │ │1123 + .ok => |p| p, │ │1124 + .invalid, .not_found => return respondRemoveNotFound(request), │ │1125 + }; │ │1126 + defer allocator.free(repo_path); │ │1127 + │ │1128 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │1129 + defer repo.deinit(io, allocator); │ │1130 + evt.remove(.xit, .{}, io, allocator, &repo, &parts.id, parts.kind, author) catch |err| switch (err) { │ │1131 + error.EventNotFound => return respondRemoveNotFound(request), │ │1132 + else => |e| return e, │ │1133 + }; │ │1134 + }, │ │1135 + .local => |src| { │ │1136 + if (parts.repo_base.len != 0) return respondRemoveNotFound(request); │ │1137 + switch (src.repo_kind) { │ │1138 + inline else => |repo_kind| { │ │1139 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); │ │1140 + defer any_repo.deinit(io, allocator); │ │1141 + switch (any_repo) { │ │1142 + inline else => |*repo| evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id, parts.kind, author) catch |err| switch (err) {│ │1143 + error.EventNotFound => return respondRemoveNotFound(request), │ │1144 + else => |e| return e, │ │1145 + }, │ │1146 + } │ │1147 + }, │ │1148 + } │ │1149 + }, │ │1150 + } │ │1151 + │ │1152 + const id = std.fmt.bytesToHex(parts.id, .lower); │ │1153 + const location = try std.fmt.allocPrint(allocator, "{s}/event:{s}/kind:{s}", .{ parts.repo_base, &id, @tagName(parts.kind) }); │ │1154 + defer allocator.free(location); │ │1155 + try request.respond("", .{ │ │1156 + .status = .see_other, │ │1157 + .keep_alive = false, │ │1158 + .extra_headers = &.{.{ .name = "location", .value = location }}, │ │1159 + }); │ │1160 } │ │1161 │ │1162 +fn respondRemoveNotFound(request: *std.http.Server.Request) !void { │ │1163 + try request.respond("event not found", .{ │ │1164 + .status = .not_found, │ │1165 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │1166 + }); │ │1167 +} │ │1168 + │ │1169 // re-emit the issue `parts` names with `update` applied, in the repo the │ │1170 // host resolves the base to. error.NotFound when the base names no repo or │ │1171 // the id no issue. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1872 try out.appendSlice(allocator, "</textarea>"); │ │1873 }, │ │1874 .custom => |custom| { │ │1777 - if (std.mem.eql(u8, "submit", custom)) { │ │1875 + if (std.mem.eql(u8, "submit", custom) or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) {│ │1876 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); │ │1877 var id_buf: [32]u8 = undefined; │ │1878 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯