╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │471 return box; │ │472 } │ │473 │ │474 + // the new-issue form: title/tags/description inputs and a submit button. │ │475 + // its form: subtree makes the web overlay wrap them in a <form> POSTing to │ │476 + // the issue route. │ │477 + fn initNewForm(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !wgt.Box(ui.Widget) { │ │478 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │479 + errdefer box.deinit(allocator); │ │480 + box.getFocus().kind = .{ .custom = if (data.identity.len == 0) │ │481 + "form:/issue" │ │482 + else │ │483 + try std.fmt.allocPrint(session.page_arena.allocator(), "form:/repo/{s}/issue", .{data.identity}) }; │ │484 + │ │485 + { │ │486 + var title = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " title ", .name = "title", .visible_width = null, .rounded_corners = true, .render_content = !wasm }); │ │487 + errdefer title.deinit(allocator); │ │488 + title.getFocus().focusable = true; │ │489 + try box.children.put(allocator, title.getFocus().id, .{ .widget = .{ .text_input = title }, .rect = null, .min_size = null }); │ │490 + } │ │491 + │ │492 + { │ │493 + var tags = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " tags (separate with spaces) ", .name = "tags", .visible_width = null, .rounded_corners = true, .render_content = !wasm }); │ │494 + errdefer tags.deinit(allocator); │ │495 + tags.getFocus().focusable = true; │ │496 + try box.children.put(allocator, tags.getFocus().id, .{ .widget = .{ .text_input = tags }, .rect = null, .min_size = null }); │ │497 + } │ │498 + │ │499 + { │ │500 + var description = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " description ", .name = "description", .visible_width = null, .rounded_corners = true, .render_content = !wasm, .multiline = true, .visible_height = 5 });│ │501 + errdefer description.deinit(allocator); │ │502 + description.getFocus().focusable = true; │ │503 + try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = null }); │ │504 + } │ │505 + │ │506 + { │ │507 + var button = try wgt.TextBox(ui.Widget).init(allocator, "submit", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │508 + errdefer button.deinit(allocator); │ │509 + button.getFocus().focusable = true; │ │510 + // the renderer distinguishes plain clickables from buttons that │ │511 + // should POST to a server route by this kind. │ │512 + button.getFocus().kind = .{ .custom = "submit" }; │ │513 + try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); │ │514 + } │ │515 + │ │516 + // absorbs the leftover min-height the box hands its last child, so │ │517 + // the button keeps its natural height │ │518 + { │ │519 + var spacer = try ui.Spacer.init(allocator); │ │520 + errdefer spacer.deinit(allocator); │ │521 + try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │522 + } │ │523 + │ │524 + box.getFocus().child_id = box.children.keys()[title_field_index]; │ │525 + return box; │ │526 + } │ │527 + │ │528 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { │ │529 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); │ │530 errdefer row.deinit(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │555 return &self.viewStack().children.values()[tags_view_index].tag_flow; │ │556 } │ │557 │ │558 + // the new-issue form inside the stack. │ │559 + fn newForm(self: *View) *wgt.Box(ui.Widget) { │ │560 + return &self.viewStack().children.values()[new_view_index].box; │ │561 + } │ │562 + │ │563 fn listScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │564 return &self.resultsBox(index).children.values()[list_index].widget.scroll;│ │565 } │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │584 return self.data.window(splitStatus(index)); │ │585 } │ │586 │ │509 - // the stack's selected master-detail split (null when the tags view shows).│ │510 - fn selectedSplitIndex(self: *View) ?usize { │ │587 + fn stackSelectedIndex(self: *View) ?usize { │ │588 const stack = self.viewStack(); │ │589 const cid = stack.getFocus().child_id orelse return null; │ │513 - const idx = stack.children.getIndex(cid) orelse return null; │ │514 - return if (idx == tags_view_index) null else idx; │ │590 + return stack.children.getIndex(cid); │ │591 + } │ │592 + │ │593 + // the stack's selected master-detail split (null when the tags view or the │ │594 + // new-issue form shows). │ │595 + fn selectedSplitIndex(self: *View) ?usize { │ │596 + const idx = self.stackSelectedIndex() orelse return null; │ │597 + return if (idx < split_count) idx else null; │ │598 } │ │599 │ │600 fn detailActive(self: *View, index: usize) bool { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │610 │ │611 fn tagsViewActive(self: *View) bool { │ │612 if (self.headerActive()) return false; │ │530 - return self.selectedSplitIndex() == null; │ │613 + return self.stackSelectedIndex() == tags_view_index; │ │614 + } │ │615 + │ │616 + fn newViewActive(self: *View) bool { │ │617 + if (self.headerActive()) return false; │ │618 + return self.stackSelectedIndex() == new_view_index; │ │619 } │ │620 │ │621 // the selected issue's index, or null when a window-navigation row is│ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │644 ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, self.data.tag, self.data.selected_id) │ │645 else if (index == tags_view_index) │ │646 ui.RoutablePage.repoIssuesTagsRoute(self.data.identity, self.data.tag) │ │647 + else if (index == new_view_index) │ │648 + ui.RoutablePage.repoIssuesNewRoute(self.data.identity) │ │649 else │ │650 ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(index), self.data.tag, "")) orelse self.session.data.current_page;│ │651 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │706 │ │707 self.tagsView().selected = self.tagsViewActive(); │ │708 │ │709 + // refresh the form inputs' entries in the session's focus-id -> input │ │710 + // map with this frame's addresses, so the web/wasm form handling can │ │711 + // find them by focus id │ │712 + const form = self.newForm(); │ │713 + const inputs_arena = self.session.arena.allocator(); │ │714 + for (form.children.values()) |*child| switch (child.widget) { │ │715 + .text_input => |*ti| try self.session.text_inputs.put(inputs_arena, ti.getFocus().id, ti),│ │716 + else => {}, │ │717 + }; │ │718 + │ │719 try self.box.build(allocator, constraint, root_focus); │ │720 } │ │721 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │756 // border reserves the space the border occupies when focused, so │ │757 // focusing doesn't shift layout. │ │758 self.description_id[index] = blk: { │ │659 - var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word });│ │759 + const description = if (entry.issue.description.len == 0) "(no description)" else entry.issue.description; │ │760 + var tb = try wgt.TextBox(ui.Widget).init(allocator, description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); │ │761 errdefer tb.deinit(allocator); │ │762 tb.getFocus().focusable = true; │ │763 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────╮ │793 } │ │794 if (self.tagsViewActive()) { │ │795 self.tagsViewInput(key, root_focus); │ │796 + return; │ │797 + } │ │798 + if (self.newViewActive()) { │ │799 + try self.newViewInput(allocator, key, root_focus);│ │800 return; │ │801 } │ │802 const i = self.selectedSplitIndex() orelse return; │ ╰───────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │877 else => return, │ │878 } │ │879 sc.clampToContent(); │ │880 + } │ │881 + │ │882 + // up/down (and tab/shift+tab) move between the form's fields; up from the │ │883 + // title crosses into the header tabs. the multiline description keeps │ │884 + // enter and any up/down that has a row to move to. │ │885 + fn newViewInput(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │886 + const form = self.newForm(); │ │887 + const cid = form.getFocus().child_id orelse return; │ │888 + const cur = form.children.getIndex(cid) orelse return; │ │889 + │ │890 + if (cur == description_field_index) { │ │891 + const child = &form.children.values()[cur]; │ │892 + const description = &child.widget.text_input; │ │893 + switch (key) { │ │894 + .enter => return child.widget.input(allocator, key, root_focus), │ │895 + .arrow_up => if (!try description.cursorOnFirstRow(allocator)) │ │896 + return child.widget.input(allocator, key, root_focus), │ │897 + .arrow_down => if (!try description.cursorOnLastRow(allocator)) │ │898 + return child.widget.input(allocator, key, root_focus), │ │899 + else => {}, │ │900 + } │ │901 + } │ │902 + │ │903 + switch (key) { │ │904 + .arrow_up, .back_tab => if (cur > 0) │ │905 + root_focus.setFocus(form.children.keys()[cur - 1]) │ │906 + else │ │907 + self.focusHeader(root_focus), │ │908 + .arrow_down, .tab => if (cur < submit_field_index) { │ │909 + root_focus.setFocus(form.children.keys()[cur + 1]); │ │910 + }, │ │911 + .enter => if (cur == submit_field_index) try self.submitNewIssue(allocator), │ │912 + .mouse => |mouse| if (cur == submit_field_index and inp.leftClickOn(root_focus, cid, mouse)) { │ │913 + try self.submitNewIssue(allocator); │ │914 + }, │ │915 + else => try form.children.values()[cur].widget.input(allocator, key, root_focus), │ │916 + } │ │917 + } │ │918 + │ │919 + // commit the new issue to the repo's events branch and navigate to it. │ │920 + // this is the terminal path; the web posts the form to the issue route, │ │921 + // so the wasm side never runs (or compiles) the repo access below. │ │922 + fn submitNewIssue(self: *View, allocator: std.mem.Allocator) !void { │ │923 + if (comptime wasm) return; │ │924 + const io = self.session.io orelse return; │ │925 + const src = self.data.repo_source orelse return; │ │926 + │ │927 + const form = self.newForm(); │ │928 + const title_input = &form.children.values()[title_field_index].widget.text_input; │ │929 + const tags_input = &form.children.values()[tags_field_index].widget.text_input; │ │930 + const description_input = &form.children.values()[description_field_index].widget.text_input; │ │931 + │ │932 + const title = try title_input.text(allocator); │ │933 + defer allocator.free(title); │ │934 + const tags = try tags_input.text(allocator); │ │935 + defer allocator.free(tags); │ │936 + const description = try description_input.text(allocator); │ │937 + defer allocator.free(description); │ │938 + │ │939 + // a title is required, and a too-long tag would fail consumption │ │940 + // after the event is already committed │ │941 + if (title.len == 0) return; │ │942 + var tag_iter = evt.Issue.tagIterator(tags); │ │943 + while (tag_iter.next()) |tag| { │ │944 + if (tag.len > evt.Issue.tag_max_len) return; │ │945 + } │ │946 + │ │947 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │948 + io.random(&id_bytes); │ │949 + const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); │ │950 + │ │951 + const event = evt.EventWithId{ │ │952 + .id = event_id_hex, │ │953 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │954 + .event = .{ .issue = .{ │ │955 + .title = title, │ │956 + .description = description, │ │957 + .tags = tags, │ │958 + } }, │ │959 + }; │ │960 + │ │961 + switch (src.repo_kind) { │ │962 + inline else => |repo_kind| { │ │963 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); │ │964 + defer any_repo.deinit(io, allocator); │ │965 + switch (any_repo) { │ │966 + inline else => |*repo| try evt.commitAndConsume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}, self.session.local != null),│ │967 + } │ │968 + }, │ │969 + } │ │970 + │ │971 + // wipe the form so a return visit starts fresh │ │972 + title_input.clear(allocator); │ │973 + tags_input.clear(allocator); │ │974 + description_input.clear(allocator); │ │975 + │ │976 + const route = ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, "", &event_id_hex) orelse return; │ │977 + try self.session.navigate(route); │ │978 } │ │979 │ │980 // arrow keys move the tag selection; at the flow's edges focus crosses to │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1156 .widget = .{ .text_box = text_box }, │ │1157 .rect = null, │ │1158 .min_size = .{ .width = label.len + 2, .height = null }, │ │1159 + }); │ │1160 + } │ │1161 + │ │1162 + // new-issue tab │ │1163 + { │ │1164 + const new_route = ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; │ │1165 + const new_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try new_route.toUrl(session.page_arena)}); │ │1166 + var text_box = try wgt.TextBox(ui.Widget).init(allocator, "new", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │1167 + errdefer text_box.deinit(allocator); │ │1168 + text_box.getFocus().focusable = true; │ │1169 + text_box.getFocus().kind = .{ .custom = new_link }; │ │1170 + try tab_ids.put(allocator, text_box.getFocus().id, {}); │ │1171 + try box.children.put(allocator, text_box.getFocus().id, .{ │ │1172 + .widget = .{ .text_box = text_box }, │ │1173 + .rect = null, │ │1174 + .min_size = .{ .width = "new".len + 2, .height = null }, │ │1175 }); │ │1176 } │ │1177 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯