╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────╮ │src/ui/Repo.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │264 try Commits.emptyResult(aa, repo_identity.identity, requested_ref_or_oid orelse .branch, requested_ref_value, commits_content), │ │265 try Refs.emptyResult(arena, repo_identity.identity, refs_kind, refs_from), │ │266 try Issues.emptyResult(aa, repo_identity.identity, issues_tag, issues_selected, issues_comment, issues_comments_start, issues_theirs, issues_view), │ │267 + try Patches.emptyResult(aa, repo_identity.identity, patches_tag, patches_selected, patches_comment, patches_comments_start, patches_theirs, patches_view), │ │268 try Discussions.emptyResult(aa, repo_identity.identity, discussions_tag, discussions_selected, discussions_comment, discussions_comments_start, discussions_view),│ │269 try Events.empty(aa, repo_identity.identity, events_view, session.local != null, session.data.sync_failure), │ │270 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │271 }; │ │272 issues.repo_source = source; │ │273 + patches.repo_source = source; │ │274 + patches.repo_id = repo_id_maybe; │ │275 + patches.target_branch = if (files.ref_or_oid == .branch) files.ref_or_oid_value else "";│ │276 discussions.repo_source = source; │ │277 │ │278 // each tab mirror carries this page's route for that tab; tabs not targeted │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │294 return .{ │ │295 // files and commits resolve the same ref, so either's serves the header, │ │296 // which points both tabs at it. │ │264 - .header = try Header.init(arena, repo.event.name, owner_name, files.ref_or_oid, files.ref_or_oid_value, issues_tag, discussions_tag), │ │297 + .header = try Header.init(arena, repo.event.name, owner_name, files.ref_or_oid, files.ref_or_oid_value, issues_tag, patches_tag, discussions_tag),│ │298 .repo = repo, │ │299 .files = files, │ │300 .commits = commits, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────╮ │301 .refs = refs, │ │302 .issues = issues, │ │303 + .patches = patches, │ │304 .discussions = discussions, │ │305 .events = events, │ │306 .settings = Settings.init(),│ ╰─────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │364 try stack.children.put(allocator, issues_view.getFocus().id, .{ .repo_issues = issues_view }); │ │365 } │ │366 │ │367 + // patches and their comment permalinks. │ │368 + { │ │369 + var patches_view = try Patches.View.init(allocator, &data.patches, session); │ │370 + errdefer patches_view.deinit(allocator); │ │371 + try stack.children.put(allocator, patches_view.getFocus().id, .{ .repo_patches = patches_view });│ │372 + } │ │373 + │ │374 // discussions and their comment permalinks. │ │375 { │ │376 var discussions_view = try Discussions.View.init(allocator, &data.discussions, session); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │437 // the issues tab mirrors this page's tag filter (issue urls │ │438 // themselves never carry the tag). │ │439 .repo_issues => self.session.data.current_page = ui.RoutablePage.repoIssuesRoute(self.data.identity.slice(), .open, self.data.issues.tag, "") orelse self.session.data.current_page, │ │440 + .repo_patches => self.session.data.current_page = ui.RoutablePage.repoPatchesRoute(self.data.identity.slice(), .open, self.data.patches.tag, "") orelse self.session.data.current_page, │ │441 .repo_discussions => self.session.data.current_page = ui.RoutablePage.repoDiscussionsRoute(self.data.identity.slice(), self.data.discussions.tag, "") orelse self.session.data.current_page,│ │442 .repo_events => self.session.data.current_page = ui.RoutablePage.repoEventsRoute(self.data.identity.slice(), self.data.events.view, null, "") orelse self.session.data.current_page, │ │443 .home_settings => { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │480 .repo_commits => |*v| v.getSelectedIndex() == 0, │ │481 .repo_refs => |*v| v.getSelectedIndex() == 0, │ │482 .repo_issues => |*v| v.getSelectedIndex() == 0, │ │483 + .repo_patches => |*v| v.getSelectedIndex() == 0, │ │484 .repo_discussions => |*v| v.getSelectedIndex() == 0,│ │485 .repo_events => |*v| v.getSelectedIndex() == 0, │ │486 .home_settings => |*v| v.getSelectedIndex() == 0, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────────╮ │src/ui/Repo/Discussions.zig│ ╰───────────────────────────╯ ╭─────────────────────────────────────────────────╮ │ 5 const xit = @import("xit"); │ │ 6 const rp = xit.repo; │ │ 7 const hash = xit.hash; │ │ 8 -const xitui = xit.xitui; │ │ 9 -const wgt = xitui.widget; │ │10 -const layout = xitui.layout; │ │11 -const Key = xitui.input.Key; │ │12 -const Grid = xitui.grid.Grid; │ │13 -const Focus = xitui.focus.Focus; │ │14 -const inp = @import("../input.zig"); │ │ 8 const Comment = @import("Comment.zig"); │ │ 9 const Attachment = @import("Attachment.zig");│ │10 const Threads = @import("Threads.zig"); │ ╰─────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │147 │ │148 var root_key: ?[]const u8 = null; │ │149 if (rooted) { │ │157 - if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound; │ │158 - var id: [evt.event_id_size]u8 = undefined; │ │159 - _ = std.fmt.hexToBytes(&id, empty.selected_id) catch return error.NotFound; │ │150 + const id = evt.parseEventId(empty.selected_id) catch return error.NotFound; │ │151 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &id)) orelse return error.NotFound; │ │152 const record = try evt.read(evt.Discussion.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(record_cursor));│ │153 if (record.removed) return error.NotFound; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │258 } │ │259 pub const View = Threads.View(.discuss, Self); │ │260 │ │270 -// tabs switching between the discussions page's views. │ │271 -pub const Header = struct { │ │272 - box: wgt.Box(ui.Widget), │ │273 - tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), │ │274 - │ │275 - pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { │ │276 - var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │277 - errdefer box.deinit(allocator); │ │278 - │ │279 - var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; │ │280 - errdefer tab_ids.deinit(allocator); │ │261 +pub const Header = Threads.Header; │ │262 │ │282 - const aa = session.page_arena.allocator(); │ │263 +// tabs switching between the discussions page's views │ │264 +pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { │ │265 + var header = try Header.init(allocator); │ │266 + errdefer header.deinit(allocator); │ │267 + const aa = session.page_arena.allocator(); │ │268 │ │284 - // recent discussions │ │285 - { │ │286 - const route = ui.RoutablePage.repoDiscussionsRoute(data.identity, data.tag, "") orelse return error.RouteTooLong; │ │287 - const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │288 - var label_buf: [64]u8 = undefined; │ │289 - const label = try std.fmt.bufPrint(&label_buf, "recent ({d})", .{data.recent.count}); │ │290 - try addTab(allocator, &box, &tab_ids, label, link); │ │291 - } │ │292 - │ │293 - // tags tab, labeled with the active tag filter │ │294 - { │ │295 - const tags_route = ui.RoutablePage.repoThreadTagsRoute(.discuss, data.identity, data.tag) orelse return error.RouteTooLong; │ │296 - const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); │ │297 - const label = if (data.tag.len == 0) "tags" else blk: { │ │298 - const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); │ │299 - break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); │ │300 - }; │ │301 - try addTab(allocator, &box, &tab_ids, label, tags_link); │ │302 - } │ │303 - │ │304 - // new-discussion tab; an edit or comment url shows its tab in this place │ │305 - { │ │306 - const route = switch (data.view) { │ │307 - .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, │ │308 - .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │309 - .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong,│ │310 - .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │311 - else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, │ │312 - }; │ │313 - const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │314 - const label: []const u8 = switch (data.view) { │ │315 - .edit => "edit", │ │316 - .new_comment => "reply", │ │317 - .edit_comment => "edit", │ │318 - .remove => "remove", │ │319 - else => "new", │ │320 - }; │ │321 - try addTab(allocator, &box, &tab_ids, label, link); │ │322 - } │ │323 - │ │324 - var self = Header{ .box = box, .tab_ids = tab_ids }; │ │325 - // the tab matching the page's view is selected initially. │ │326 - self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; │ │327 - return self; │ │269 + // recent discussions │ │270 + { │ │271 + const route = ui.RoutablePage.repoDiscussionsRoute(data.identity, data.tag, "") orelse return error.RouteTooLong; │ │272 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │273 + var label_buf: [64]u8 = undefined; │ │274 + const label = try std.fmt.bufPrint(&label_buf, "recent ({d})", .{data.recent.count}); │ │275 + try header.addTab(allocator, label, link, 0); │ │276 } │ │277 │ │330 - fn addTab(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), tab_ids: *std.AutoArrayHashMapUnmanaged(usize, void), label: []const u8, link: []const u8) !void { │ │331 - var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │332 - errdefer text_box.deinit(allocator); │ │333 - text_box.getFocus().focusable = true; │ │334 - text_box.getFocus().kind = .{ .custom = link }; │ │335 - try tab_ids.put(allocator, text_box.getFocus().id, {}); │ │336 - try box.children.put(allocator, text_box.getFocus().id, .{ │ │337 - .widget = .{ .text_box = text_box }, │ │338 - .rect = null, │ │339 - .min_size = .{ .width = label.len + 2, .height = null }, │ │340 - }); │ │341 - } │ │342 - │ │343 - pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { │ │344 - self.box.deinit(allocator); │ │345 - self.tab_ids.deinit(allocator); │ │346 - } │ │347 - │ │348 - pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │349 - self.clearGrid(); │ │350 - // only the selected tab shows its border │ │351 - for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │352 - switch (child.widget) { │ │353 - .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, │ │354 - else => {}, │ │355 - } │ │356 - } │ │357 - try self.box.build(allocator, constraint, root_focus); │ │358 - } │ │359 - │ │360 - pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │361 - _ = allocator; │ │362 - const current_tab = self.currentTabIndex() orelse return; │ │363 - if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { │ │364 - root_focus.setFocus(self.tab_ids.keys()[new_tab]); │ │365 - } │ │366 - } │ │367 - │ │368 - pub fn clearGrid(self: *Header) void { │ │369 - self.box.clearGrid(); │ │370 - } │ │371 - │ │372 - pub fn getGrid(self: Header) ?Grid { │ │373 - return self.box.getGrid(); │ │278 + // tags tab, labeled with the active tag filter │ │279 + { │ │280 + const tags_route = ui.RoutablePage.repoThreadTagsRoute(.discuss, data.identity, data.tag) orelse return error.RouteTooLong; │ │281 + const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); │ │282 + const label = if (data.tag.len == 0) "tags" else blk: { │ │283 + const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); │ │284 + break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); │ │285 + }; │ │286 + try header.addTab(allocator, label, tags_link, View.viewIndex(.tags)); │ │287 } │ │288 │ │376 - pub fn getFocus(self: *Header) *Focus { │ │377 - return self.box.getFocus(); │ │378 - } │ │379 - │ │380 - pub fn getSelectedIndex(self: Header) ?usize { │ │381 - return self.currentTabIndex(); │ │289 + // new-discussion tab; an edit or comment url shows its tab in this place │ │290 + { │ │291 + const route = switch (data.view) { │ │292 + .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, │ │293 + .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │294 + .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │295 + .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, │ │296 + else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, │ │297 + }; │ │298 + const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); │ │299 + const label: []const u8 = switch (data.view) { │ │300 + .edit => "edit", │ │301 + .new_comment => "reply", │ │302 + .edit_comment => "edit", │ │303 + .remove => "remove", │ │304 + else => "new", │ │305 + }; │ │306 + try header.addTab(allocator, label, link, View.viewIndex(.new)); │ │307 } │ │308 │ │384 - fn currentTabIndex(self: Header) ?usize { │ │385 - const child_id = self.box.focus.child_id orelse return null; │ │386 - return self.tab_ids.getIndex(child_id); │ │387 - } │ │388 -}; │ │309 + header.select(View.viewIndex(data.view)); │ │310 + return header; │ │311 +} │ │312 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯