╭ changed ───╮ │src/main.zig│ ╰────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │114 .is_terminal = true, │ │115 .data = .{ │ │116 // the files root; local routes carry no identity │ │117 - .current_page = .{ .repo_files = .{ .name = .{} } },│ │117 + .current_page = .{ .repo_files = .{} }, │ │118 .is_local = true, │ │119 }, │ │120 }; │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/test/ui.zig│ ╰───────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │58 // the route layer holds the value already url-encoded ("feature%2Ffoo"). │ │59 const route = RP.repoCommitsRoute("alice/ziglings", .branch, "feature%2Ffoo", 0).?; │ │60 const url = try route.urlAlloc(&arena); │ │61 - try std.testing.expectEqualStrings("/repo/alice/ziglings/commits/branch/feature%2Ffoo", url);│ │61 + try std.testing.expectEqualStrings("/repo/alice/ziglings/commits/branch:feature%2Ffoo", url);│ │62 │ │63 // parsing it back yields the same route, and the ref splits out intact. │ │64 - const parsed = RP.fromUrl(url, null); │ │64 + const parsed = RP.fromUrl(url); │ │65 try std.testing.expect(parsed != null); │ │66 - const ref = RP.repoCommitsRef(parsed.?.repo_commits.name.slice()); │ │66 + const ref = RP.repoCommitsRef(parsed.?.repo_commits.slice()); │ │67 try std.testing.expectEqual(RP.RefOrOid.branch, ref.ref_or_oid.?); │ │68 try std.testing.expectEqualStrings("feature%2Ffoo", ref.value); │ │69 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │35 // user pages always read from the admin db. │ │36 return switch (route.parent()) { │ │37 .home => .{ .home = try Home.init(arena, session.haxy_moment orelse return error.NoMoment, switch (route) {│ │38 - .home_users => |after| after, │ │38 + .home_users => |start| start, │ │39 else => 0, │ │40 }, switch (route) { │ │41 - .home_repos => |after| after, │ │41 + .home_repos => |start| start, │ │42 else => 0, │ │43 }) }, │ │44 .user => blk: { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │45 const haxy_moment = session.haxy_moment orelse return error.NoMoment; │ │46 break :blk switch (route) { │ │47 - .user_repos => |u| .{ .user = try User.init(arena, haxy_moment, u.name, u.after) }, │ │47 + .user_repos => |u| .{ .user = try User.init(arena, haxy_moment, u.name, u.start) }, │ │48 .user_settings, .user_auth => |name| .{ .user = try User.init(arena, haxy_moment, name, 0) },│ │49 else => return error.UnexpectedRoute, │ │50 }; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │73 home_repos: usize, // 0 = first page │ │74 home_settings, │ │75 home_auth, │ │76 - user_repos: struct { name: Array(evt.User.name_max_len), after: usize = 0 }, │ │76 + user_repos: struct { name: Array(evt.User.name_max_len), start: usize = 0 }, │ │77 user_settings: Array(evt.User.name_max_len), │ │78 user_auth: Array(evt.User.name_max_len), │ │79 - repo_files: struct { name: Array(repo_route_max_len), after: usize = 0 }, │ │80 - repo_commits: struct { name: Array(repo_route_max_len), after: usize = 0 }, │ │81 - repo_refs: struct { name: Array(repo_route_max_len), kind: RefKind = .branch, after: usize = 0 },│ │79 + repo_files: Array(repo_route_max_len), │ │80 + repo_commits: Array(repo_route_max_len), │ │81 + repo_refs: struct { │ │82 + name: Array(repo_route_max_len), │ │83 + kind: RefKind = .branch, │ │84 + // the url-encoded ref name `kind`'s column windows from ("" = the │ │85 + // first window; the other column always shows its first window). │ │86 + from: Array(ref_route_max_len) = .{}, │ │87 + }, │ │88 repo_issues: struct { │ │89 name: Array(repo_route_max_len), │ │90 // the tag the list is filtered to, url-encoded ("" = unfiltered). │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │107 branch, │ │108 tag, │ │109 object, │ │110 + }; │ │111 │ │105 - fn fromSeg(s: []const u8) ?RefOrOid { │ │106 - return std.meta.stringToEnum(RefOrOid, s); │ │112 + // the "key:value" segments of a url tail, collected in any order │ │113 + const Params = struct { │ │114 + const ParamKey = enum { start, branch, tag, object }; │ │115 + │ │116 + // a branch:/tag:/object: param; the value stays url-encoded │ │117 + const RefParam = struct { kind: RefOrOid, value: []const u8 }; │ │118 + │ │119 + values: std.enums.EnumArray(ParamKey, ?[]const u8) = .initFill(null), │ │120 + │ │121 + // consume the leading "key:value" segments, stopping at the first │ │122 + // segment that isn't one │ │123 + fn scanPairs(self: *Params, segments: *std.mem.SplitIterator(u8, .scalar)) error{BadUrl}!void { │ │124 + while (segments.peek()) |seg| { │ │125 + const colon = std.mem.indexOfScalar(u8, seg, ':') orelse return; │ │126 + const key = std.meta.stringToEnum(ParamKey, seg[0..colon]) orelse return; │ │127 + const value = seg[colon + 1 ..]; │ │128 + if (value.len == 0) return error.BadUrl; │ │129 + if (self.values.get(key) != null) return error.BadUrl; │ │130 + self.values.set(key, value); │ │131 + _ = segments.next(); │ │132 + } │ │133 + } │ │134 + │ │135 + // consume every remaining segment: pairs plus at most one bare word, │ │136 + // which is returned │ │137 + fn scanTail(self: *Params, segments: *std.mem.SplitIterator(u8, .scalar)) error{BadUrl}!?[]const u8 {│ │138 + var word: ?[]const u8 = null; │ │139 + while (true) { │ │140 + try self.scanPairs(segments); │ │141 + const seg = segments.next() orelse return word; │ │142 + if (seg.len == 0 or word != null) return error.BadUrl; │ │143 + word = seg; │ │144 + } │ │145 + } │ │146 + │ │147 + // true when every set param is one of `allowed` │ │148 + fn only(self: Params, allowed: []const ParamKey) bool { │ │149 + for (std.enums.values(ParamKey)) |key| { │ │150 + if (self.values.get(key) == null) continue; │ │151 + if (std.mem.indexOfScalar(ParamKey, allowed, key) == null) return false; │ │152 + } │ │153 + return true; │ │154 + } │ │155 + │ │156 + // the "start:<n>" window param (0 when absent), null when malformed │ │157 + fn start(self: Params) ?usize { │ │158 + const value = self.values.get(.start) orelse return 0; │ │159 + return std.fmt.parseInt(usize, value, 10) catch null; │ │160 + } │ │161 + │ │162 + // the single branch:/tag:/object: param, null when none is set │ │163 + fn ref(self: Params) error{BadUrl}!?RefParam { │ │164 + var result: ?RefParam = null; │ │165 + inline for (@typeInfo(RefOrOid).@"enum".fields) |field| { │ │166 + if (self.values.get(@field(ParamKey, field.name))) |value| { │ │167 + if (result != null) return error.BadUrl; │ │168 + result = .{ .kind = @field(RefOrOid, field.name), .value = value }; │ │169 + } │ │170 + } │ │171 + return result; │ │172 } │ │173 }; │ │174 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │176 const repo_segment = "/repo/"; │ │177 const files_seg = "files"; │ │178 const commits_seg = "commits"; │ │179 + // the `Params` key spellings the url emitters use │ │180 + const tag_filter_seg = @tagName(Params.ParamKey.tag) ++ ":"; │ │181 + const start_seg = @tagName(Params.ParamKey.start) ++ ":"; │ │182 + // marks a files route's trailing path: the value is the raw remainder of │ │183 + // the url, so it must come last │ │184 + const path_seg = "path:"; │ │185 │ │115 - // a repo route is "username/reponame", optionally followed by the files tab's│ │116 - // "/files/<refkind>/<refvalue>/<dir>" (refkind/refvalue name the ref the tree│ │117 - // is read at). the bare "username/reponame" (and "username/reponame/files") │ │118 - // mean the files root of the repo's default branch. a local session's routes │ │119 - // elide the identity: the stored string is "" at the bare root, or starts │ │120 - // straight at the tail's separator ("/files/..."). │ │186 + // a repo route is "username/reponame", optionally followed by the files │ │187 + // tab's "/files/<refkind>:<refvalue>[/start:<n>][/path:<dir>]"; the bare │ │188 + // pair means the default branch's files root. a local session's routes │ │189 + // elide the identity: "" at the bare root, else starting at the tail's "/". │ │190 pub const repo_route_max_len = 1024; │ │191 │ │192 // true when a repo route's stored string elides its identity (local mode). │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │198 // a url-encoded tag can grow to three bytes per source byte. │ │199 pub const issue_tag_route_max_len = evt.Issue.tag_max_len * 3; │ │200 │ │201 + // caps a url-encoded ref name in a route (a longer name doesn't route). │ │202 + pub const ref_route_max_len = 512; │ │203 + │ │204 // the parts of a `.repo_files` route. all slices point into the route's │ │205 // stored string. ref_kind is null (and ref_value/dir empty) for the bare│ │206 // default-branch root; dir is "" at a ref's root. │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │208 identity: []const u8, // "owner/name" │ │209 owner: []const u8, │ │210 name: []const u8, │ │211 + start: usize, // the content window's first line, 0 = the first window│ │212 ref_kind: ?RefOrOid, │ │213 ref_value: []const u8, │ │214 dir: []const u8, │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │215 │ │143 - // parse a `.repo_files` route's stored string ("owner/name" or │ │144 - // "owner/name/files/<refkind>/<refvalue>[/<dir>]", identity elided in │ │145 - // local mode). a malformed tail reads as the bare default-branch root.│ │216 + // parse a `.repo_files` route's stored string. a malformed tail reads │ │217 + // as the bare default-branch root. │ │218 pub fn parse(s: []const u8) ?RepoFiles { │ │219 var result: RepoFiles = .{ │ │220 .identity = "", │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯