╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │113 defer allocator.free(payload); │ │114 try terminal.writeBytes(payload); │ │115 while (terminal.popKey()) |key| { │ │116 - try ui.inputKey(allocator, &root, key, &terminal); │ │116 + try ui.inputKey(allocator, &nav.root, key, &ui_session);│ │117 } │ │118 }, │ │119 .resize => |sz| { │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │120 terminal.pushResize(.{ .width = sz.width_cells, .height = sz.height_cells });│ │121 while (terminal.popKey()) |key| { │ │122 - try ui.inputKey(allocator, &root, key, &terminal); │ │122 + try ui.inputKey(allocator, &nav.root, key, &ui_session); │ │123 } │ │124 }, │ │125 .close => terminal.requestQuit(), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │127 │ │128 try ui_session.applyAndWritePending(io, allocator, &repo); │ │129 │ │130 - try root.build(allocator, .{ │ │130 + // reconcile navigation: forward to a new page, or back on escape (and │ │131 + // quit when there's nothing left to go back to). │ │132 + if (!try nav.sync(allocator, &ui_session)) terminal.requestQuit(); │ │133 + │ │134 + try nav.root.build(allocator, .{ │ │135 .min_size = .{ .width = null, .height = null }, │ │136 .max_size = .{ .width = last_size.width, .height = last_size.height },│ │133 - }, root.getFocus()); │ │134 - _ = try terminal.render(&root, &last_grid, &last_size); │ │137 + }, nav.root.getFocus()); │ │138 + _ = try terminal.render(&nav.root, &last_grid, &last_size); │ │139 } │ │140 } │ │141 │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/test/event.zig│ ╰──────────────────╯ ╭──────────╮ │808 }│ │809 } │ │810 │ │811 - │ ╰──────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │175 try evt.commitAndConsume(evt.admin_repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume); │ │176 │ │177 session = try ui.Session.init(&page_arena, &repo, .{}); │ │178 - break :blk .{ .home = try .init(&page_arena, session.haxy_moment orelse unreachable) }; │ │178 + break :blk try ui.Page.init(&page_arena, session.haxy_moment orelse unreachable, session.data.current_page);│ │179 }; │ │180 │ │181 // start the server │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │11 const evt = @import("./event.zig"); │ │12 │ │13 pub const Home = @import("./ui/Home.zig"); │ │14 +pub const User = @import("./ui/User.zig"); │ │15 pub const Title = @import("./ui/Title.zig"); │ │16 │ │16 -pub const Page = union(enum) { │ │17 +pub const PageKind = enum { │ │18 + home, │ │19 + user, │ │20 +}; │ │21 + │ │22 +pub const Page = union(PageKind) { │ │23 home: Home, │ │24 + user: User, │ │25 + │ │26 + pub fn init(arena: *std.heap.ArenaAllocator, haxy_moment: evt.AdminDB.HashMap(.read_only), route: RoutablePage) !Page {│ │27 + return switch (route.parent()) { │ │28 + .home => .{ .home = try Home.init(arena, haxy_moment) }, │ │29 + .user => switch (route) { │ │30 + .user, .user_settings, .user_auth => |name| .{ .user = try User.init(arena, haxy_moment, name) }, │ │31 + else => return error.UnexpectedRoute, │ │32 + }, │ │33 + }; │ │34 + } │ │35 }; │ │36 │ │37 // what the server hands to the client (and what main_wasm parses on _start). │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │41 session: Session.Data = .{}, │ │42 }; │ │43 │ │27 -// a top-level "page" the user can navigate to │ │28 -pub const RoutablePage = enum { │ │44 +// a top-level "page" the user can navigate to. the user_* variants are the │ │45 +// tabs of a user page; they each carry the user's name and all map to the │ │46 +// .user parent page, so switching between them stays on that page (and just│ │47 +// updates the url) rather than navigating away. │ │48 +pub const RoutablePage = union(enum) { │ │49 home_users, │ │50 home_repos, │ │31 - settings, │ │32 - auth, │ │51 + home_settings, │ │52 + home_auth, │ │53 + user: []const u8, │ │54 + user_settings: []const u8, │ │55 + user_auth: []const u8, │ │56 │ │57 pub const default: RoutablePage = .home_users; │ │58 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │36 - pub fn url(self: RoutablePage) []const u8 { │ │59 + const user_segment = "/user/"; │ │60 + │ │61 + pub fn url(comptime self: RoutablePage) []const u8 {│ │62 return switch (self) { │ │63 .home_users => "/users", │ │64 .home_repos => "/repos", │ ╰────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │40 - .settings => "/settings", │ │41 - .auth => "/auth", │ │65 + .home_settings => "/settings", │ │66 + .home_auth => "/auth", │ │67 + // user routes carry a runtime name; build them with urlAlloc. │ │68 + .user, .user_settings, .user_auth => @compileError("user routes are dynamic; use urlAlloc"), │ │69 + }; │ │70 + } │ │71 + │ │72 + pub fn urlAlloc(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 { │ │73 + return switch (self) { │ │74 + .user => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}", .{name}), │ │75 + .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name}),│ │76 + .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name}), │ │77 + inline else => |_, tag| url(tag), │ │78 + }; │ │79 + } │ │80 + │ │81 + pub fn parent(self: RoutablePage) PageKind { │ │82 + return switch (self) { │ │83 + .home_users, .home_repos, .home_settings, .home_auth => .home, │ │84 + .user, .user_settings, .user_auth => .user, │ │85 }; │ │86 } │ │87 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 89 if (std.mem.eql(u8, path, "/")) return default; │ │ 90 if (std.mem.eql(u8, path, "/users")) return .home_users; │ │ 91 if (std.mem.eql(u8, path, "/repos")) return .home_repos; │ │ 49 - if (std.mem.eql(u8, path, "/settings")) return .settings; │ │ 50 - if (std.mem.eql(u8, path, "/auth")) return .auth; │ │ 92 + if (std.mem.eql(u8, path, "/settings")) return .home_settings; │ │ 93 + if (std.mem.eql(u8, path, "/auth")) return .home_auth; │ │ 94 + if (std.mem.startsWith(u8, path, user_segment)) { │ │ 95 + const rest = path[user_segment.len..]; │ │ 96 + const slash = std.mem.indexOfScalar(u8, rest, '/'); │ │ 97 + const name = if (slash) |s| rest[0..s] else rest; │ │ 98 + if (name.len == 0) return null; │ │ 99 + if (slash) |s| { │ │100 + const sub = rest[s + 1 ..]; │ │101 + if (std.mem.eql(u8, sub, "settings")) return .{ .user_settings = name }; │ │102 + if (std.mem.eql(u8, sub, "auth")) return .{ .user_auth = name }; │ │103 + return null; // unknown user sub-path │ │104 + } │ │105 + return .{ .user = name }; │ │106 + } │ │107 return null; │ │108 + } │ │109 + │ │110 + pub fn eql(a: RoutablePage, b: RoutablePage) bool { │ │111 + if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; │ │112 + return switch (a) { │ │113 + .user => |a_name| std.mem.eql(u8, a_name, b.user), │ │114 + .user_settings => |a_name| std.mem.eql(u8, a_name, b.user_settings), │ │115 + .user_auth => |a_name| std.mem.eql(u8, a_name, b.user_auth), │ │116 + else => true, │ │117 + }; │ │118 + } │ │119 + │ │120 + // serialize as { "kind": <tag>, "name"?: <user name> }. the default │ │121 + // union(enum) codec doesn't round-trip here: Stringify emits a void │ │122 + // variant as a bare tag string, but the parser expects an object — so the │ │123 + // server->wasm Snapshot JSON would fail to parse. │ │124 + pub fn jsonStringify(self: RoutablePage, jw: anytype) !void { │ │125 + try jw.beginObject(); │ │126 + try jw.objectField("kind"); │ │127 + try jw.write(@tagName(self)); │ │128 + switch (self) { │ │129 + .user, .user_settings, .user_auth => |name| { │ │130 + try jw.objectField("name"); │ │131 + try jw.write(name); │ │132 + }, │ │133 + else => {}, │ │134 + } │ │135 + try jw.endObject(); │ │136 + } │ │137 + │ │138 + pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !RoutablePage {│ │139 + const Helper = struct { kind: std.meta.Tag(RoutablePage), name: ?[]const u8 = null }; │ │140 + const helper = try std.json.innerParse(Helper, allocator, source, options); │ │141 + return switch (helper.kind) { │ │142 + .home_users => .home_users, │ │143 + .home_repos => .home_repos, │ │144 + .home_settings => .home_settings, │ │145 + .home_auth => .home_auth, │ │146 + .user => .{ .user = helper.name orelse return error.MissingField }, │ │147 + .user_settings => .{ .user_settings = helper.name orelse return error.MissingField }, │ │148 + .user_auth => .{ .user_auth = helper.name orelse return error.MissingField }, │ │149 + }; │ │150 } │ │151 }; │ │152 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯