╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 = "", │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────╮ │221 .owner = "", │ │222 .name = "", │ │223 + .start = 0, │ │224 .ref_kind = null,│ │225 .ref_value = "", │ │226 .dir = "", │ ╰──────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │240 } │ │241 var segments = std.mem.splitScalar(u8, rest, '/'); │ │242 if (!std.mem.eql(u8, segments.next() orelse return result, files_seg)) return result; │ │170 - const kind = RefOrOid.fromSeg(segments.next() orelse return result) orelse return result;│ │171 - const value = segments.next() orelse return result; │ │172 - if (value.len == 0) return result; │ │173 - result.ref_kind = kind; │ │174 - result.ref_value = value; │ │175 - result.dir = segments.rest(); │ │243 + var params = Params{}; │ │244 + params.scanPairs(&segments) catch return result; │ │245 + result.start = params.start() orelse return result; │ │246 + const ref = (params.ref() catch return result) orelse return result; │ │247 + result.ref_kind = ref.kind; │ │248 + result.ref_value = ref.value; │ │249 + result.dir = pathValue(segments.rest()) orelse return result; │ │250 return result; │ │251 } │ │252 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯