╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │93 if (method == .POST) { │ │94 switch (host) { │ │95 .remote => |remote| { │ │96 - const PostRoute = enum { login, logout, ansi, issue, open, close }; │ │96 + const PostRoute = enum { login, logout, ansi, issue, open, close, edit };│ │97 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │98 const suffix = "/" ++ field.name; │ │99 if (std.mem.endsWith(u8, path, suffix)) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │105 .issue => handleIssue(io, request, allocator, base, remote.admin_repo_path), │ │106 .open => handleIssueStatus(io, request, allocator, base, remote.admin_repo_path, .open), │ │107 .close => handleIssueStatus(io, request, allocator, base, remote.admin_repo_path, .closed),│ │108 + .edit => handleIssueEdit(io, request, allocator, base, remote.admin_repo_path), │ │109 }; │ │110 } │ │111 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │358 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n");│ │359 defer allocator.free(description); │ │360 │ │360 - // a title is required, and a too-long tag would fail consumption after │ │361 - // the event is already committed; both send the user back to the form │ │362 - const valid = blk: { │ │363 - if (title.len == 0) break :blk false; │ │364 - var tag_iter = evt.Issue.tagIterator(tags); │ │365 - while (tag_iter.next()) |tag| { │ │366 - if (tag.len > evt.Issue.tag_max_len) break :blk false; │ │367 - } │ │368 - break :blk true; │ │369 - }; │ │370 - if (!valid) { │ │361 + if (!issueFieldsValid(title, tags)) { │ │362 const form_location = try std.fmt.allocPrint(allocator, "{s}/issues/new", .{base}); │ │363 defer allocator.free(form_location); │ │364 try request.respond("", .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │415 .status = .see_other, │ │416 .extra_headers = &.{.{ .name = "location", .value = location }}, │ │417 }); │ │418 +} │ │419 + │ │420 +// a title is required, and a too-long tag would fail consumption after the │ │421 +// event is already committed │ │422 +fn issueFieldsValid(title: []const u8, tags: []const u8) bool { │ │423 + if (title.len == 0) return false; │ │424 + var tag_iter = evt.Issue.tagIterator(tags); │ │425 + while (tag_iter.next()) |tag| { │ │426 + if (tag.len > evt.Issue.tag_max_len) return false; │ │427 + } │ │428 + return true; │ │429 +} │ │430 + │ │431 +// split an issue url ("/repo/<owner>/<name>/issues/<id>") into the repo base │ │432 +// and the decoded id, null when it isn't one │ │433 +const IssueBaseParts = struct { repo_base: []const u8, id_bytes: [evt.event_id_size]u8 }; │ │434 +fn issueBaseParts(base: []const u8) ?IssueBaseParts { │ │435 + const repo_prefix = "/repo/"; │ │436 + const issues_infix = "/issues/"; │ │437 + const id_len = evt.event_id_size * 2; │ │438 + if (base.len < repo_prefix.len + issues_infix.len + id_len) return null; │ │439 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │440 + _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; │ │441 + const head = base[0 .. base.len - id_len]; │ │442 + if (!std.mem.endsWith(u8, head, issues_infix)) return null; │ │443 + const repo_base = head[0 .. head.len - issues_infix.len]; │ │444 + if (!std.mem.startsWith(u8, repo_base, repo_prefix)) return null; │ │445 + return .{ .repo_base = repo_base, .id_bytes = id_bytes }; │ │446 +} │ │447 + │ │448 +// the issue `id_bytes` names in `repo`'s consumed event db, or null │ │449 +fn readIssue( │ │450 + repo: *rp.Repo(.xit, .{}), │ │451 + arena: *std.heap.ArenaAllocator, │ │452 + id_bytes: *const [evt.event_id_size]u8, │ │453 +) !?evt.Issue { │ │454 + const repo_opts: rp.RepoOpts(.xit) = .{}; │ │455 + const DB = rp.Repo(.xit, repo_opts).DB; │ │456 + const moment = evt.currentMoment(repo_opts, repo) catch return null; │ │457 + const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue"))) orelse return null;│ │458 + const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ │459 + const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) orelse return null; │ │460 + const issue_map = try DB.HashMap(.read_only).init(issue_cursor); │ │461 + return try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map); │ │462 } │ │463 │ │464 // set the status of the issue the url names (base is │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │473 status: evt.Issue.Status, │ │474 ) !void { │ │475 const repo_prefix = "/repo/"; │ │441 - const issues_infix = "/issues/"; │ │476 const id_len = evt.event_id_size * 2; │ │443 - │ │444 - var id_bytes: [evt.event_id_size]u8 = undefined; │ │445 - const repo_base_maybe: ?[]const u8 = blk: { │ │446 - if (base.len < repo_prefix.len + issues_infix.len + id_len) break :blk null; │ │447 - _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch break :blk null;│ │448 - const head = base[0 .. base.len - id_len]; │ │449 - if (!std.mem.endsWith(u8, head, issues_infix)) break :blk null; │ │450 - const repo_base = head[0 .. head.len - issues_infix.len]; │ │451 - if (!std.mem.startsWith(u8, repo_base, repo_prefix)) break :blk null; │ │452 - break :blk repo_base; │ │453 - }; │ │477 │ │478 const not_found = "issue not found"; │ │456 - const repo_base = repo_base_maybe orelse { │ │479 + const parts = issueBaseParts(base) orelse { │ │480 try request.respond(not_found, .{ │ │481 .status = .not_found, │ │482 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 }));│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯