╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │486 │ │487 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(admin_repo_path) orelse ".", "repos" }); │ │488 defer allocator.free(repos_dir); │ │466 - const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, repo_base[repo_prefix.len..], false)) { │ │489 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, parts.repo_base[repo_prefix.len..], false)) {│ │490 .ok => |p| p, │ │491 .invalid, .not_found => { │ │492 try request.respond(not_found, .{ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │505 var arena = std.heap.ArenaAllocator.init(allocator); │ │506 defer arena.deinit(); │ │507 │ │485 - const issue_maybe: ?evt.Issue = blk: { │ │486 - const DB = rp.Repo(.xit, repo_opts).DB; │ │487 - const moment = evt.currentMoment(repo_opts, &repo) catch break :blk null; │ │488 - const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue"))) orelse break :blk null;│ │489 - const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ │490 - const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &id_bytes))) orelse break :blk null; │ │491 - const issue_map = try DB.HashMap(.read_only).init(issue_cursor); │ │492 - break :blk try evt.read(evt.Issue, DB, repo_opts.hash, &arena, issue_map); │ │493 - }; │ │494 - const issue = issue_maybe orelse { │ │508 + const issue = (try readIssue(&repo, &arena, &parts.id_bytes)) orelse { │ │509 try request.respond(not_found, .{ │ │510 .status = .not_found, │ │511 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │532 }); │ │533 } │ │534 │ │535 +// replace the title/tags/description of the issue the url names (base is │ │536 +// "/repo/<owner>/<name>/issues/<id>", the form posts to "<base>/edit") by │ │537 +// re-emitting its event with its status preserved, then redirect back to it │ │538 +fn handleIssueEdit( │ │539 + io: std.Io, │ │540 + request: *std.http.Server.Request, │ │541 + allocator: std.mem.Allocator, │ │542 + base: []const u8, │ │543 + admin_repo_path: []const u8, │ │544 +) !void { │ │545 + const repo_prefix = "/repo/"; │ │546 + const id_len = evt.event_id_size * 2; │ │547 + │ │548 + var body_buf: [256]u8 = undefined; │ │549 + const reader = request.readerExpectNone(&body_buf); │ │550 + const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; │ │551 + defer allocator.free(body); │ │552 + │ │553 + const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); │ │554 + defer allocator.free(title); │ │555 + const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); │ │556 + defer allocator.free(tags); │ │557 + const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); │ │558 + defer allocator.free(description_crlf); │ │559 + // form submission normalizes textarea line breaks to CRLF; store plain │ │560 + // newlines so the text renders the same on every host │ │561 + const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); │ │562 + defer allocator.free(description); │ │563 + │ │564 + // invalid fields send the user back to the edit form │ │565 + if (!issueFieldsValid(title, tags)) { │ │566 + const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); │ │567 + defer allocator.free(form_location); │ │568 + try request.respond("", .{ │ │569 + .status = .see_other, │ │570 + .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ │571 + }); │ │572 + return; │ │573 + } │ │574 + │ │575 + const not_found = "issue not found"; │ │576 + const parts = issueBaseParts(base) orelse { │ │577 + try request.respond(not_found, .{ │ │578 + .status = .not_found, │ │579 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │580 + }); │ │581 + return; │ │582 + }; │ │583 + │ │584 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(admin_repo_path) orelse ".", "repos" }); │ │585 + defer allocator.free(repos_dir); │ │586 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, parts.repo_base[repo_prefix.len..], false)) {│ │587 + .ok => |p| p, │ │588 + .invalid, .not_found => { │ │589 + try request.respond(not_found, .{ │ │590 + .status = .not_found, │ │591 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │592 + }); │ │593 + return; │ │594 + }, │ │595 + }; │ │596 + defer allocator.free(repo_path); │ │597 + │ │598 + const repo_opts: rp.RepoOpts(.xit) = .{}; │ │599 + var repo = try rp.Repo(.xit, repo_opts).open(io, allocator, .{ .path = repo_path }); │ │600 + defer repo.deinit(io, allocator); │ │601 + │ │602 + var arena = std.heap.ArenaAllocator.init(allocator); │ │603 + defer arena.deinit(); │ │604 + │ │605 + const issue = (try readIssue(&repo, &arena, &parts.id_bytes)) orelse { │ │606 + try request.respond(not_found, .{ │ │607 + .status = .not_found, │ │608 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │609 + }); │ │610 + return; │ │611 + }; │ │612 + │ │613 + var updated = issue; │ │614 + updated.title = title; │ │615 + updated.tags = tags; │ │616 + updated.description = description; │ │617 + try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{.{ │ │618 + .id = base[base.len - id_len ..][0..id_len].*, │ │619 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │620 + .event = .{ .issue = updated }, │ │621 + }}); │ │622 + │ │623 + try request.respond("", .{ │ │624 + .status = .see_other, │ │625 + .extra_headers = &.{.{ .name = "location", .value = base }}, │ │626 + }); │ │627 +} │ │628 + │ │629 fn renderIndexHtml( │ │630 io: std.Io, │ │631 allocator: std.mem.Allocator, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 998 try out.appendSlice(allocator, "\" name=\""); │ │ 999 try appendEscapedHtml(allocator, &out, ti.options.name); │ │1000 } │ │ 893 - // intentionally no `value` attribute: the browser tracks user input │ │ 894 - // natively, and including the wasm-side value here would make the │ │ 895 - // overlay HTML differ on every keystroke — that would trip the diff │ │ 896 - // in _setOverlay and rebuild the <input>, eating the user's caret. │ │1001 + // never the live content as the `value`: the browser tracks user │ │1002 + // input natively, and including the wasm-side value here would make │ │1003 + // the overlay HTML differ on every keystroke — that would trip the │ │1004 + // diff in _setOverlay and rebuild the <input>, eating the user's │ │1005 + // caret. a page-constant initial value is safe. │ │1006 + if (session.input_values.get(inner_id)) |value| { │ │1007 + try out.appendSlice(allocator, "\" value=\""); │ │1008 + try appendEscapedHtml(allocator, &out, value); │ │1009 + } │ │1010 try out.appendSlice(allocator, "\" style=\"left:"); │ │1011 var pos_buf: [64]u8 = undefined; │ │1012 try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:1em", .{ inner_left, inner_top, inner_width }));│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1026 try out.appendSlice(allocator, "\" name=\""); │ │1027 try appendEscapedHtml(allocator, &out, ti.options.name); │ │1028 } │ │ 916 - // like the input above, no content inside: the browser │ │ 917 - // tracks the value and the overlay diff must stay stable │ │ 918 - // across keystrokes │ │1029 + // like the input above, only a page-constant initial value │ │1030 + // inside: the browser tracks the live value and the │ │1031 + // overlay diff must stay stable across keystrokes │ │1032 try out.appendSlice(allocator, "\" style=\"left:"); │ │1033 var pos_buf: [128]u8 = undefined; │ │1034 try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ inner_left, inner_top, inner_width, inner_height }));│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 922 - try out.appendSlice(allocator, "\"></textarea>"); │ │1035 + try out.appendSlice(allocator, "\">"); │ │1036 + if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value);│ │1037 + try out.appendSlice(allocator, "</textarea>"); │ │1038 }, │ │1039 .custom => |custom| { │ │1040 if (std.mem.eql(u8, "submit", custom)) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯