╭ changed ────────────╮ │src/event/Comment.zig│ ╰─────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │149 } }, │ │150 }}); │ │151 return event_id; │ │152 +} │ │153 + │ │154 +// replace a comment's body while preserving its thread and parent. `repo` must │ │155 +// be writable. │ │156 +pub fn update( │ │157 + comptime repo_kind: rp.RepoKind, │ │158 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │159 + io: std.Io, │ │160 + allocator: std.mem.Allocator, │ │161 + repo: *rp.Repo(repo_kind, repo_opts), │ │162 + thread_id: *const [evt.event_id_size * 2]u8, │ │163 + comment_id: *const [evt.event_id_size]u8, │ │164 + body: []const u8, │ │165 + author_email: []const u8, │ │166 +) !void { │ │167 + if (!fieldsValid(body)) return error.InvalidFields; │ │168 + │ │169 + var arena = std.heap.ArenaAllocator.init(allocator); │ │170 + defer arena.deinit(); │ │171 + │ │172 + const DB = evt.EventDB(repo_opts.hash); │ │173 + var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null;│ │174 + defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); │ │175 + const moment = (if (event_db_maybe) |*event_db| │ │176 + evt.currentMomentFromDb(repo_opts.hash, event_db.db) │ │177 + else if (repo_kind == .git) │ │178 + return error.NotFound │ │179 + else │ │180 + evt.currentMoment(repo_opts, repo)) catch return error.NotFound; │ │181 + const comment = (try readById(DB, repo_opts.hash, moment, &arena, comment_id)) orelse return error.NotFound; │ │182 + if (!std.mem.eql(u8, &comment.event.thread_id, thread_id)) return error.NotFound; │ │183 + │ │184 + var updated = comment.event; │ │185 + updated.body = body; │ │186 + try evt.consume(repo_kind, repo_opts, io, allocator, repo, evt.events_ref, &.{.{ │ │187 + .id = std.fmt.bytesToHex(comment_id.*, .lower), │ │188 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │189 + .author_email = author_email, │ │190 + .event = .{ .comment = updated }, │ │191 + }}); │ │192 } │ │193 │ │194 // read a comment by event id, or null if the id isn't a known comment. field │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │111 │ │112 pub const RefKind = enum { branch, tag }; │ │113 │ │114 - pub const IssuesView = enum { open, closed, tags, new, edit, description, new_comment, conflicts, resolve }; │ │114 + pub const IssuesView = enum { open, closed, tags, new, edit, description, new_comment, edit_comment, conflicts, resolve };│ │115 │ │116 pub const RefOrOid = enum { │ │117 branch, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │375 return route; │ │376 } │ │377 │ │378 + // build the form route for editing a comment. │ │379 + pub fn repoCommentEditRoute(identity: []const u8, thread: []const u8, comment_id: []const u8) ?RoutablePage {│ │380 + if (thread.len == 0 or comment_id.len == 0) return null; │ │381 + var route = repoCommentsRoute(identity, thread, comment_id, 0) orelse return null; │ │382 + route.repo_issues.view = .edit_comment; │ │383 + return route; │ │384 + } │ │385 + │ │386 // build a `.repo_issues` route showing tags view, keeping the url-encoded │ │387 // `tag` filter ("" = unfiltered). │ │388 pub fn repoIssuesTagsRoute(identity: []const u8, tag: []const u8) ?RoutablePage { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │533 try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/new", .{ prefix, i.selected.slice() }) │ │534 else │ │535 try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/" ++ comment_seg ++ "{s}/new", .{ prefix, i.selected.slice(), i.comment.slice() }); │ │536 + if (i.view == .edit_comment) break :blk try std.fmt.allocPrint(arena.allocator(), "{s}/" ++ issue_seg ++ "{s}/" ++ comment_seg ++ "{s}/edit", .{ prefix, i.selected.slice(), i.comment.slice() });│ │537 if (i.view == .conflicts) break :blk if (i.selected.len == 0) │ │538 try std.fmt.allocPrint(arena.allocator(), "{s}/issues/conflicts", .{prefix}) │ │539 else │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │696 if (comment_id.len == 0) return null; │ │697 const word = params.scanTail(&segments) catch return null; │ │698 if (word) |last| { │ │690 - if (!std.mem.eql(u8, last, "new") or !params.only(&.{})) return null; │ │691 - return repoCommentNewRoute(pair, issue_id, comment_id); │ │699 + if (!params.only(&.{})) return null; │ │700 + if (std.mem.eql(u8, last, "new")) return repoCommentNewRoute(pair, issue_id, comment_id); │ │701 + if (std.mem.eql(u8, last, "edit")) return repoCommentEditRoute(pair, issue_id, comment_id);│ │702 + return null; │ │703 } │ │704 if (!params.only(&.{.start})) return null; │ │705 return repoCommentsRoute(pair, issue_id, comment_id, params.start() orelse return null); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Comment.zig│ ╰───────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │157 var reply = try linkBox(allocator, session, "new reply", ui.RoutablePage.repoCommentNewRoute(identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); │ │158 errdefer reply.deinit(allocator); │ │159 try bar.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = .{ .width = "new reply".len + 2, .height = null } }); │ │160 + │ │161 + var edit = try linkBox(allocator, session, "edit comment", ui.RoutablePage.repoCommentEditRoute(identity, &entry.comment.event.thread_id, &entry.id) orelse return error.RouteTooLong); │ │162 + errdefer edit.deinit(allocator); │ │163 + try bar.children.put(allocator, edit.getFocus().id, .{ .widget = .{ .text_box = edit }, .rect = null, .min_size = .{ .width = "edit comment".len + 2, .height = null } }); │ │164 │ │165 var permalink = try linkBox(allocator, session, "permalink", ui.RoutablePage.repoCommentsRoute(identity, &entry.comment.event.thread_id, &entry.id, 0) orelse return error.RouteTooLong);│ │166 errdefer permalink.deinit(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │279 .closed => closed_root = order_key, │ │280 } │ │281 // form urls keep their view; otherwise the issue's status picks it. │ │282 - if (view != .edit and view != .new_comment) resolved_view = switch (issue_event.event.status) { │ │282 + if (view != .edit and view != .new_comment and view != .edit_comment) resolved_view = switch (issue_event.event.status) {│ │283 .open => .open, │ │284 .closed => .closed, │ │285 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │605 .open => open_view_index, │ │606 .closed => closed_view_index, │ │607 .tags => tags_view_index, │ │608 - .new, .edit, .new_comment, .resolve => form_view_index, │ │608 + .new, .edit, .new_comment, .edit_comment, .resolve => form_view_index,│ │609 .conflicts => conflicts_view_index, │ │610 // init resolves a description url to its issue's status list │ │611 .description => unreachable, │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │665 // logged-out session can't create events, so the unauthorized view │ │666 // stands in. │ │667 if (session.data.is_local or session.data.user_id != null) { │ │668 - if (data.view == .new_comment) { │ │669 - const route = ui.RoutablePage.repoCommentNewRoute(data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong; │ │668 + if (data.view == .new_comment or data.view == .edit_comment) { │ │669 + const editing = data.view == .edit_comment; │ │670 + const route = if (editing) │ │671 + ui.RoutablePage.repoCommentEditRoute(data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong │ │672 + else │ │673 + ui.RoutablePage.repoCommentNewRoute(data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong; │ │674 const page_url = try route.toUrl(session.page_arena); │ │671 - const action = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}/comment", .{page_url[0 .. page_url.len - "/new".len]}); │ │672 - const parent_route = if (data.comment_page) |page| │ │673 - ui.RoutablePage.repoCommentsRoute(data.identity, data.selected_id, &page.selected.id, 0) │ │675 + const action = if (editing) │ │676 + try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{page_url}) │ │677 + else │ │678 + try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}/comment", .{page_url[0 .. page_url.len - "/new".len]}); │ │679 + const page = data.comment_page; │ │680 + const top_level = if (page) |p| std.mem.eql(u8, &p.selected.comment.event.parent_id, &p.selected.comment.event.thread_id) else false; │ │681 + const parent_route = if (editing) │ │682 + if (top_level) │ │683 + ui.RoutablePage.repoIssueCommentsRoute(data.identity, data.selected_id, 0) │ │684 + else if (page) |p| │ │685 + ui.RoutablePage.repoCommentsRoute(data.identity, data.selected_id, &p.selected.comment.event.parent_id, 0) │ │686 + else │ │687 + null │ │688 + else if (page) |p| │ │689 + ui.RoutablePage.repoCommentsRoute(data.identity, data.selected_id, &p.selected.id, 0) │ │690 else │ │691 ui.RoutablePage.repoIssueCommentsRoute(data.identity, data.selected_id, 0); │ │676 - var form = try initCommentForm(allocator, session, action, if (data.comment_page) |page| page.selected.author else if (data.selectedIssue()) |entry| entry.author else .unknown, parent_route orelse return error.RouteTooLong);│ │692 + const parent_author: ui.Author = if (editing) │ │693 + if (top_level) │ │694 + if (data.selectedIssue()) |entry| entry.author else .unknown │ │695 + else if (page) |p| │ │696 + p.selected.parent_author orelse .unknown │ │697 + else │ │698 + .unknown │ │699 + else if (page) |p| │ │700 + p.selected.author │ │701 + else if (data.selectedIssue()) |entry| │ │702 + entry.author │ │703 + else │ │704 + .unknown; │ │705 + const initial_body: ?[]const u8 = if (editing) if (page) |p| p.selected.comment.event.body else null else null; │ │706 + var form = try initCommentForm(allocator, session, action, parent_author, parent_route orelse return error.RouteTooLong, initial_body); │ │707 errdefer form.deinit(allocator); │ │708 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); │ │709 } else if (data.view == .resolve) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │721 try stack.children.put(allocator, form_widget.getFocus().id, form_widget); │ │722 } else { │ │723 const aa = session.page_arena.allocator(); │ │694 - const action = if (data.view == .edit) │ │695 - (if (data.identity.len == 0) │ │696 - try std.fmt.allocPrint(aa, "form:/issue:{s}/edit", .{data.selected_id}) │ │697 - else │ │698 - try std.fmt.allocPrint(aa, "form:/repo/{s}/issue:{s}/edit", .{ data.identity, data.selected_id }))│ │699 - else if (data.identity.len == 0) │ │700 - "form:/issue" │ │724 + const route = if (data.view == .edit) │ │725 + ui.RoutablePage.repoIssuesEditRoute(data.identity, data.selected_id) orelse return error.RouteTooLong │ │726 else │ │702 - try std.fmt.allocPrint(aa, "form:/repo/{s}/issue", .{data.identity}); │ │727 + ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; │ │728 + const action = try std.fmt.allocPrint(aa, "form:{s}", .{try route.toUrl(session.page_arena)}); │ │729 const issue = if (data.view == .edit) │ │730 (if (data.selectedIssue()) |entry| &entry.issue else null) │ │731 else │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯