╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────╮ │src/serve.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │182 serve_common.runListener(io, net_server, tasks, err, "web ui", Context{│ │183 .io = io, │ │184 .allocator = allocator, │ │149 - .admin_repo_path = admin_repo_path, │ │150 - .session_store = session_store, │ │185 + .host = host, │ │186 .err = err, │ │187 }, handle); │ │188 } │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/test/ui.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │44 const lb = &content.children.values()[0].widget.scroll.child.box; │ │45 const next_id = lb.children.keys()[lb.children.count() - 1]; │ │46 │ │47 - const route = ui.crossPageLink(root_focus, next_id, session.data.current_page);│ │47 + const route = ui.crossPageLink(root_focus, next_id, session.data); │ │48 try std.testing.expect(route != null); │ │49 } │ │50 │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │107 // a repo route is "username/reponame", optionally followed by the files tab's│ │108 // "/files/<refkind>/<refvalue>/<dir>" (refkind/refvalue name the ref the tree│ │109 // is read at). the bare "username/reponame" (and "username/reponame/files") │ │110 - // mean the files root of the repo's default branch. │ │110 + // mean the files root of the repo's default branch. a local session's routes │ │111 + // elide the identity: the stored string is "" at the bare root, or starts │ │112 + // straight at the tail's separator ("/files/..."). │ │113 pub const repo_route_max_len = 1024; │ │114 │ │115 + // true when a repo route's stored string elides its identity (local mode) │ │116 + fn identityElided(s: []const u8) bool { │ │117 + return s.len == 0 or s[0] == '/'; │ │118 + } │ │119 + │ │120 // a url-encoded tag can grow to three bytes per source byte. │ │121 pub const issue_tag_route_max_len = evt.Issue.tag_max_len * 3; │ │122 │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │132 dir: []const u8, │ │133 │ │134 // parse a `.repo_files` route's stored string ("owner/name" or │ │128 - // "owner/name/files/<refkind>/<refvalue>[/<dir>]"). a malformed tail │ │129 - // reads as the bare default-branch root. │ │135 + // "owner/name/files/<refkind>/<refvalue>[/<dir>]", identity elided in │ │136 + // local mode). a malformed tail reads as the bare default-branch root.│ │137 pub fn parse(s: []const u8) ?RepoFiles { │ │131 - var segments = std.mem.splitScalar(u8, s, '/'); │ │132 - const owner = segments.next() orelse return null; │ │133 - const name = segments.next() orelse return null; │ │134 - if (owner.len == 0 or name.len == 0) return null; │ │138 var result: RepoFiles = .{ │ │136 - .identity = s[0 .. owner.len + 1 + name.len], │ │137 - .owner = owner, │ │138 - .name = name, │ │139 + .identity = "", │ │140 + .owner = "", │ │141 + .name = "", │ │142 .ref_kind = null, │ │143 .ref_value = "", │ │144 .dir = "", │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │145 }; │ │146 + var rest: []const u8 = undefined; │ │147 + if (identityElided(s)) { │ │148 + rest = if (s.len == 0) "" else s[1..]; │ │149 + } else { │ │150 + var id_segments = std.mem.splitScalar(u8, s, '/'); │ │151 + const owner = id_segments.next() orelse return null; │ │152 + const name = id_segments.next() orelse return null; │ │153 + if (owner.len == 0 or name.len == 0) return null; │ │154 + result.identity = s[0 .. owner.len + 1 + name.len]; │ │155 + result.owner = owner; │ │156 + result.name = name; │ │157 + rest = id_segments.rest(); │ │158 + } │ │159 + var segments = std.mem.splitScalar(u8, rest, '/'); │ │160 if (!std.mem.eql(u8, segments.next() orelse return result, files_seg)) return result; │ │161 const kind = RefOrOid.fromSeg(segments.next() orelse return result) orelse return result;│ │162 const value = segments.next() orelse return result; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │189 pub const CommitsRef = struct { ref_or_oid: ?RefOrOid, value: []const u8 }; │ │190 pub fn repoCommitsRef(s: []const u8) CommitsRef { │ │191 const bare = CommitsRef{ .ref_or_oid = null, .value = "" }; │ │192 + if (s.len == 0) return bare; │ │193 var segments = std.mem.splitScalar(u8, s, '/'); │ │176 - _ = segments.next(); // owner │ │177 - _ = segments.next(); // name │ │194 + if (identityElided(s)) { │ │195 + _ = segments.next(); // the empty segment before the tail │ │196 + } else { │ │197 + _ = segments.next(); // owner │ │198 + _ = segments.next(); // name │ │199 + } │ │200 if (!std.mem.eql(u8, segments.next() orelse "", commits_seg)) return bare; │ │201 const kind = RefOrOid.fromSeg(segments.next() orelse "") orelse return bare;│ │202 const value = segments.rest(); │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │267 try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/repos?after={d}", .{ u.name.slice(), u.after }), │ │268 .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name.slice()}), │ │269 .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name.slice()}), │ │248 - .repo_files => |f| if (f.after == 0) │ │249 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{f.name.slice()}) │ │250 - else │ │251 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}?after={d}", .{ f.name.slice(), f.after }), │ │252 - .repo_commits => |c| if (c.after == 0) │ │253 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{c.name.slice()}) │ │254 - else │ │255 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}?after={d}", .{ c.name.slice(), c.after }), │ │256 - .repo_refs => |r| if (r.after == 0) │ │257 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs", .{r.name.slice()}) │ │258 - else │ │259 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs?kind={s}&after={d}", .{ r.name.slice(), @tagName(r.kind), r.after }), │ │260 - .repo_issues => |i| if (i.tag.len == 0) │ │261 - (if (i.selected.len == 0) │ │262 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues", .{i.name.slice()}) │ │270 + .repo_files => |f| blk: { │ │271 + const path = try repoUrlPath(arena, f.name.slice()); │ │272 + break :blk if (f.after == 0) path else try std.fmt.allocPrint(arena.allocator(), "{s}?after={d}", .{ path, f.after }); │ │273 + }, │ │274 + .repo_commits => |c| blk: { │ │275 + const path = try repoUrlPath(arena, c.name.slice()); │ │276 + break :blk if (c.after == 0) path else try std.fmt.allocPrint(arena.allocator(), "{s}?after={d}", .{ path, c.after }); │ │277 + }, │ │278 + .repo_refs => |r| blk: { │ │279 + const prefix = try repoUrlPrefix(arena, r.name.slice()); │ │280 + break :blk if (r.after == 0) │ │281 + try std.fmt.allocPrint(arena.allocator(), "{s}/refs", .{prefix}) │ │282 + else │ │283 + try std.fmt.allocPrint(arena.allocator(), "{s}/refs?kind={s}&after={d}", .{ prefix, @tagName(r.kind), r.after }); │ │284 + }, │ │285 + .repo_issues => |i| blk: { │ │286 + const prefix = try repoUrlPrefix(arena, i.name.slice()); │ │287 + break :blk if (i.tag.len == 0) │ │288 + (if (i.selected.len == 0) │ │289 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues", .{prefix}) │ │290 + else │ │291 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/{s}", .{ prefix, i.selected.slice() })) │ │292 + else if (i.selected.len == 0) │ │293 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/tag/{s}", .{ prefix, i.tag.slice() }) │ │294 else │ │264 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues/{s}", .{ i.name.slice(), i.selected.slice() })) │ │265 - else if (i.selected.len == 0) │ │266 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues/tag/{s}", .{ i.name.slice(), i.tag.slice() }) │ │267 - else │ │268 - try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues/tag/{s}/{s}", .{ i.name.slice(), i.tag.slice(), i.selected.slice() }),│ │269 - .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/settings", .{name.slice()}), │ │270 - .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/auth", .{name.slice()}), │ │295 + try std.fmt.allocPrint(arena.allocator(), "{s}/issues/tag/{s}/{s}", .{ prefix, i.tag.slice(), i.selected.slice() }); │ │296 + }, │ │297 + .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/settings", .{try repoUrlPrefix(arena, name.slice())}), │ │298 + .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{try repoUrlPrefix(arena, name.slice())}), │ │299 }; │ │300 } │ │301 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │274 - // the url with its leading "/repo/<owner>/<name>" elided, for local mode │ │275 - // (every url there points at the one local repo). non-repo routes serialize │ │276 - // in full. │ │277 - pub fn urlAllocLocal(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 {│ │278 - const full = try self.urlAlloc(arena); │ │279 - const name_str: []const u8 = switch (self) { │ │280 - .repo_files => |f| f.name.slice(), │ │281 - .repo_commits => |c| c.name.slice(), │ │282 - .repo_refs => |r| r.name.slice(), │ │283 - .repo_issues => |i| i.name.slice(), │ │284 - .repo_settings, .repo_auth => |n| n.slice(), │ │285 - else => return full, │ │286 - }; │ │287 - const identity = (RepoFiles.parse(name_str) orelse return full).identity; │ │288 - const rest = full[repo_segment.len + identity.len ..]; │ │289 - if (rest.len == 0) return "/"; │ │290 - if (rest[0] == '/') return rest; │ │291 - return try std.fmt.allocPrint(arena.allocator(), "/{s}", .{rest}); │ │302 + // the url path a files/commits route's stored string (identity + tail) maps │ │303 + // to: "/repo/<string>", or the tail itself when the identity is elided │ │304 + // ("/" at the bare root). │ │305 + fn repoUrlPath(arena: *std.heap.ArenaAllocator, name: []const u8) ![]const u8 { │ │306 + if (identityElided(name)) return if (name.len == 0) "/" else name; │ │307 + return std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{name}); │ │308 + } │ │309 + │ │310 + // the url prefix for a repo route's identity: "/repo/<identity>", or "" │ │311 + // when the identity is elided. │ │312 + fn repoUrlPrefix(arena: *std.heap.ArenaAllocator, identity: []const u8) ![]const u8 { │ │313 + if (identity.len == 0) return ""; │ │314 + return std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}", .{identity}); │ │315 } │ │316 │ │317 pub fn parent(self: RoutablePage) PageKind { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │359 if (owner.len == 0 or repo_name.len == 0) return null; │ │360 const pair = rest[0 .. owner.len + 1 + repo_name.len]; // "username/reponame" │ │361 if (rest_segments.peek() != null) { │ │339 - const sub = rest_segments.rest(); │ │340 - if (std.mem.eql(u8, sub, "refs")) return .{ .repo_refs = .{ │ │341 - .name = Array(repo_route_max_len).from(pair) orelse return null, │ │342 - .kind = refsKind(query), │ │343 - .after = after, │ │344 - } }; │ │345 - if (std.mem.eql(u8, sub, "issues") or std.mem.startsWith(u8, sub, "issues/")) { │ │346 - // "issues[/tag/<tag>][/<issue event id>]" │ │347 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │348 - _ = segments.next(); // "issues" │ │349 - var tag_value: []const u8 = ""; │ │350 - var id: []const u8 = ""; │ │351 - if (segments.next()) |segment| { │ │352 - if (std.mem.eql(u8, segment, "tag")) { │ │353 - tag_value = segments.next() orelse return null; │ │354 - if (tag_value.len == 0) return null; │ │355 - id = segments.next() orelse ""; │ │356 - } else { │ │357 - if (segment.len == 0) return null; │ │358 - id = segment; │ │359 - } │ │360 - } │ │361 - if (segments.next() != null) return null; │ │362 - return repoIssuesRoute(pair, tag_value, id); │ │363 - } │ │364 - if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null };│ │365 - if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null }; │ │366 - if (std.mem.eql(u8, sub, files_seg) or std.mem.startsWith(u8, sub, files_seg ++ "/")) { │ │367 - // "files[/<refkind>/<refvalue>[/<dir>]]" (bare = default-branch root) │ │368 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │369 - _ = segments.next(); // "files" │ │370 - const kind_segment = segments.next() orelse return repoFilesRoute(pair, null, "", "", after); │ │371 - const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │372 - const value = segments.next() orelse return null; │ │373 - if (value.len == 0) return null; │ │374 - return repoFilesRoute(pair, kind, value, segments.rest(), after); │ │375 - } │ │376 - if (std.mem.eql(u8, sub, commits_seg) or std.mem.startsWith(u8, sub, commits_seg ++ "/")) { │ │377 - // "commits[/<refkind>/<refvalue>]" (bare = default branch) │ │378 - var segments = std.mem.splitScalar(u8, sub, '/'); │ │379 - _ = segments.next(); // "commits" │ │380 - const kind_segment = segments.next() orelse return repoCommitsRoute(pair, null, "", after); │ │381 - const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │382 - const value = segments.rest(); │ │383 - if (value.len == 0) return null; │ │384 - return repoCommitsRoute(pair, kind, value, after); │ │385 - } │ │386 - return null; // unknown sub-path │ │362 + return repoSubRoute(pair, rest_segments.rest(), query); │ │363 } │ │364 return repoFilesRoute(pair, null, "", "", after); │ │365 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │366 return null; │ │367 + } │ │368 + │ │369 + // parse a local-mode url, which elides the "/repo/<owner>/<name>" prefix: │ │370 + // "/" is the files root and the sub-paths follow directly. │ │371 + pub fn fromUrlLocal(path: []const u8, query: ?[]const u8) ?RoutablePage { │ │372 + if (std.mem.eql(u8, path, "/")) return repoFilesRoute("", null, "", "", uintParam(query, "after") orelse 0); │ │373 + if (path.len < 2 or path[0] != '/') return null; │ │374 + return repoSubRoute("", path[1..], query); │ │375 + } │ │376 + │ │377 + // parse the sub-path after a repo route's identity into a route whose │ │378 + // stored string carries `pair` as its identity ("" for a local route). │ │379 + fn repoSubRoute(pair: []const u8, sub: []const u8, query: ?[]const u8) ?RoutablePage { │ │380 + const after = uintParam(query, "after") orelse 0; │ │381 + if (std.mem.eql(u8, sub, "refs")) return .{ .repo_refs = .{ │ │382 + .name = Array(repo_route_max_len).from(pair) orelse return null, │ │383 + .kind = refsKind(query), │ │384 + .after = after, │ │385 + } }; │ │386 + if (std.mem.eql(u8, sub, "issues") or std.mem.startsWith(u8, sub, "issues/")) { │ │387 + // "issues[/tag/<tag>][/<issue event id>]" │ │388 + var segments = std.mem.splitScalar(u8, sub, '/'); │ │389 + _ = segments.next(); // "issues" │ │390 + var tag_value: []const u8 = ""; │ │391 + var id: []const u8 = ""; │ │392 + if (segments.next()) |segment| { │ │393 + if (std.mem.eql(u8, segment, "tag")) { │ │394 + tag_value = segments.next() orelse return null; │ │395 + if (tag_value.len == 0) return null; │ │396 + id = segments.next() orelse ""; │ │397 + } else { │ │398 + if (segment.len == 0) return null; │ │399 + id = segment; │ │400 + } │ │401 + } │ │402 + if (segments.next() != null) return null; │ │403 + return repoIssuesRoute(pair, tag_value, id); │ │404 + } │ │405 + if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null };│ │406 + if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null }; │ │407 + if (std.mem.eql(u8, sub, files_seg) or std.mem.startsWith(u8, sub, files_seg ++ "/")) { │ │408 + // "files[/<refkind>/<refvalue>[/<dir>]]" (bare = default-branch root) │ │409 + var segments = std.mem.splitScalar(u8, sub, '/'); │ │410 + _ = segments.next(); // "files" │ │411 + const kind_segment = segments.next() orelse return repoFilesRoute(pair, null, "", "", after); │ │412 + const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │413 + const value = segments.next() orelse return null; │ │414 + if (value.len == 0) return null; │ │415 + return repoFilesRoute(pair, kind, value, segments.rest(), after); │ │416 + } │ │417 + if (std.mem.eql(u8, sub, commits_seg) or std.mem.startsWith(u8, sub, commits_seg ++ "/")) { │ │418 + // "commits[/<refkind>/<refvalue>]" (bare = default branch) │ │419 + var segments = std.mem.splitScalar(u8, sub, '/'); │ │420 + _ = segments.next(); // "commits" │ │421 + const kind_segment = segments.next() orelse return repoCommitsRoute(pair, null, "", after); │ │422 + const kind = RefOrOid.fromSeg(kind_segment) orelse return null; │ │423 + const value = segments.rest(); │ │424 + if (value.len == 0) return null; │ │425 + return repoCommitsRoute(pair, kind, value, after); │ │426 + } │ │427 + return null; // unknown sub-path │ │428 } │ │429 │ │430 // the refs column a "kind=" query param names; absent or unrecognized reads │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯