╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────╮ │50 home_repos, │ │51 home_settings, │ │52 home_auth, │ │53 - user: []const u8, │ │54 - user_settings: []const u8, │ │55 - user_auth: []const u8, │ │53 + user: Array(evt.User.name_max_len), │ │54 + user_settings: Array(evt.User.name_max_len), │ │55 + user_auth: Array(evt.User.name_max_len), │ │56 │ │57 pub const default: RoutablePage = .home_users;│ │58 │ ╰──────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │59 const user_segment = "/user/"; │ │60 + │ │61 + // an inline, owned array of data. keeping it in the route (rather than a │ │62 + // borrowed slice) makes RoutablePage a plain value: it can be copied, stored│ │63 + // in history, and serialized without any arena tracking. │ │64 + pub fn Array(comptime max_len: usize) type { │ │65 + return struct { │ │66 + bytes: [max_len]u8 = undefined, │ │67 + len: u8 = 0, │ │68 │ │69 + pub fn from(s: []const u8) ?Array(max_len) { │ │70 + if (s.len > max_len) return null; │ │71 + var name = Array(max_len){ .len = @intCast(s.len) }; │ │72 + @memcpy(name.bytes[0..s.len], s); │ │73 + return name; │ │74 + } │ │75 + │ │76 + pub fn slice(self: *const Array(max_len)) []const u8 { │ │77 + return self.bytes[0..self.len]; │ │78 + } │ │79 + }; │ │80 + } │ │81 + │ │82 pub fn url(comptime self: RoutablePage) []const u8 { │ │83 return switch (self) { │ │84 .home_users => "/users", │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 92 │ │ 93 pub fn urlAlloc(self: RoutablePage, arena: *std.heap.ArenaAllocator) ![]const u8 { │ │ 94 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}), │ │ 95 + .user => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}", .{name.slice()}), │ │ 96 + .user_settings => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/settings", .{name.slice()}),│ │ 97 + .user_auth => |name| try std.fmt.allocPrint(arena.allocator(), user_segment ++ "{s}/auth", .{name.slice()}), │ │ 98 inline else => |_, tag| url(tag), │ │ 99 }; │ │100 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │117 const slash = std.mem.indexOfScalar(u8, rest, '/'); │ │118 const name = if (slash) |s| rest[0..s] else rest; │ │119 if (name.len == 0) return null; │ │120 + const parsed = Array(evt.User.name_max_len).from(name) orelse return null; // name too long│ │121 if (slash) |s| { │ │122 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 }; │ │123 + if (std.mem.eql(u8, sub, "settings")) return .{ .user_settings = parsed }; │ │124 + if (std.mem.eql(u8, sub, "auth")) return .{ .user_auth = parsed }; │ │125 return null; // unknown user sub-path │ │126 } │ │105 - return .{ .user = name }; │ │127 + return .{ .user = parsed }; │ │128 } │ │129 return null; │ │130 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │132 pub fn eql(a: RoutablePage, b: RoutablePage) bool { │ │133 if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; │ │134 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), │ │135 + .user => |a_name| std.mem.eql(u8, a_name.slice(), b.user.slice()), │ │136 + .user_settings => |a_name| std.mem.eql(u8, a_name.slice(), b.user_settings.slice()),│ │137 + .user_auth => |a_name| std.mem.eql(u8, a_name.slice(), b.user_auth.slice()), │ │138 else => true, │ │139 }; │ │140 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────╮ │150 switch (self) { │ │151 .user, .user_settings, .user_auth => |name| {│ │152 try jw.objectField("name"); │ │131 - try jw.write(name); │ │153 + try jw.write(name.slice()); │ │154 }, │ │155 else => {}, │ │156 } │ ╰──────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │160 pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !RoutablePage { │ │161 const Helper = struct { kind: std.meta.Tag(RoutablePage), name: ?[]const u8 = null }; │ │162 const helper = try std.json.innerParse(Helper, allocator, source, options); │ │163 + const parseName = struct { │ │164 + // errors must stay within std.json's ParseError set; ValueTooLong │ │165 + // is its member for an over-long field. │ │166 + fn f(maybe: ?[]const u8) !Array(evt.User.name_max_len) { │ │167 + return Array(evt.User.name_max_len).from(maybe orelse return error.MissingField) orelse error.ValueTooLong;│ │168 + } │ │169 + }.f; │ │170 return switch (helper.kind) { │ │171 .home_users => .home_users, │ │172 .home_repos => .home_repos, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │173 .home_settings => .home_settings, │ │174 .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 }, │ │175 + .user => .{ .user = try parseName(helper.name) }, │ │176 + .user_settings => .{ .user_settings = try parseName(helper.name) }, │ │177 + .user_auth => .{ .user_auth = try parseName(helper.name) }, │ │178 }; │ │179 } │ │180 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │183 // run gets its own. │ │184 pub const Session = struct { │ │185 data: Data = .{}, │ │186 + // session-lifetime allocations: login/user_id, persisted prefs, queued │ │187 + // actions. lives as long as the connection. │ │188 arena: *std.heap.ArenaAllocator, │ │189 + // current page's allocations: page data and the page-scoped strings widgets │ │190 + // build (form actions, links). owned by Nav, swapped on each navigation, so │ │191 + // it doesn't accumulate over a long-lived session. on the web/wasm paths, │ │192 + // where a render is one-shot, this points at the same arena as `arena`. │ │193 + page_arena: *std.heap.ArenaAllocator, │ │194 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) │ │195 pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame │ │196 nav_back: bool = false, // set by input (escape) to request the native TUI pop a page; see Nav│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │224 var session = Self{ │ │225 .data = data, │ │226 .arena = arena, │ │227 + // until a host swaps in a page-scoped arena (Nav does this), page │ │228 + // allocations land in the session arena. on the web path that's the│ │229 + // intended behavior, since the whole arena is per-request. │ │230 + .page_arena = arena, │ │231 .haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo), │ │232 }; │ │233 try session.loadUserPrefs(); │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯