╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────╮ │1245 } │ │1246 │ │1247 const button_in_row_index: usize = 1;│ │1248 + const edit_in_row_index: usize = 2; │ │1249 const title_child_index: usize = 0; │ │1250 const tags_child_index: usize = 1; │ │1251 │ ╰───────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │1271 return &row.children.values()[button_in_row_index].widget.text_box;│ │1272 } │ │1273 │ │1153 - fn statusButtonFocused(self: *View, index: usize) bool { │ │1274 + fn toolRowFocused(self: *View, index: usize) bool { │ │1275 const outer = self.detailOuter(index); │ │1276 const cid = outer.getFocus().child_id orelse return false; │ │1277 return outer.children.getIndex(cid) == tool_row_index; │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │1317 if (self.description_id[index]) |id| root_focus.setFocus(id); │ │1318 } │ │1319 │ │1199 - fn focusStatusButton(self: *View, index: usize, root_focus: *Focus) void { │ │1200 - if (self.statusButton(index)) |button| root_focus.setFocus(button.getFocus().id);│ │1320 + // return to the tool row's last-focused button. │ │1321 + fn focusToolRow(self: *View, index: usize, root_focus: *Focus) void { │ │1322 + const cid = self.toolRow(index).getFocus().child_id orelse return; │ │1323 + root_focus.setFocus(cid); │ │1324 } │ │1325 │ │1326 // enter the detail pane. an empty pane (no issues) can't be entered. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1436 }); │ │1437 } │ │1438 │ │1316 - // new-issue tab │ │1439 + // new-issue tab; an edit url shows the edit tab in its place │ │1440 { │ │1318 - const new_route = ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; │ │1319 - const new_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try new_route.toUrl(session.page_arena)}); │ │1320 - var text_box = try wgt.TextBox(ui.Widget).init(allocator, "new", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │1441 + const route = if (data.view == .edit) │ │1442 + ui.RoutablePage.repoIssuesEditRoute(data.identity, data.selected_id) orelse return error.RouteTooLong │ │1443 + else │ │1444 + ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; │ │1445 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │1446 + const label: []const u8 = if (data.view == .edit) "edit" else "new"; │ │1447 + var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │1448 errdefer text_box.deinit(allocator); │ │1449 text_box.getFocus().focusable = true; │ │1323 - text_box.getFocus().kind = .{ .custom = new_link }; │ │1450 + text_box.getFocus().kind = .{ .custom = link }; │ │1451 try tab_ids.put(allocator, text_box.getFocus().id, {}); │ │1452 try box.children.put(allocator, text_box.getFocus().id, .{ │ │1453 .widget = .{ .text_box = text_box }, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │1454 .rect = null, │ │1328 - .min_size = .{ .width = "new".len + 2, .height = null },│ │1455 + .min_size = .{ .width = label.len + 2, .height = null },│ │1456 }); │ │1457 } │ │1458 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │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" }}, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯