╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────╮ │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 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │253 │ │180 - // build a `.repo_files` route for "owner/name" (identity) at `dir` ("" = the │ │181 - // ref's root) pinned to `ref_kind`/`ref_value`, showing the file content │ │182 - // window starting at line `after` (0 = the first window, dropped from the │ │183 - // url). a null ref_kind yields the bare default-branch root. null if the │ │184 - // result doesn't fit the inline name. │ │185 - pub fn repoFilesRoute(identity: []const u8, ref_kind: ?RefOrOid, ref_value: []const u8, dir: []const u8, after: usize) ?RoutablePage { │ │186 - const kind = ref_kind orelse return .{ .repo_files = .{ .name = Array(repo_route_max_len).from(identity) orelse return null, .after = after } };│ │254 + // build a `.repo_files` route (a null ref_kind = the bare default-branch │ │255 + // root). null if the result doesn't fit the inline name. │ │256 + pub fn repoFilesRoute(identity: []const u8, ref_kind: ?RefOrOid, ref_value: []const u8, dir: []const u8, start: usize) ?RoutablePage { │ │257 + if (ref_kind == null and start == 0) return .{ .repo_files = Array(repo_route_max_len).from(identity) orelse return null }; │ │258 var buf: [repo_route_max_len]u8 = undefined; │ │188 - const s = if (dir.len == 0) │ │189 - std.fmt.bufPrint(&buf, "{s}/" ++ files_seg ++ "/{s}/{s}", .{ identity, @tagName(kind), ref_value }) catch return null │ │190 - else │ │191 - std.fmt.bufPrint(&buf, "{s}/" ++ files_seg ++ "/{s}/{s}/{s}", .{ identity, @tagName(kind), ref_value, dir }) catch return null; │ │192 - return .{ .repo_files = .{ .name = Array(repo_route_max_len).from(s) orelse return null, .after = after } }; │ │259 + var writer: std.Io.Writer = .fixed(&buf); │ │260 + writer.print("{s}/" ++ files_seg, .{identity}) catch return null; │ │261 + if (ref_kind) |kind| writer.print("/{s}:{s}", .{ @tagName(kind), ref_value }) catch return null; │ │262 + if (start != 0) writer.print("/" ++ start_seg ++ "{d}", .{start}) catch return null; │ │263 + if (dir.len != 0) writer.print("/" ++ path_seg ++ "{s}", .{dir}) catch return null; │ │264 + return .{ .repo_files = Array(repo_route_max_len).from(writer.buffered()) orelse return null }; │ │265 } │ │266 │ │195 - // the ref/oid a `.repo_commits` route's stored string walks from. kind is │ │196 - // null for the bare "owner/name/commits" (the repo's default branch). the │ │197 - // returned value slices into `s`. │ │198 - pub const CommitsRef = struct { ref_or_oid: ?RefOrOid, value: []const u8 }; │ │267 + // the ref/oid a `.repo_commits` route's stored string walks from (null = │ │268 + // the default branch), plus the diff window start. the value slices into `s`. │ │269 + pub const CommitsRef = struct { ref_or_oid: ?RefOrOid, value: []const u8, start: usize }; │ │270 pub fn repoCommitsRef(s: []const u8) CommitsRef { │ │200 - const bare = CommitsRef{ .ref_or_oid = null, .value = "" }; │ │201 - if (s.len == 0) return bare; │ │271 + var result = CommitsRef{ .ref_or_oid = null, .value = "", .start = 0 }; │ │272 + if (s.len == 0) return result; │ │273 var segments = std.mem.splitScalar(u8, s, '/'); │ │274 if (identityElided(s)) { │ │275 _ = segments.next(); // the empty segment before the tail │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │277 _ = segments.next(); // owner │ │278 _ = segments.next(); // name │ │279 } │ │209 - if (!std.mem.eql(u8, segments.next() orelse "", commits_seg)) return bare; │ │210 - const kind = RefOrOid.fromSeg(segments.next() orelse "") orelse return bare; │ │211 - const value = segments.rest(); │ │212 - if (value.len == 0) return bare; │ │213 - return .{ .ref_or_oid = kind, .value = value }; │ │280 + if (!std.mem.eql(u8, segments.next() orelse "", commits_seg)) return result; │ │281 + var params = Params{}; │ │282 + params.scanPairs(&segments) catch return result; │ │283 + result.start = params.start() orelse return result; │ │284 + const ref = (params.ref() catch return result) orelse return result; │ │285 + result.ref_or_oid = ref.kind; │ │286 + result.value = ref.value; │ │287 + return result; │ │288 } │ │289 │ │216 - // build a `.repo_commits` route for "owner/name" (identity), walking from │ │217 - // `ref_or_oid`/`value` (a null ref_or_oid yields the bare default-branch │ │218 - // route). always carries the `commits` marker so the bare route │ │219 - // ("owner/name/commits") doesn't collide with the files root. │ │220 - pub fn repoCommitsRoute(identity: []const u8, ref_or_oid: ?RefOrOid, value: []const u8, after: usize) ?RoutablePage { │ │290 + // build a `.repo_commits` route (a null ref_or_oid = the default branch). │ │291 + // always carries the `commits` marker so the bare route doesn't collide │ │292 + // with the files root. │ │293 + pub fn repoCommitsRoute(identity: []const u8, ref_or_oid: ?RefOrOid, value: []const u8, start: usize) ?RoutablePage { │ │294 var buf: [repo_route_max_len]u8 = undefined; │ │222 - const s = if (ref_or_oid) |kind| │ │223 - std.fmt.bufPrint(&buf, "{s}/" ++ commits_seg ++ "/{s}/{s}", .{ identity, @tagName(kind), value }) catch return null │ │224 - else │ │225 - std.fmt.bufPrint(&buf, "{s}/" ++ commits_seg, .{identity}) catch return null; │ │226 - return .{ .repo_commits = .{ .name = Array(repo_route_max_len).from(s) orelse return null, .after = after } }; │ │295 + var writer: std.Io.Writer = .fixed(&buf); │ │296 + writer.print("{s}/" ++ commits_seg, .{identity}) catch return null; │ │297 + if (ref_or_oid) |kind| writer.print("/{s}:{s}", .{ @tagName(kind), value }) catch return null; │ │298 + if (start != 0) writer.print("/" ++ start_seg ++ "{d}", .{start}) catch return null; │ │299 + return .{ .repo_commits = Array(repo_route_max_len).from(writer.buffered()) orelse return null }; │ │300 } │ │301 │ │229 - // build a `.repo_refs` route for "owner/name" (identity) paginating `kind`'s │ │230 - // column to `after` (0 = the first window, the other column always at 0). │ │231 - pub fn repoRefsRoute(identity: []const u8, kind: RefKind, after: usize) ?RoutablePage { │ │232 - return .{ .repo_refs = .{ .name = Array(repo_route_max_len).from(identity) orelse return null, .kind = kind, .after = after } };│ │302 + // build a `.repo_refs` route windowing `kind`'s column from the │ │303 + // url-encoded ref name `from` ("" = the first window). │ │304 + pub fn repoRefsRoute(identity: []const u8, kind: RefKind, from: []const u8) ?RoutablePage { │ │305 + return .{ .repo_refs = .{ │ │306 + .name = Array(repo_route_max_len).from(identity) orelse return null, │ │307 + .kind = kind, │ │308 + .from = Array(ref_route_max_len).from(from) orelse return null, │ │309 + } }; │ │310 } │ │311 │ │312 // build a `.repo_issues` route for "owner/name" (identity) showing │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │368 │ │369 pub fn urlAlloc(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 { │ │370 return switch (self) { │ │294 - .home_users => |after| if (after == 0) @as([]const u8, "/users") else try std.fmt.allocPrint(arena.allocator(), "/users?after={d}", .{after}), │ │295 - .home_repos => |after| if (after == 0) @as([]const u8, "/repos") else try std.fmt.allocPrint(arena.allocator(), "/repos?after={d}", .{after}), │ │371 + .home_users => |start| if (start == 0) @as([]const u8, "/users") else try std.fmt.allocPrint(arena.allocator(), "/users/" ++ start_seg ++ "{d}", .{start}),│ │372 + .home_repos => |start| if (start == 0) @as([]const u8, "/repos") else try std.fmt.allocPrint(arena.allocator(), "/repos/" ++ start_seg ++ "{d}", .{start}),│ │373 .home_settings => "/settings", │ │374 .home_auth => "/auth", │ │298 - .user_repos => |u| if (u.after == 0) │ │375 + .user_repos => |u| if (u.start == 0) │ │376 try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/repos", .{u.name.slice()}) │ │377 else │ │301 - try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/repos?after={d}", .{ u.name.slice(), u.after }), │ │378 + try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/repos/" ++ start_seg ++ "{d}", .{ u.name.slice(), u.start }), │ │379 .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name.slice()}), │ │380 .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name.slice()}), │ │304 - .repo_files => |f| blk: { │ │305 - const path = try repoUrlPath(arena, f.name.slice()); │ │306 - break :blk if (f.after == 0) path else try std.fmt.allocPrint(arena.allocator(), "{s}?after={d}", .{ path, f.after }); │ │307 - }, │ │308 - .repo_commits => |c| blk: { │ │309 - const path = try repoUrlPath(arena, c.name.slice()); │ │310 - break :blk if (c.after == 0) path else try std.fmt.allocPrint(arena.allocator(), "{s}?after={d}", .{ path, c.after }); │ │311 - }, │ │381 + .repo_files => |name| try repoUrlPath(arena, name.slice()), │ │382 + .repo_commits => |name| try repoUrlPath(arena, name.slice()), │ │383 .repo_refs => |r| blk: { │ │384 const prefix = try repoUrlPrefix(arena, r.name.slice()); │ │314 - break :blk if (r.after == 0) │ │385 + break :blk if (r.from.len == 0) │ │386 try std.fmt.allocPrint(arena.allocator(), "{s}/refs", .{prefix}) │ │387 else │ │317 - try std.fmt.allocPrint(arena.allocator(), "{s}/refs?kind={s}&after={d}", .{ prefix, @tagName(r.kind), r.after }); │ │388 + try std.fmt.allocPrint(arena.allocator(), "{s}/refs/{s}:{s}", .{ prefix, @tagName(r.kind), r.from.slice() }); │ │389 }, │ │390 .repo_issues => |i| blk: { │ │391 const prefix = try repoUrlPrefix(arena, i.name.slice()); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │321 - // list urls name the view; a selected issue's url is status-free │ │322 - // so it survives the issue changing status. │ │323 - break :blk switch (i.view) { │ │324 - .tags => if (i.tag.len == 0) │ │325 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/tags", .{prefix}) │ │326 - else │ │327 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/tags/{s}", .{ prefix, i.tag.slice() }), │ │328 - .open, .closed => |view| if (i.selected.len != 0) │ │329 - (if (i.tag.len == 0) │ │330 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, i.selected.slice() }) │ │331 - else │ │332 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/tags/{s}/{s}", .{ prefix, i.tag.slice(), i.selected.slice() }))│ │333 - else if (i.tag.len == 0) │ │334 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, @tagName(view) }) │ │335 - else │ │336 - try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/tags/{s}", .{ prefix, @tagName(view), i.tag.slice() }), │ │337 - }; │ │392 + // a selected issue's url carries its id instead of a view │ │393 + // name, so it survives status changes │ │394 + const word = if (i.selected.len != 0) i.selected.slice() else @tagName(i.view); │ │395 + break :blk if (i.tag.len == 0) │ │396 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, word }) │ │397 + else │ │398 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}/" ++ tag_filter_seg ++ "{s}", .{ prefix, word, i.tag.slice() }); │ │399 }, │ │400 .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/settings", .{try repoUrlPrefix(arena, name.slice())}), │ │401 .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{try repoUrlPrefix(arena, name.slice())}), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │425 }; │ │426 } │ │427 │ │367 - // parse a path plus its raw query string │ │368 - pub fn fromUrl(path: []const u8, query: ?[]const u8) ?RoutablePage { │ │369 - const after = uintParam(query, "after") orelse 0; │ │428 + pub fn fromUrl(path: []const u8) ?RoutablePage { │ │429 if (std.mem.eql(u8, path, "/")) return default; │ │371 - if (std.mem.eql(u8, path, "/users")) return .{ .home_users = after }; │ │372 - if (std.mem.eql(u8, path, "/repos")) return .{ .home_repos = after }; │ │373 - if (std.mem.eql(u8, path, "/settings")) return .home_settings; │ │374 - if (std.mem.eql(u8, path, "/auth")) return .home_auth; │ │375 - // /user/<name>[/repos|/settings|/auth] │ │376 - if (std.mem.startsWith(u8, path, user_segment)) { │ │377 - const rest = path[user_segment.len..]; │ │378 - var segments = std.mem.splitScalar(u8, rest, '/'); │ │430 + if (path.len < 2 or path[0] != '/') return null; │ │431 + var segments = std.mem.splitScalar(u8, path[1..], '/'); │ │432 + const first = segments.next() orelse return null; │ │433 + if (std.mem.eql(u8, first, "users")) return .{ .home_users = listStart(&segments) orelse return null };│ │434 + if (std.mem.eql(u8, first, "repos")) return .{ .home_repos = listStart(&segments) orelse return null };│ │435 + if (std.mem.eql(u8, first, "settings")) return if (segments.next() == null) .home_settings else null; │ │436 + if (std.mem.eql(u8, first, "auth")) return if (segments.next() == null) .home_auth else null; │ │437 + // "user/<name>[/repos[/start:<n>]|/settings|/auth]" │ │438 + if (std.mem.eql(u8, first, "user")) { │ │439 const name = segments.next() orelse return null; │ │440 if (name.len == 0) return null; │ │441 const parsed = Array(evt.User.name_max_len).from(name) orelse return null; // name too long │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │382 - if (segments.peek() != null) { │ │383 - const sub = segments.rest(); │ │384 - if (std.mem.eql(u8, sub, "repos")) return .{ .user_repos = .{ .name = parsed, .after = after } }; │ │385 - if (std.mem.eql(u8, sub, "settings")) return .{ .user_settings = parsed }; │ │386 - if (std.mem.eql(u8, sub, "auth")) return .{ .user_auth = parsed }; │ │387 - return null; // unknown sub-path │ │388 - } │ │389 - return .{ .user_repos = .{ .name = parsed, .after = after } }; │ │442 + const sub = segments.next() orelse return .{ .user_repos = .{ .name = parsed } }; │ │443 + if (std.mem.eql(u8, sub, "repos")) return .{ .user_repos = .{ .name = parsed, .start = listStart(&segments) orelse return null } };│ │444 + if (std.mem.eql(u8, sub, "settings")) return if (segments.next() == null) .{ .user_settings = parsed } else null; │ │445 + if (std.mem.eql(u8, sub, "auth")) return if (segments.next() == null) .{ .user_auth = parsed } else null; │ │446 + return null; // unknown sub-path │ │447 } │ │391 - // /repo/<username>/<reponame> (files root), then optionally /settings, │ │392 - // /auth, or /files/<dir> for a directory in the files tab. the `repo` │ │393 - // variant stores "username/reponame" (root) or │ │394 - // "username/reponame/files/<dir>"; settings/auth store just the pair. │ │395 - if (std.mem.startsWith(u8, path, repo_segment)) { │ │396 - const rest = path[repo_segment.len..]; │ │397 - var rest_segments = std.mem.splitScalar(u8, rest, '/'); │ │398 - const owner = rest_segments.next() orelse return null; │ │399 - const repo_name = rest_segments.next() orelse return null; │ │448 + // "repo/<username>/<reponame>[/<tab tail>]"; the bare pair is the │ │449 + // files root │ │450 + if (std.mem.eql(u8, first, "repo")) { │ │451 + const rest = segments.rest(); // "username/reponame[/...]" │ │452 + const owner = segments.next() orelse return null; │ │453 + const repo_name = segments.next() orelse return null; │ │454 // reject an empty username or reponame │ │455 if (owner.len == 0 or repo_name.len == 0) return null; │ │456 const pair = rest[0 .. owner.len + 1 + repo_name.len]; // "username/reponame" │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │403 - if (rest_segments.peek() != null) { │ │404 - return repoSubRoute(pair, rest_segments.rest(), query);│ │457 + if (segments.peek() != null) { │ │458 + return repoSubRoute(pair, segments.rest()); │ │459 } │ │406 - return repoFilesRoute(pair, null, "", "", after); │ │460 + return repoFilesRoute(pair, null, "", "", 0); │ │461 } │ │462 return null; │ │463 } │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │464 │ │465 // parse a local-mode url, which elides the "/repo/<owner>/<name>" prefix: │ │466 // "/" is the files root and the sub-paths follow directly. │ │413 - pub fn fromUrlLocal(path: []const u8, query: ?[]const u8) ?RoutablePage { │ │414 - if (std.mem.eql(u8, path, "/")) return repoFilesRoute("", null, "", "", uintParam(query, "after") orelse 0); │ │467 + pub fn fromUrlLocal(path: []const u8) ?RoutablePage { │ │468 + if (std.mem.eql(u8, path, "/")) return repoFilesRoute("", null, "", "", 0); │ │469 if (path.len < 2 or path[0] != '/') return null; │ │416 - return repoSubRoute("", path[1..], query); │ │470 + return repoSubRoute("", path[1..]); │ │471 } │ │472 │ │419 - // parse the sub-path after a repo route's identity into a route whose │ │420 - // stored string carries `pair` as its identity ("" for a local route). │ │421 - fn repoSubRoute(pair: []const u8, sub: []const u8, query: ?[]const u8) ?RoutablePage { │ │422 - const after = uintParam(query, "after") orelse 0; │ │423 - if (std.mem.eql(u8, sub, "refs")) return .{ .repo_refs = .{ │ │424 - .name = Array(repo_route_max_len).from(pair) orelse return null, │ │425 - .kind = refsKind(query), │ │426 - .after = after, │ │427 - } }; │ │428 - if (std.mem.eql(u8, sub, "issues") or std.mem.startsWith(u8, sub, "issues/")) { │ │429 - // "issues[/<issue event id>]", "issues/open|closed[/tags/<tag>]", │ │430 - // "issues/tags[/<tag>]" (the tags view, optionally filtered), or │ │431 - // "issues/tags/<tag>/<issue event id>" │ │432 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │433 - _ = segments.next(); // "issues" │ │434 - var status: evt.Issue.Status = .open; │ │435 - var tag_value: []const u8 = ""; │ │436 - var id: []const u8 = ""; │ │437 - if (segments.next()) |segment| { │ │438 - if (std.meta.stringToEnum(evt.Issue.Status, segment)) |s| { │ │439 - status = s; │ │440 - if (segments.next()) |tags_segment| { │ │441 - if (!std.mem.eql(u8, tags_segment, "tags")) return null; │ │442 - tag_value = segments.next() orelse return null; │ │443 - if (tag_value.len == 0) return null; │ │444 - } │ │445 - } else if (std.mem.eql(u8, segment, "tags")) { │ │446 - tag_value = segments.next() orelse return repoIssuesTagsRoute(pair, ""); │ │447 - if (tag_value.len == 0) return null; │ │448 - id = segments.next() orelse return repoIssuesTagsRoute(pair, tag_value); │ │449 - if (id.len == 0) return null; │ │450 - } else { │ │451 - if (segment.len == 0) return null; │ │452 - id = segment; │ │453 - } │ │454 - } │ │455 - if (segments.next() != null) return null; │ │456 - return repoIssuesRoute(pair, status, tag_value, id); │ │457 - } │ │458 - if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null }; │ │459 - if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null }; │ │460 - if (std.mem.eql(u8, sub, files_seg) or std.mem.startsWith(u8, sub, files_seg ++ "/")) { │ │461 - // "files[/<refkind>/<refvalue>[/<dir>]]" (bare = default-branch root) │ │462 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │463 - _ = segments.next(); // "files" │ │464 - const kind_segment = segments.next() orelse return repoFilesRoute(pair, null, "", "", after); │ │465 - const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │466 - const value = segments.next() orelse return null; │ │467 - if (value.len == 0) return null; │ │468 - return repoFilesRoute(pair, kind, value, segments.rest(), after); │ │469 - } │ │470 - if (std.mem.eql(u8, sub, commits_seg) or std.mem.startsWith(u8, sub, commits_seg ++ "/")) { │ │471 - // "commits[/<refkind>/<refvalue>]" (bare = default branch) │ │472 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │473 - _ = segments.next(); // "commits" │ │474 - const kind_segment = segments.next() orelse return repoCommitsRoute(pair, null, "", after); │ │475 - const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │476 - const value = segments.rest(); │ │477 - if (value.len == 0) return null; │ │478 - return repoCommitsRoute(pair, kind, value, after); │ │479 - } │ │480 - return null; // unknown sub-path │ │473 + // the dir a trailing "path:<dir>" names: the raw remainder ("" when │ │474 + // absent, null when malformed) │ │475 + fn pathValue(rest: []const u8) ?[]const u8 { │ │476 + if (rest.len == 0) return ""; │ │477 + if (!std.mem.startsWith(u8, rest, path_seg)) return null; │ │478 + const dir = rest[path_seg.len..]; │ │479 + return if (dir.len == 0) null else dir; │ │480 } │ │481 │ │483 - // the refs column a "kind=" query param names; absent or unrecognized reads │ │484 - // as the branches column. │ │485 - fn refsKind(query: ?[]const u8) RefKind { │ │486 - const qs = query orelse return .branch; │ │487 - var it = std.mem.splitScalar(u8, qs, '&'); │ │488 - while (it.next()) |pair| { │ │489 - const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; │ │490 - if (std.mem.eql(u8, pair[0..eq], "kind") and std.mem.eql(u8, pair[eq + 1 ..], "tag")) return .tag; │ │491 - } │ │492 - return .branch; │ │482 + // a list route's tail: at most a "start:<n>" param, null on anything else │ │483 + fn listStart(segments: *std.mem.SplitIterator(u8, .scalar)) ?usize { │ │484 + var params = Params{}; │ │485 + const word = params.scanTail(segments) catch return null; │ │486 + if (word != null or !params.only(&.{.start})) return null; │ │487 + return params.start(); │ │488 } │ │489 │ │495 - // read a "key=<uint>" value from a raw "a=1&b=2" query string. │ │496 - fn uintParam(query: ?[]const u8, key: []const u8) ?usize { │ │497 - const qs = query orelse return null; │ │498 - var it = std.mem.splitScalar(u8, qs, '&'); │ │499 - while (it.next()) |pair| { │ │500 - const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; │ │501 - if (std.mem.eql(u8, pair[0..eq], key)) │ │502 - return std.fmt.parseInt(usize, pair[eq + 1 ..], 10) catch null; │ │490 + // parse the sub-path after a repo route's identity ("" for a local │ │491 + // route): a tab keyword followed by "key:value" params in any order, plus │ │492 + // the files tab's trailing "path:<dir>". │ │493 + fn repoSubRoute(pair: []const u8, sub: []const u8) ?RoutablePage { │ │494 + // the params the ref-pinned tabs (files/commits) accept │ │495 + const ref_tab_keys: []const Params.ParamKey = &.{ .start, .branch, .tag, .object }; │ │496 + var segments = std.mem.splitScalar(u8, sub, '/'); │ │497 + const tab = segments.next() orelse return null; │ │498 + var params = Params{}; │ │499 + if (std.mem.eql(u8, tab, "settings")) return if (segments.next() == null) .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null } else null;│ │500 + if (std.mem.eql(u8, tab, "auth")) return if (segments.next() == null) .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null } else null; │ │501 + if (std.mem.eql(u8, tab, "refs")) { │ │502 + const word = params.scanTail(&segments) catch return null; │ │503 + if (word != null or !params.only(&.{ .branch, .tag })) return null; │ │504 + const ref = (params.ref() catch return null) orelse return repoRefsRoute(pair, .branch, ""); │ │505 + const kind = std.meta.stringToEnum(RefKind, @tagName(ref.kind)) orelse return null; │ │506 + return repoRefsRoute(pair, kind, ref.value); │ │507 + } │ │508 + if (std.mem.eql(u8, tab, "issues")) { │ │509 + // the word is a view name or an issue event id (whose status │ │510 + // picks the view); a filter with neither is never emitted. │ │511 + const word = params.scanTail(&segments) catch return null; │ │512 + if (!params.only(&.{.tag})) return null; │ │513 + const tag_value = params.values.get(.tag) orelse ""; │ │514 + const w = word orelse return if (tag_value.len == 0) repoIssuesRoute(pair, .open, "", "") else null; │ │515 + if (std.meta.stringToEnum(evt.Issue.Status, w)) |status| return repoIssuesRoute(pair, status, tag_value, ""); │ │516 + if (std.mem.eql(u8, w, "tags")) return repoIssuesTagsRoute(pair, tag_value); │ │517 + return repoIssuesRoute(pair, .open, tag_value, w); │ │518 + } │ │519 + if (std.mem.eql(u8, tab, files_seg)) { │ │520 + params.scanPairs(&segments) catch return null; │ │521 + if (!params.only(ref_tab_keys)) return null; │ │522 + const start = params.start() orelse return null; │ │523 + const dir = pathValue(segments.rest()) orelse return null; │ │524 + const ref = (params.ref() catch return null) orelse │ │525 + return if (dir.len == 0) repoFilesRoute(pair, null, "", "", start) else null; │ │526 + return repoFilesRoute(pair, ref.kind, ref.value, dir, start); │ │527 } │ │504 - return null; │ │528 + if (std.mem.eql(u8, tab, commits_seg)) { │ │529 + const word = params.scanTail(&segments) catch return null; │ │530 + if (word != null or !params.only(ref_tab_keys)) return null; │ │531 + const start = params.start() orelse return null; │ │532 + const ref = (params.ref() catch return null) orelse return repoCommitsRoute(pair, null, "", start); │ │533 + return repoCommitsRoute(pair, ref.kind, ref.value, start); │ │534 + } │ │535 + return null; // unknown sub-path │ │536 } │ │537 │ │538 pub fn eql(a: RoutablePage, b: RoutablePage) bool { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯