╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │87 overlay.innerHTML = html; │ │88 }, │ │89 _focusInput: function (id) { │ │90 - const target = overlay.querySelector(`input[data-focus-id="${id}"]`);│ │90 + const target = overlay.querySelector(`[data-focus-id="${id}"]`); │ │91 if (target && document.activeElement !== target) target.focus(); │ │92 }, │ │93 }, │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │101 wasmInstance.exports._tick(minRows(), maxCols()); │ │102 } │ │103 │ │104 +function sendEnter(form) { │ │105 + const btn = form.querySelector("[data-focus-id]"); │ │106 + if (!btn) return; │ │107 + wasmInstance.exports._onMouseClick(Number(btn.dataset.focusId)); │ │108 + wasmInstance.exports._onKeyDown(13); │ │109 + wasmInstance.exports._tick(minRows(), maxCols()); │ │110 +} │ │111 + │ │112 WebAssembly.instantiateStreaming(fetch("haxy.wasm"), importObject).then(async (result) => {│ │113 wasmInstance = result.instance; │ │114 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │134 // the event so the TUI can move focus between widgets. │ │135 if (document.activeElement) { │ │136 const tag = document.activeElement.tagName; │ │137 + const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown" || │ │138 + event.key === "ArrowLeft" || event.key === "ArrowRight"; │ │139 if (tag === "INPUT" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {│ │140 + // up/down moves between widgets; left/right stay in the input │ │141 + document.activeElement.blur(); │ │142 + } else if (tag === "BUTTON" && isArrow) { │ │143 + // arrows navigate away from a focused submit button │ │144 document.activeElement.blur(); │ │145 } else if (tag === "INPUT" || tag === "BUTTON") { │ │146 + // Enter (submit), typing, Tab — handled natively by the browser │ │147 return; │ │148 } │ │149 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │11 pub const Issue = @import("event/Issue.zig"); │ │12 │ │13 pub const event_id_size: usize = 32; │ │14 + │ │15 +// the branch haxy events are committed to before being consumed │ │16 +pub const events_ref: rf.Ref = .{ .kind = .head, .name = "haxy/meta" };│ │17 │ │18 pub const EventKind = enum { │ │19 user, │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │319 │ │320 if (last_object_id_maybe) |*last_object_id| { │ │321 try state.extra.moment.put(hash.hashInt(repo_opts.hash, "haxy-last-object-id"), .{ .bytes = last_object_id }); │ │322 + } │ │323 +} │ │324 + │ │325 +pub fn currentMoment( │ │326 + comptime repo_opts: rp.RepoOpts(.xit), │ │327 + repo: *rp.Repo(.xit, repo_opts), │ │328 +) !rp.Repo(.xit, repo_opts).DB.HashMap(.read_only) { │ │329 + const DB = rp.Repo(.xit, repo_opts).DB; │ │330 + const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); │ │331 + const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; │ │332 + const moment = try DB.HashMap(.read_only).init(moment_cursor); │ │333 + const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound;│ │334 + var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; │ │335 + _ = try last_object_id_cursor.readBytes(&last_object_id); │ │336 + const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; │ │337 + const haxy = try DB.ArrayList(.read_only).init(haxy_cursor); │ │338 + const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; │ │339 + const haxy_moments = try DB.HashMap(.read_only).init(haxy_moments_cursor); │ │340 + const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound;│ │341 + return try DB.HashMap(.read_only).init(haxy_moment_cursor); │ │342 +} │ │343 + │ │344 +// commit each event as a JSON message on the events branch, then consume them │ │345 +// into the database │ │346 +pub fn commitAndConsume( │ │347 + comptime repo_opts: rp.RepoOpts(.xit), │ │348 + io: std.Io, │ │349 + allocator: std.mem.Allocator, │ │350 + repo: *rp.Repo(.xit, repo_opts), │ │351 + ref: rf.Ref, │ │352 + events: []const EventWithId, │ │353 +) !void { │ │354 + var json: std.Io.Writer.Allocating = .init(allocator); │ │355 + defer json.deinit(); │ │356 + │ │357 + for (events) |event| { │ │358 + json.clearRetainingCapacity(); │ │359 + try std.json.Stringify.value(event, .{}, &json.writer); │ │360 + _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, ref); │ │361 } │ │362 + │ │363 + try consume(repo_opts, io, allocator, repo, ref); │ │364 } │ │365 │ │366 // │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/event/User.zig│ ╰──────────────────╯ ╭───────────────────────────────────────────╮ │1 const std = @import("std"); │ │2 const evt = @import("../event.zig"); │ │3 const xit = @import("xit"); │ │4 +const rp = xit.repo; │ │5 const hash = xit.hash; │ │6 const bcrypt = std.crypto.pwhash.bcrypt;│ │7 │ ╰───────────────────────────────────────────╯ ╭──────────────────────────────╮ │ 9 display_name: []const u8, │ │10 email: []const u8, │ │11 password_hash: []const u8,│ │12 +enable_ansi: bool = false,│ │13 │ │14 pub fn consume( │ │15 comptime DB: type, │ ╰──────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │109 }; │ │110 │ │111 return .{ .success = user_id }; │ │112 +} │ │113 + │ │114 +// read a user by event id via the event-id->user index, or null if the id │ │115 +// isn't a known user. field byte slices are allocated in `arena`. │ │116 +pub fn readById( │ │117 + comptime DB: type, │ │118 + comptime hash_kind: hash.HashKind, │ │119 + haxy_moment: DB.HashMap(.read_only), │ │120 + arena: *std.heap.ArenaAllocator, │ │121 + user_id: []const u8, │ │122 +) !?@This() { │ │123 + const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->user")) orelse return null;│ │124 + const event_id_to_user = try DB.HashMap(.read_only).init(event_id_to_user_cursor); │ │125 + const user_cursor = try event_id_to_user.getCursor(hash.hashInt(hash_kind, user_id)) orelse return null; │ │126 + const user_map = try DB.HashMap(.read_only).init(user_cursor); │ │127 + return try evt.read(@This(), DB, hash_kind, arena, user_map); │ │128 +} │ │129 + │ │130 +// flip a user's ANSI-art preference by re-emitting their User event with │ │131 +// enable_ansi negated. a no-op for an unknown user. `repo` must be writable. │ │132 +pub fn toggleAnsi( │ │133 + comptime repo_opts: rp.RepoOpts(.xit), │ │134 + io: std.Io, │ │135 + allocator: std.mem.Allocator, │ │136 + repo: *rp.Repo(.xit, repo_opts), │ │137 + user_id: []const u8, │ │138 +) !void { │ │139 + const DB = rp.Repo(.xit, repo_opts).DB; │ │140 + var arena = std.heap.ArenaAllocator.init(allocator); │ │141 + defer arena.deinit(); │ │142 + │ │143 + const moment = try evt.currentMoment(repo_opts, repo); │ │144 + const user = (try readById(DB, repo_opts.hash, moment, &arena, user_id)) orelse return; │ │145 + │ │146 + var updated = user; │ │147 + updated.enable_ansi = !user.enable_ansi; │ │148 + try evt.commitAndConsume(repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{ │ │149 + .id = std.fmt.bytesToHex(user_id[0..evt.event_id_size].*, .lower), │ │150 + .event = .{ .user = updated }, │ │151 + }}); │ │152 } │ │153 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │41 │ │42 fn tick(min_height: u32, max_width: u32) !void { │ │43 const root_ptr = if (root) |*root_value| root_value else return error.NotStarted;│ │44 + │ │45 + // apply actions queued during input. the wasm path has no repo, so this is │ │46 + // in-memory only; logged-in web persistence goes through the /ansi POST. │ │47 + session.applyPending(); │ │48 + │ │49 try root_ptr.build(allocator, .{ │ │50 // min_height lets the TUI fill the viewport when its content is │ │51 // short; max height stays null so taller content extends downward │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │80 defer allocator.free(overlay); │ │81 setOverlay(overlay); │ │82 │ │78 - // if focused widget is a TextInput, focus on the HTML element │ │83 + // browser-focus the focused overlay control (text input or submit button) │ │84 + // so the browser handles typing and Enter-to-submit natively. without this│ │85 + // the submit button never gets focus and Enter falls through to the wasm. │ │86 const root_focus = root_ptr.getFocus(); │ │87 if (root_focus.grandchild_id) |gid| { │ │88 if (root_focus.children.get(gid)) |child| { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯