╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │ 90 const target = overlay.querySelector(`[data-focus-id="${id}"]`);│ │ 91 if (target && document.activeElement !== target) target.focus();│ │ 92 }, │ │ 93 + _navigate: function (ptr, len) { │ │ 94 + // a real browser navigation to another top-level page (the wasm│ │ 95 + // can't render it client-side without server data). │ │ 96 + window.location.assign(readWasmString(ptr, len)); │ │ 97 + }, │ │ 98 }, │ │ 99 }; │ │100 │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/User.zig│ ╰──────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │10 email: []const u8, │ │11 password_hash: []const u8, │ │12 enable_ansi: bool = false, │ │13 + │ │14 +const Self = @This(); │ │15 + │ │16 +// the subset of a user that's safe to hand to clients │ │17 +pub const Safe = struct { │ │18 + name: []const u8, │ │19 + display_name: []const u8, │ │20 + │ │21 + pub fn init(user: Self) Safe { │ │22 + return .{ .name = user.name, .display_name = user.display_name };│ │23 + } │ │24 +}; │ │25 │ │26 pub fn consume( │ │27 comptime DB: type, │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │16 │ │17 fn init(json: []const u8, min_height: u32, max_width: u32) !void { │ │18 _ = page_arena.reset(.free_all); │ │19 + // the stored page can borrow a name slice from page_arena (a .user route),│ │20 + // which we just freed — drop it so the next tick's eql never reads it and │ │21 + // always pushes the fresh page's url. │ │22 + last_pushed_page_maybe = null; │ │23 │ │24 // alloc_always so the parsed strings live entirely inside the arena │ │25 // and don't borrow from the caller's json buffer. │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │63 // so the first tick after _init is a no-op even though we always send │ │64 // something through. │ │65 const current_page = session.data.current_page; │ │62 - const should_push = if (last_pushed_page_maybe) |lp| lp != current_page else true; │ │66 + const should_push = if (last_pushed_page_maybe) |lp| !lp.eql(current_page) else true;│ │67 if (should_push) { │ │68 last_pushed_page_maybe = current_page; │ │65 - const url = current_page.url(); │ │69 + const url = try current_page.urlAlloc(&page_arena); │ │70 _pushState(url.ptr, @intCast(url.len)); │ │71 } │ │72 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │113 40 => .arrow_down, │ │114 else => return, │ │115 }; │ │116 + │ │117 + if (key == .enter) { │ │118 + if (root_ptr.getFocus().grandchild_id) |gid| { │ │119 + // follow a cross-page link │ │120 + if (ui.crossPageLink(root_ptr.getFocus(), gid, session.data.current_page)) |route| {│ │121 + const url = try route.urlAlloc(&page_arena); │ │122 + navigate(url); │ │123 + return; │ │124 + } │ │125 + } │ │126 + } │ │127 + │ │128 try root_ptr.input(allocator, key, root_ptr.getFocus()); │ │129 } │ │130 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │131 fn onMouseClick(focus_id: usize) !void { │ │132 const root_ptr = if (root) |*root_value| root_value else return error.NotStarted; │ │133 + // follow a cross-page link │ │134 + if (ui.crossPageLink(root_ptr.getFocus(), focus_id, session.data.current_page)) |route| {│ │135 + const url = try route.urlAlloc(&page_arena); │ │136 + navigate(url); │ │137 + return; │ │138 + } │ │139 try root_ptr.getFocus().setFocus(focus_id); │ │140 } │ │141 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │151 _setOverlay(arg.ptr, @intCast(arg.len)); │ │152 } │ │153 │ │154 +fn navigate(arg: []const u8) void { │ │155 + _navigate(arg.ptr, @intCast(arg.len)); │ │156 +} │ │157 + │ │158 extern fn _consoleLog(arg: [*]const u8, len: u32) void;│ │159 extern fn _setHtml(arg: [*]const u8, len: u32) void; │ │160 extern fn _setOverlay(arg: [*]const u8, len: u32) void;│ ╰────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │161 extern fn _pushState(arg: [*]const u8, len: u32) void; │ │162 extern fn _focusInput(focus_id: u32) void; │ │163 +extern fn _navigate(arg: [*]const u8, len: u32) void; │ │164 │ │165 /// js calls this first to get a wasm pointer it can write the page json into.│ │166 export fn _alloc(len: u32) ?[*]u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │74 defer repo.deinit(io, allocator); │ │75 var ui_session = try ui.Session.init(&page_arena, &repo, .{}); │ │76 │ │77 - const page: ui.Page = .{ .home = try .init(&page_arena, ui_session.haxy_moment orelse unreachable) }; │ │77 + const page: ui.Page = try ui.Page.init(&page_arena, ui_session.haxy_moment orelse unreachable, ui_session.data.current_page);│ │78 │ │79 - var root = try ui.initRoot(allocator, &page, &ui_session); │ │80 - defer root.deinit(allocator); │ │79 + var nav = try ui.Nav.init(allocator, &page, &ui_session); │ │80 + defer nav.deinit(allocator); │ │81 │ │82 var session_writer_buf: [8192]u8 = undefined; │ │83 var session_writer = ssh.SessionWriter.init(sess, &session_writer_buf); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 97 defer last_grid.deinit(); │ │ 98 │ │ 99 // initial render — user sees the page immediately │ │100 - try root.build(allocator, .{ │ │100 + try nav.root.build(allocator, .{ │ │101 .min_size = .{ .width = null, .height = null }, │ │102 .max_size = .{ .width = last_size.width, .height = last_size.height },│ │103 - }, root.getFocus()); │ │104 - _ = try terminal.render(&root, &last_grid, &last_size); │ │103 + }, nav.root.getFocus()); │ │104 + _ = try terminal.render(&nav.root, &last_grid, &last_size); │ │105 │ │106 // event loop. nextEvent blocks until something interesting arrives; │ │107 // for each event, rebuild the widget tree and re-render so the user │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯