╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │62 // never touched by this wipe, so we don't need to save and │ │63 // restore focus across the swap. │ │64 grid.innerHTML = html; │ │65 + }, │ │66 + _pushState: function (ptr, len) { │ │67 + const url = readWasmString(ptr, len); │ │68 + // idempotent: wasm tells us its current page every tick after a│ │69 + // change, but we don't want to add history entries for a URL │ │70 + // the browser is already on (e.g., right after the page loaded │ │71 + // from a server-side render of the same route). │ │72 + if (window.location.pathname !== url) { │ │73 + history.pushState({}, "", url); │ │74 + } │ │75 }, │ │76 _setOverlay: function (ptr, len) { │ │77 const html = readWasmString(ptr, len); │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │32 var page_arena: ?std.heap.ArenaAllocator = null; │ │33 var snapshot: ?ui.Snapshot = null; │ │34 var session: ui.Session = .{}; │ │35 +// last current_page we told JS about. used to avoid pushing the URL on│ │36 +// every tick — we only call history.pushState when the tab actually │ │37 +// changes. │ │38 +var last_pushed_page: ?ui.RoutablePage = null; │ │39 │ │40 fn start(json: []const u8, min_height: u32, max_width: u32) !void { │ │41 if (page_arena) |*a| a.deinit(); │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │69 .min_size = .{ .width = null, .height = min_height }, │ │70 .max_size = .{ .width = max_width, .height = null }, │ │71 }, root_ptr.getFocus()); │ │72 + │ │73 + // mirror the page Header settled on into the browser URL. _pushState │ │74 + // is idempotent on the JS side (it bails when pathname already matches)│ │75 + // so the first tick after _start is a no-op even though we always send │ │76 + // something through. │ │77 + const current_page = session.data.current_page; │ │78 + if (last_pushed_page == null or last_pushed_page.? != current_page) { │ │79 + last_pushed_page = current_page; │ │80 + const url = current_page.url(); │ │81 + _pushState(url.ptr, @intCast(url.len)); │ │82 + } │ │83 + │ │84 try updateHtml(); │ │85 } │ │86 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │121 extern fn _consoleLog(arg: [*]const u8, len: u32) void; │ │122 extern fn _setHtml(arg: [*]const u8, len: u32) void; │ │123 extern fn _setOverlay(arg: [*]const u8, len: u32) void; │ │124 +extern fn _pushState(arg: [*]const u8, len: u32) void; │ │125 │ │126 /// js calls this first to get a wasm pointer it can write the page json into.│ │127 export fn _alloc(len: u32) ?[*]u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │18 pub const Page = union(enum) { │ │19 home: Home, │ │20 }; │ │21 + │ │22 +// a top-level "page" the user can navigate to │ │23 +pub const RoutablePage = enum { │ │24 + home_users, │ │25 + home_repos, │ │26 + home_auth, │ │27 │ │28 + pub fn url(self: RoutablePage) []const u8 { │ │29 + return switch (self) { │ │30 + .home_users => "/users", │ │31 + .home_repos => "/repos", │ │32 + .home_auth => "/auth", │ │33 + }; │ │34 + } │ │35 + │ │36 + pub fn fromUrl(path: []const u8) ?RoutablePage { │ │37 + if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/users")) return .home_users;│ │38 + if (std.mem.eql(u8, path, "/repos")) return .home_repos; │ │39 + if (std.mem.eql(u8, path, "/auth")) return .home_auth; │ │40 + return null; │ │41 + } │ │42 +}; │ │43 + │ │44 // per-connection mutable state. each SSH session / web session / local TUI │ │45 // run gets its own. `data` is the subset that round-trips between server │ │46 // and wasm; the other fields are runtime context only and stay local. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │55 user_id: ?[]const u8 = null, │ │56 // a transient outcome to surface from the last /login POST attempt │ │57 login_failure: ?Home.Auth.Login.Failure = null, │ │58 + current_page: RoutablePage = .home_users, │ │59 }; │ │60 │ │61 // what the server hands to the client (and what main_wasm parses on _start).│ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/ui/Home.zig│ ╰───────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │129 // each header tab maps 1:1 to a stack child by position │ │130 if (header.getSelectedIndex()) |index| { │ │131 stack.getFocus().child_id = stack.children.keys()[index];│ │132 + self.session.data.current_page = switch (index) { │ │133 + 0 => .home_users, │ │134 + 1 => .home_repos, │ │135 + 2 => .home_auth, │ │136 + else => .home_users, │ │137 + }; │ │138 } │ │139 try self.box.build(allocator, constraint, root_focus); │ │140 } │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Home/Header.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │71 } │ │72 │ │73 var self = View{ .box = box, .data = data, .tab_ids = tab_ids }; │ │74 - // when a previous login attempt just failed, land on the auth tab so │ │75 - // the user sees the form (with its "(invalid)" label) instead of the │ │76 - // default users list — otherwise the post-redirect page reads as if │ │77 - // the login silently succeeded. │ │78 - self.getFocus().child_id = if (session.data.login_failure != null) │ │79 - tab_ids[auth_tab_index] │ │80 - else │ │81 - tab_ids[0]; │ │74 + // initial selected tab follows session.data.current_page — the web │ │75 + // layer decides this from the request URL, the TTY uses whatever │ │76 + // the session arrives with — so we don't bake a default here. │ │77 + self.getFocus().child_id = tab_ids[switch (session.data.current_page) {│ │78 + .home_users => 0, │ │79 + .home_repos => 1, │ │80 + .home_auth => 2, │ │81 + }]; │ │82 return self; │ │83 } │ │84 │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │159 return; │ │160 } │ │161 │ │162 - if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) { │ │163 - const user_id_hex = getCookieValue(request, cookie_name); │ │162 + if (ui.RoutablePage.fromUrl(path)) |current_page| { │ │163 + // decode the haxy_user cookie into raw bytes on the stack; the │ │164 + // slice lives for the rest of handleRequest, which is plenty for │ │165 + // renderIndexHtml to consume. │ │166 + var user_id_buf: [evt.event_id_size]u8 = undefined; │ │167 + const user_id: ?[]const u8 = blk: { │ │168 + const hex = getCookieValue(request, cookie_name) orelse break :blk null;│ │169 + if (hex.len != evt.event_id_size * 2) break :blk null; │ │170 + _ = std.fmt.hexToBytes(&user_id_buf, hex) catch break :blk null; │ │171 + break :blk user_id_buf[0..evt.event_id_size]; │ │172 + }; │ │173 const login_failure: ?ui.Home.Auth.Login.Failure = │ │174 if (getCookieValue(request, login_failure_cookie)) |raw| │ │175 if (std.mem.eql(u8, raw, "unknown_user")) │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │180 null │ │181 else │ │182 null; │ │174 - const html = try renderIndexHtml(io, allocator, admin_repo_path, user_id_hex, login_failure);│ │183 + const html = try renderIndexHtml(io, allocator, admin_repo_path, .{ │ │184 + .user_id = user_id, │ │185 + .login_failure = login_failure, │ │186 + .current_page = current_page, │ │187 + }); │ │188 defer allocator.free(html); │ │189 // expire the flash cookie on the way out so a refresh doesn't keep │ │190 // showing the failure label. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯