╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │27 user: User, │ │28 repo: Repo, │ │29 │ │30 - pub fn init(arena: *std.heap.ArenaAllocator, haxy_moment: evt.AdminDB.HashMap(.read_only), route: RoutablePage) !Page {│ │30 + pub fn init(arena: *std.heap.ArenaAllocator, session: *Session, route: RoutablePage) !Page { │ │31 + const haxy_moment = session.haxy_moment orelse return error.NoMoment; │ │32 return switch (route.parent()) { │ │33 .home => .{ .home = try Home.init(arena, haxy_moment) }, │ │34 .user => switch (route) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │36 else => return error.UnexpectedRoute, │ │37 }, │ │38 .repo => switch (route) { │ │38 - .repo, .repo_settings, .repo_auth => |name| .{ .repo = try Repo.init(arena, haxy_moment, name) },│ │39 + .repo, .repo_settings, .repo_auth => |name| .{ .repo = try Repo.init(arena, session, name) }, │ │40 else => return error.UnexpectedRoute, │ │41 }, │ │42 }; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │62 user: Array(evt.User.name_max_len), │ │63 user_settings: Array(evt.User.name_max_len), │ │64 user_auth: Array(evt.User.name_max_len), │ │64 - repo: Array(repo_name_max_len), │ │65 - repo_settings: Array(repo_name_max_len), │ │66 - repo_auth: Array(repo_name_max_len), │ │65 + repo: Array(repo_route_max_len), │ │66 + repo_settings: Array(repo_route_max_len), │ │67 + repo_auth: Array(repo_route_max_len), │ │68 │ │69 pub const default: RoutablePage = .home_users;│ │70 │ ╰──────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 71 const user_segment = "/user/"; │ │ 72 const repo_segment = "/repo/"; │ │ 73 + const files_seg = "files"; │ │ 74 + │ │ 75 + // a repo route is "username/reponame", optionally followed by │ │ 76 + // "/files/<dir>" for a directory in the files tab │ │ 77 + pub const repo_route_max_len = 1024; │ │ 78 │ │ 73 - // a repo is identified by "username/reponame"; the route carries both names │ │ 74 - // joined by '/', so the inline name must fit both plus the separator. │ │ 75 - pub const repo_name_max_len = evt.User.name_max_len + 1 + evt.Repo.name_max_len; │ │ 79 + // the parts of a `.repo` files route. owner/name/dir are slices into the │ │ 80 + // route's stored string; dir is "" at the repo root. │ │ 81 + pub const RepoFiles = struct { │ │ 82 + identity: []const u8, // "owner/name" │ │ 83 + owner: []const u8, │ │ 84 + name: []const u8, │ │ 85 + dir: []const u8, │ │ 86 + │ │ 87 + // parse a `.repo` route's stored string ("owner/name" or │ │ 88 + // "owner/name/files/<dir>"). │ │ 89 + pub fn parse(s: []const u8) ?RepoFiles { │ │ 90 + const s1 = std.mem.indexOfScalar(u8, s, '/') orelse return null; │ │ 91 + const owner = s[0..s1]; │ │ 92 + const after = s[s1 + 1 ..]; │ │ 93 + const s2 = std.mem.indexOfScalar(u8, after, '/'); │ │ 94 + const name = if (s2) |i| after[0..i] else after; │ │ 95 + if (owner.len == 0 or name.len == 0) return null; │ │ 96 + const dir = if (s2) |i| blk: { │ │ 97 + const tail = after[i + 1 ..]; // "files" or "files/<dir>" │ │ 98 + break :blk if (std.mem.startsWith(u8, tail, files_seg ++ "/")) tail[files_seg.len + 1 ..] else "";│ │ 99 + } else ""; │ │100 + return .{ .identity = s[0 .. s1 + 1 + name.len], .owner = owner, .name = name, .dir = dir }; │ │101 + } │ │102 + }; │ │103 + │ │104 + // build a `.repo` files route for "owner/name" (identity) at `dir` ("" = │ │105 + // root), or null if the result doesn't fit the inline name. │ │106 + pub fn repoFilesRoute(identity: []const u8, dir: []const u8) ?RoutablePage { │ │107 + if (dir.len == 0) return .{ .repo = Array(repo_route_max_len).from(identity) orelse return null }; │ │108 + var buf: [repo_route_max_len]u8 = undefined; │ │109 + const s = std.fmt.bufPrint(&buf, "{s}/" ++ files_seg ++ "/{s}", .{ identity, dir }) catch return null; │ │110 + return .{ .repo = Array(repo_route_max_len).from(s) orelse return null }; │ │111 + } │ │112 │ │113 // an inline, owned array of data. keeping it in the route (rather than a │ │114 // borrowed slice) makes RoutablePage a plain value: it can be copied, stored │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────╮ │116 pub fn Array(comptime max_len: usize) type { │ │117 return struct { │ │118 bytes: [max_len]u8 = undefined, │ │ 83 - len: u8 = 0, │ │119 + len: u16 = 0, │ │120 │ │121 pub fn from(s: []const u8) ?Array(max_len) {│ │122 if (s.len > max_len) return null; │ ╰─────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │183 } │ │184 return .{ .user = parsed }; │ │185 } │ │150 - // /repo/<username>/<reponame>[/settings|/auth] — the name carried by the │ │151 - // route is the "username/reponame" pair (both segments, joined). │ │186 + // /repo/<username>/<reponame> (files root), then optionally /settings, │ │187 + // /auth, or /files/<dir> for a directory in the files tab. the `repo` │ │188 + // variant stores "username/reponame" (root) or │ │189 + // "username/reponame/files/<dir>"; settings/auth store just the pair. │ │190 if (std.mem.startsWith(u8, path, repo_segment)) { │ │191 const rest = path[repo_segment.len..]; │ │192 const user_slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null;│ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │195 const repo_name = if (repo_slash) |s| after_user[0..s] else after_user; │ │196 // reject an empty username or reponame │ │197 if (user_slash == 0 or repo_name.len == 0) return null; │ │160 - const pair = rest[0 .. user_slash + 1 + repo_name.len]; │ │161 - const parsed = Array(repo_name_max_len).from(pair) orelse return null; // name too long │ │198 + const pair = rest[0 .. user_slash + 1 + repo_name.len]; // "username/reponame" │ │199 if (repo_slash) |s| { │ │200 const sub = after_user[s + 1 ..]; │ │164 - if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = parsed }; │ │165 - if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = parsed }; │ │201 + if (std.mem.eql(u8, sub, "settings")) return .{ .repo_settings = Array(repo_route_max_len).from(pair) orelse return null };│ │202 + if (std.mem.eql(u8, sub, "auth")) return .{ .repo_auth = Array(repo_route_max_len).from(pair) orelse return null }; │ │203 + if (std.mem.eql(u8, sub, files_seg)) return repoFilesRoute(pair, ""); // trailing /files == root │ │204 + if (std.mem.startsWith(u8, sub, files_seg ++ "/")) return repoFilesRoute(pair, sub[files_seg.len + 1 ..]); │ │205 return null; // unknown sub-path │ │206 } │ │168 - return .{ .repo = parsed }; │ │207 + return repoFilesRoute(pair, ""); │ │208 } │ │209 return null; │ │210 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │222 }; │ │223 } │ │224 │ │225 + // true when `a` and `b` are both `.repo` files routes pointing at different │ │226 + // directories. a repo directory change is a navigation (its own page); │ │227 + // settings/auth tab switches are not, so they don't satisfy this. │ │228 + pub fn repoDirChanged(a: RoutablePage, b: RoutablePage) bool { │ │229 + return std.meta.activeTag(a) == .repo and std.meta.activeTag(b) == .repo and !a.eql(b);│ │230 + } │ │231 + │ │232 // serialize as { "kind": <tag>, "name"?: <user name> }. the default │ │233 // union(enum) codec doesn't round-trip here: Stringify emits a void │ │234 // variant as a bare tag string, but the parser expects an object — so the │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │269 .user => .{ .user = try parseName(evt.User.name_max_len, helper.name) }, │ │270 .user_settings => .{ .user_settings = try parseName(evt.User.name_max_len, helper.name) },│ │271 .user_auth => .{ .user_auth = try parseName(evt.User.name_max_len, helper.name) }, │ │226 - .repo => .{ .repo = try parseName(repo_name_max_len, helper.name) }, │ │227 - .repo_settings => .{ .repo_settings = try parseName(repo_name_max_len, helper.name) }, │ │228 - .repo_auth => .{ .repo_auth = try parseName(repo_name_max_len, helper.name) }, │ │272 + .repo => .{ .repo = try parseName(repo_route_max_len, helper.name) }, │ │273 + .repo_settings => .{ .repo_settings = try parseName(repo_route_max_len, helper.name) }, │ │274 + .repo_auth => .{ .repo_auth = try parseName(repo_route_max_len, helper.name) }, │ │275 }; │ │276 } │ │277 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │289 // where a render is one-shot, this points at the same arena as `arena`. │ │290 page_arena: *std.heap.ArenaAllocator, │ │291 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) │ │292 + // filesystem io and the path to <server>/repos, for opening on-disk repos │ │293 + // during page construction. both null on wasm, which has no filesystem and │ │294 + // rebuilds pages from the serialized snapshot rather than from disk. │ │295 + io: ?std.Io = null, │ │296 + repos_dir: ?[]const u8 = null, │ │297 pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame │ │298 nav_back: bool = false, // set by input (escape) to request the native TUI pop a page; see Nav│ │299 is_terminal: bool = false, // true on remote SSH and local TUI │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯