╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │1086 } else try copyable.input(allocator, key, root_focus), │ │1087 else => try copyable.input(allocator, key, root_focus), │ │1088 }, │ │ 985 - .box => |*row| if (Attachment.removeId(row)) |attachment_id| { │ │1089 + .box => |*row| if (isAssigneeRow(row)) { │ │1090 + try self.assigneeInput(allocator, child_index, row, key, root_focus);│ │1091 + } else if (Attachment.removeId(row)) |attachment_id| { │ │1092 const remove_id = row.children.keys()[1]; │ │1093 switch (key) { │ │1094 .arrow_left => if (!self.moveHorizontal(root_focus, false)) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1370 return true; │ │1371 } │ │1372 │ │1373 + // the input takes typing and assigns on enter; the buttons after it │ │1374 + // unassign │ │1375 + fn assigneeInput(self: *This, allocator: std.mem.Allocator, child_index: usize, row: *wgt.Box(Widget), key: Key, root_focus: *Focus) !void { │ │1376 + const text_input = &row.children.values()[0].widget.text_input; │ │1377 + const flow: ?*WordFlow = if (row.children.count() > 1) &row.children.values()[1].widget.word_flow else null; │ │1378 + if (root_focus.grandchild_id == text_input.getFocus().id) { │ │1379 + switch (key) { │ │1380 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │1381 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │1382 + .arrow_left => if (text_input.cursor == 0) { │ │1383 + self.exit = .list; │ │1384 + } else try text_input.input(allocator, key, root_focus), │ │1385 + .arrow_right => { │ │1386 + // right past the last character steps onto the first button │ │1387 + if (flow) |buttons| if (text_input.cursor == text_input.content.items.len) return self.focusLabel(child_index, buttons, root_focus, 0); │ │1388 + try text_input.input(allocator, key, root_focus); │ │1389 + }, │ │1390 + .enter => { │ │1391 + const text = try text_input.text(allocator); │ │1392 + defer allocator.free(text); │ │1393 + try self.assign(allocator, text); │ │1394 + }, │ │1395 + else => try text_input.input(allocator, key, root_focus), │ │1396 + } │ │1397 + return; │ │1398 + } │ │1399 + │ │1400 + const buttons = flow orelse return; │ │1401 + const cur = buttons.indexOfFocusId(root_focus.grandchild_id orelse return) orelse return; │ │1402 + const assignees = (self.entry orelse return).assignees; │ │1403 + switch (key) { │ │1404 + .arrow_left => if (cur > 0) self.focusLabel(child_index, buttons, root_focus, cur - 1) else root_focus.setFocus(text_input.getFocus().id), │ │1405 + .arrow_right => if (cur + 1 < buttons.text_boxes.items.len) self.focusLabel(child_index, buttons, root_focus, cur + 1), │ │1406 + .arrow_up => if (buttons.rowStep(cur, false)) |index| { │ │1407 + self.focusLabel(child_index, buttons, root_focus, index); │ │1408 + } else { │ │1409 + _ = self.moveVertical(root_focus, false); │ │1410 + }, │ │1411 + .arrow_down => if (buttons.rowStep(cur, true)) |index| { │ │1412 + self.focusLabel(child_index, buttons, root_focus, index); │ │1413 + } else { │ │1414 + _ = self.moveVertical(root_focus, true); │ │1415 + }, │ │1416 + .enter => try self.unassign(allocator, assignees[cur]), │ │1417 + .mouse => |mouse| if (inp.leftClickOn(root_focus, buttons.text_boxes.items[cur].getFocus().id, mouse)) try self.unassign(allocator, assignees[cur]),│ │1418 + else => {}, │ │1419 + } │ │1420 + } │ │1421 + │ │1422 fn labelsInput(self: *This, child_index: usize, labels: *WordFlow, key: Key, root_focus: *Focus) void { │ │1423 const child_id = labels.focus.child_id orelse return; │ │1424 const cur = labels.indexOfFocusId(child_id) orelse return; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1524 const id_hex = std.fmt.bytesToHex(id, .lower); │ │1525 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex, null) orelse return; │ │1526 try self.session.navigate(route); │ │1527 + } │ │1528 + │ │1529 + // assign the typed email, or the email of the user the text names │ │1530 + fn assign(self: *This, allocator: std.mem.Allocator, text: []const u8) !void { │ │1531 + if (comptime wasm) return; │ │1532 + const entry = self.entry orelse return; │ │1533 + const io = self.session.io orelse return; │ │1534 + const source = self.data.repo_source orelse return; │ │1535 + const actor = (try self.session.authorize(self.data.identity, .read, kind)) orelse return; │ │1536 + const thread_id = try evt.parseEventId(entry.id); │ │1537 + if (!try source.canModify(io, allocator, actor, kind, &thread_id)) return; │ │1538 + │ │1539 + var arena = std.heap.ArenaAllocator.init(allocator); │ │1540 + defer arena.deinit(); │ │1541 + const failure: ?ui.Session.FormFeedback.AssignmentFailure = blk: { │ │1542 + const moment = if (self.session.data.host_kind == .local) null else self.session.haxy_moment; │ │1543 + const email = switch (try ui.assigneeEmail(moment, &arena, text)) { │ │1544 + .email => |email| email, │ │1545 + .failure => |value| break :blk value, │ │1546 + }; │ │1547 + const thread_id_hex = std.fmt.bytesToHex(thread_id, .lower); │ │1548 + switch (source.repo_kind) { │ │1549 + inline else => |repo_kind| { │ │1550 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1551 + defer any_repo.deinit(io, allocator); │ │1552 + switch (any_repo) { │ │1553 + inline else => |*repo| _ = evt.Assignment.create(self.session.eventHost(), repo_kind, repo.self_repo_opts, io, allocator, repo, kind, &thread_id_hex, email, actor.author) catch |err| switch (err) { │ │1554 + error.AlreadyAssigned => break :blk .already_assigned, │ │1555 + error.InvalidFields => break :blk .not_an_email, │ │1556 + else => |e| return e, │ │1557 + }, │ │1558 + } │ │1559 + }, │ │1560 + } │ │1561 + break :blk null; │ │1562 + }; │ │1563 + if (failure) |value| { │ │1564 + self.session.data.form_feedback = .{ .assignment = .{ .failure = value, .text = try self.session.arena.allocator().dupe(u8, text) } }; │ │1565 + return; │ │1566 + } │ │1567 + try self.session.navigate(ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong); │ │1568 + } │ │1569 + │ │1570 + fn unassign(self: *This, allocator: std.mem.Allocator, email: []const u8) !void { │ │1571 + if (comptime wasm) return; │ │1572 + const entry = self.entry orelse return; │ │1573 + const io = self.session.io orelse return; │ │1574 + const source = self.data.repo_source orelse return; │ │1575 + const actor = (try self.session.authorize(self.data.identity, .read, kind)) orelse return; │ │1576 + const thread_id = try evt.parseEventId(entry.id); │ │1577 + if (!try source.canModify(io, allocator, actor, kind, &thread_id)) return; │ │1578 + │ │1579 + switch (source.repo_kind) { │ │1580 + inline else => |repo_kind| { │ │1581 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1582 + defer any_repo.deinit(io, allocator); │ │1583 + switch (any_repo) { │ │1584 + inline else => |*repo| evt.removeInThread(self.session.eventHost(), repo_kind, repo.self_repo_opts, io, allocator, repo, kind, &thread_id, .assign, &evt.Assignment.idOf(&thread_id, email), actor.author) catch |err| switch (err) {│ │1585 + // a stale button; the refresh below shows the row as it is │ │1586 + error.EventNotFound => {}, │ │1587 + else => |e| return e, │ │1588 + }, │ │1589 + } │ │1590 + }, │ │1591 + } │ │1592 + try self.session.navigate(ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong); │ │1593 } │ │1594 │ │1595 fn toggleStatus(self: *This, allocator: std.mem.Allocator) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭──────────────────────────────────────────────────────────╮ │21 const local_flash_cookie = "haxy_local_flash"; │ │22 const local_required_title_prefix = "required_title:";│ │23 const local_sync_failure_prefix = "sync:"; │ │24 +const local_assignment_prefix = "assignment:"; │ │25 │ │26 const Embed = struct { │ │27 path: []const u8, │ ╰──────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │157 switch (host) { │ │158 .server => |server| { │ │159 // "repo/new" and "user/new" come before "new", which would claim them │ │159 - const PostRoute = enum { login, logout, @"repo/new", @"user/new", user, settings, grant, revoke, promote, demote, new, edit, remove, open, close, resolve, publish, merge, squash, attach, undo, clear }; │ │160 + const PostRoute = enum { login, logout, @"repo/new", @"user/new", user, settings, grant, revoke, promote, demote, new, edit, remove, open, close, resolve, publish, merge, squash, attach, assign, unassign, undo, clear };│ │161 inline for (@typeInfo(PostRoute).@"enum".field_names) |name| { │ │162 const suffix = "/" ++ name; │ │163 if (std.mem.endsWith(u8, path, suffix)) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │185 .undo => handleUndo(io, request, allocator, base, host, .undo), │ │186 .clear => handleUndo(io, request, allocator, base, host, .clear),│ │187 .attach => handleAttach(io, request, allocator, base, host), │ │188 + .assign => handleAssign(io, request, allocator, base, host), │ │189 + .unassign => handleUnassign(io, request, allocator, base, host), │ │190 }; │ │191 } │ │192 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │193 }, │ │194 .local => |local| { │ │192 - const PostRoute = enum { new, edit, remove, open, close, resolve, sync, attach, undo, clear }; │ │195 + const PostRoute = enum { new, edit, remove, open, close, resolve, sync, attach, assign, unassign, undo, clear };│ │196 inline for (@typeInfo(PostRoute).@"enum".field_names) |name| { │ │197 const suffix = "/" ++ name; │ │198 if (std.mem.endsWith(u8, path, suffix)) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 .undo => handleUndo(io, request, allocator, base, host, .undo), │ │209 .clear => handleUndo(io, request, allocator, base, host, .clear),│ │210 .attach => handleAttach(io, request, allocator, base, host), │ │211 + .assign => handleAssign(io, request, allocator, base, host), │ │212 + .unassign => handleUnassign(io, request, allocator, base, host), │ │213 }; │ │214 } │ │215 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │305 .discussion => .{ .discussion = .{ .failure = .required_title } }, │ │306 else => null, │ │307 } else null; │ │308 + } else if (std.mem.startsWith(u8, raw, local_assignment_prefix)) { │ │309 + const rest = raw[local_assignment_prefix.len..]; │ │310 + if (std.mem.indexOfScalar(u8, rest, ':')) |colon| { │ │311 + if (std.meta.stringToEnum(ui.Session.FormFeedback.AssignmentFailure, rest[0..colon])) |failure| { │ │312 + const text = std.Uri.percentDecodeInPlace(try form_arena.allocator().dupe(u8, rest[colon + 1 ..]));│ │313 + form_feedback = .{ .assignment = .{ .failure = failure, .text = text } }; │ │314 + } │ │315 + } │ │316 } else if (std.mem.startsWith(u8, raw, local_sync_failure_prefix)) { │ │317 const value = try allocator.dupe(u8, raw[local_sync_failure_prefix.len..]); │ │318 sync_failure_allocated = value; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │430 return switch (host) { │ │431 .server => |server| respondFormFailure(request, allocator, server.session_store, location, feedback), │ │432 .local => { │ │420 - const tag = std.meta.activeTag(feedback); │ │421 - const required_title = switch (feedback) { │ │422 - .issue => |value| value.failure == .required_title, │ │423 - .discussion => |value| value.failure == .required_title, │ │424 - .patch => |value| value.failure == .required_title, │ │425 - else => false, │ │433 + var arena = std.heap.ArenaAllocator.init(allocator); │ │434 + defer arena.deinit(); │ │435 + const cookie = switch (feedback) { │ │436 + .assignment => |value| try arena.allocator().print( │ │437 + local_flash_cookie ++ "=" ++ local_assignment_prefix ++ "{s}:{s}; Path=/; HttpOnly; SameSite=Strict", │ │438 + .{ @tagName(value.failure), try ui.urlEncodeRef(arena.allocator(), value.text) }, │ │439 + ), │ │440 + else => blk: { │ │441 + const required_title = switch (feedback) { │ │442 + .issue => |value| value.failure == .required_title, │ │443 + .discussion => |value| value.failure == .required_title, │ │444 + .patch => |value| value.failure == .required_title, │ │445 + else => false, │ │446 + }; │ │447 + if (!required_title) return error.InvalidFormFeedback; │ │448 + break :blk try arena.allocator().print(local_flash_cookie ++ "=" ++ local_required_title_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", .{@tagName(std.meta.activeTag(feedback))});│ │449 + }, │ │450 }; │ │427 - if (!required_title) return error.InvalidFormFeedback; │ │428 - const cookie = try allocator.print(local_flash_cookie ++ "=" ++ local_required_title_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", .{@tagName(tag)}); │ │429 - defer allocator.free(cookie); │ │451 try request.respond("", .{ │ │452 .status = .see_other, │ │453 .extra_headers = &.{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯