╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │51 }; │ │52 }, │ │53 .repo => switch (route) { │ │54 - .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_settings, .repo_auth => .{ .repo = try Repo.init(arena, session, route) }, │ │54 + .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_comments, .repo_settings, .repo_auth => .{ .repo = try Repo.init(arena, session, route) },│ │55 else => return error.UnexpectedRoute, │ │56 }, │ │57 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │ 99 // the view the url names; a route with `selected` derives its view│ │100 // from that issue's status instead. │ │101 view: IssuesView = .open, │ │102 + // the selected issue's comment window (0 = the first window). │ │103 + comments_start: usize = 0, │ │104 + }, │ │105 + repo_comments: struct { │ │106 + name: Array(repo_route_max_len), │ │107 + thread: Array(evt.event_id_size * 2), │ │108 + selected: Array(evt.event_id_size * 2), │ │109 + start: usize = 0, │ │110 }, │ │111 repo_settings: Array(repo_route_max_len), │ │112 repo_auth: Array(repo_route_max_len), │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │353 } }; │ │354 } │ │355 │ │356 + // build a selected issue route at one of its comment windows. │ │357 + pub fn repoIssueCommentsRoute(identity: []const u8, selected: []const u8, start: usize) ?RoutablePage { │ │358 + var route = repoIssuesRoute(identity, .open, "", selected) orelse return null; │ │359 + route.repo_issues.comments_start = start; │ │360 + return route; │ │361 + } │ │362 + │ │363 + // build a comment permalink route at one of its immediate-reply windows. │ │364 + pub fn repoCommentsRoute(identity: []const u8, thread: []const u8, selected: []const u8, start: usize) ?RoutablePage {│ │365 + if (thread.len == 0 or selected.len == 0) return null; │ │366 + return .{ .repo_comments = .{ │ │367 + .name = Array(repo_route_max_len).from(identity) orelse return null, │ │368 + .thread = Array(evt.event_id_size * 2).from(thread) orelse return null, │ │369 + .selected = Array(evt.event_id_size * 2).from(selected) orelse return null, │ │370 + .start = start, │ │371 + } }; │ │372 + } │ │373 + │ │374 // build a `.repo_issues` route showing tags view, keeping the url-encoded │ │375 // `tag` filter ("" = unfiltered). │ │376 pub fn repoIssuesTagsRoute(identity: []const u8, tag: []const u8) ?RoutablePage { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │524 // a selected issue's url carries its id instead of a view │ │525 // name, so it survives status changes │ │526 const word = if (i.selected.len != 0) i.selected.slice() else @tagName(i.view); │ │501 - break :blk if (i.tag.len == 0) │ │527 + break :blk if (i.tag.len == 0 and i.comments_start == 0) │ │528 try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, word }) │ │529 + else if (i.tag.len == 0) │ │530 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/" ++ start_seg ++ "{d}", .{ prefix, word, i.comments_start }) │ │531 else │ │532 try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/" ++ tag_filter_seg ++ "{s}", .{ prefix, word, i.tag.slice() }); │ │533 + }, │ │534 + .repo_comments => |c| blk: { │ │535 + const prefix = try repoUrlPrefix(arena, c.name.slice()); │ │536 + break :blk if (c.start == 0) │ │537 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/comments/{s}", .{ prefix, c.thread.slice(), c.selected.slice() }) │ │538 + else │ │539 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/comments/{s}/" ++ start_seg ++ "{d}", .{ prefix, c.thread.slice(), c.selected.slice(), c.start });│ │540 }, │ │541 .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/settings", .{try repoUrlPrefix(arena, name.slice())}), │ │542 .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{try repoUrlPrefix(arena, name.slice())}), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │600 pub fn fullHeight(self: RoutablePage) bool { │ │601 return switch (self) { │ │602 .repo_issues => |i| i.view == .resolve,│ │603 + .repo_comments => true, │ │604 else => false, │ │605 }; │ │606 } │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────╮ │613 .repo_commits => |*c| c.name.slice(), │ │614 .repo_refs => |*r| r.name.slice(), │ │615 .repo_issues => |*i| i.name.slice(), │ │616 + .repo_comments => |*c| c.name.slice(), │ │617 .repo_settings, .repo_auth => |*name| name.slice(),│ │618 else => null, │ │619 }; │ ╰────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │638 return switch (self) { │ │639 .home_users, .home_repos, .home_settings, .home_auth => .home, │ │640 .user_repos, .user_settings, .user_auth => .user, │ │604 - .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_settings, .repo_auth => .repo, │ │641 + .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_comments, .repo_settings, .repo_auth => .repo,│ │642 }; │ │643 } │ │644 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │679 // "<id>/edit" is the edit form for that issue, with no params; │ │680 // "<id>/description" is its whole-description page. │ │681 const after_tab = segments.rest(); │ │682 + // "<thread-id>/comments/<comment-id>[/start:<n>]" is a comment │ │683 + // permalink with one window of its immediate replies. │ │684 + { │ │685 + var comment_segments = std.mem.splitScalar(u8, after_tab, '/'); │ │686 + const thread = comment_segments.next() orelse return null; │ │687 + const marker = comment_segments.next(); │ │688 + if (marker) |word| { │ │689 + if (std.mem.eql(u8, word, "comments")) { │ │690 + const comment = comment_segments.next() orelse return null; │ │691 + if (thread.len == 0 or comment.len == 0) return null; │ │692 + return repoCommentsRoute(pair, thread, comment, listStart(&comment_segments) orelse return null);│ │693 + } │ │694 + } │ │695 + } │ │696 if (std.mem.endsWith(u8, after_tab, "/edit")) { │ │697 const id = after_tab[0 .. after_tab.len - "/edit".len]; │ │698 if (id.len == 0 or std.mem.indexOfScalar(u8, id, '/') != null) return null; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │721 if (!params.only(&.{.start})) return null; │ │722 return repoIssuesConflictsRoute(pair, params.values.get(.start) orelse ""); │ │723 } │ │724 + if (params.values.get(.start) != null) { │ │725 + if (!params.only(&.{.start})) return null; │ │726 + return repoIssueCommentsRoute(pair, w, params.start() orelse return null); │ │727 + } │ │728 if (!params.only(&.{.tag})) return null; │ │729 if (std.meta.stringToEnum(evt.Issue.Status, w)) |status| return repoIssuesRoute(pair, status, tag_value, "");│ │730 if (std.mem.eql(u8, w, "tags")) return repoIssuesTagsRoute(pair, tag_value); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │785 std.mem.eql(u8, a_i.tag.slice(), b.repo_issues.tag.slice()) and │ │786 std.mem.eql(u8, a_i.selected.slice(), b.repo_issues.selected.slice()) and │ │787 std.mem.eql(u8, a_i.theirs.slice(), b.repo_issues.theirs.slice()) and │ │733 - a_i.view == b.repo_issues.view, │ │788 + a_i.view == b.repo_issues.view and │ │789 + a_i.comments_start == b.repo_issues.comments_start, │ │790 + .repo_comments => |a_c| std.mem.eql(u8, a_c.name.slice(), b.repo_comments.name.slice()) and│ │791 + std.mem.eql(u8, a_c.thread.slice(), b.repo_comments.thread.slice()) and │ │792 + std.mem.eql(u8, a_c.selected.slice(), b.repo_comments.selected.slice()) and │ │793 + a_c.start == b.repo_comments.start, │ │794 .repo_settings => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_settings.slice()), │ │795 .repo_auth => |a_name| std.mem.eql(u8, a_name.slice(), b.repo_auth.slice()), │ │796 else => true, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯