fix(zig): keep an open editor across responses; trim Unicode whitespace

app.js no longer replaces an item that is being edited when an earlier
request's answer arrives; it refreshes the list around it. Titles are
trimmed of Unicode White_Space (and U+FEFF), so whitespace-only adds are
ignored and whitespace-only edits delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 34e7b199e3937a30ec15e3c10bdeba2a6fe2d328 parent 9ca84f9
2 files changed, +77 −6
  • apps/zig/src/app.zig +39 −2
  • apps/zig/static/app.js +38 −4
diff --git a/apps/zig/src/app.zig b/apps/zig/src/app.zig
index 864b88c..579194f 100644
--- a/apps/zig/src/app.zig
+++ b/apps/zig/src/app.zig
@@ -169,7 +169,7 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8,
const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" };
if (std.mem.eql(u8, path, "/todos")) {
- const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace);
+ const title = trimTitle(form.title orelse "");
const list = try visitor.list();
if (title.len > 0) try store.add(list, title);
} else if (std.mem.eql(u8, path, "/todos/toggle-all")) {
@@ -182,7 +182,7 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8,
.toggle => try store.toggle(list, action.id),
.destroy => try store.destroy(list, action.id),
.edit => edit: {
- const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace);
+ const title = trimTitle(form.title orelse "");
break :edit if (title.len == 0)
try store.destroy(list, action.id)
else
@@ -200,6 +200,35 @@ fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8,
return .{ .status = .see_other, .location = filter.path() };
}
+/// Trims Unicode whitespace (the White_Space property, plus U+FEFF as
+/// JavaScript's `trim` does) from both ends of a UTF-8 title.
+fn trimTitle(s: []const u8) []const u8 {
+ var start: usize = 0;
+ while (start < s.len) {
+ const len = std.unicode.utf8ByteSequenceLength(s[start]) catch break;
+ if (start + len > s.len) break;
+ const cp = std.unicode.utf8Decode(s[start..][0..len]) catch break;
+ if (!isSpace(cp)) break;
+ start += len;
+ }
+ var end = s.len;
+ while (end > start) {
+ var i = end - 1;
+ while (i > start and s[i] & 0xC0 == 0x80) i -= 1;
+ const cp = std.unicode.utf8Decode(s[i..end]) catch break;
+ if (!isSpace(cp)) break;
+ end = i;
+ }
+ return s[start..end];
+}
+
+fn isSpace(cp: u21) bool {
+ return switch (cp) {
+ 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF => true,
+ else => false,
+ };
+}
+
const ItemAction = struct {
id: i64,
verb: enum { toggle, edit, destroy },
@@ -305,6 +334,14 @@ fn originAllowed(app: *const App, h: *const Headers) bool {
return std.mem.eql(u8, after_scheme, host);
}
+test trimTitle {
+ try std.testing.expectEqualStrings("", trimTitle("\u{a0}\u{2003} \t\u{3000}"));
+ try std.testing.expectEqualStrings("café ✓", trimTitle("\u{feff} café ✓\u{a0}\n"));
+ try std.testing.expectEqualStrings("a\u{a0}b", trimTitle("a\u{a0}b"));
+ try std.testing.expectEqualStrings("日本語", trimTitle("日本語"));
+ try std.testing.expectEqualStrings("\xff x", trimTitle("\xff x"));
+}
+
test itemAction {
try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?);
try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?);
diff --git a/apps/zig/static/app.js b/apps/zig/static/app.js
index 01559c9..e3c6782 100644
--- a/apps/zig/static/app.js
+++ b/apps/zig/static/app.js
@@ -20,16 +20,50 @@ function send(form) {
body,
});
if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
- const fresh = new DOMParser().parseFromString(await res.text(), 'text/html');
- for (const part of ['.main', '.footer']) {
- app.querySelector(part).replaceWith(fresh.querySelector(part));
- }
+ apply(new DOMParser().parseFromString(await res.text(), 'text/html'));
}).catch((err) => {
console.error(err);
location.reload();
});
}
+// Swaps in the server's fresh list and footer. An editor that's open when
+// the answer arrives stays open with its unsaved text: its item is kept in
+// place (never detached, so it keeps focus) and only refreshed around it.
+function apply(fresh) {
+ app.querySelector('.footer').replaceWith(fresh.querySelector('.footer'));
+ const editing = app.querySelector('.todo-list li.editing');
+ const update = editing && fresh.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
+ if (!update) {
+ app.querySelector('.main').replaceWith(fresh.querySelector('.main'));
+ return;
+ }
+
+ const main = app.querySelector('.main');
+ const freshMain = fresh.querySelector('.main');
+ main.hidden = freshMain.hidden;
+ main.querySelector('form').replaceWith(freshMain.querySelector('form'));
+
+ for (const item of [...main.querySelectorAll('.todo-list > li')]) {
+ if (item !== editing) item.remove();
+ }
+ let before = true;
+ for (const item of [...freshMain.querySelectorAll('.todo-list > li')]) {
+ if (item === update) {
+ before = false;
+ } else if (before) {
+ editing.before(item);
+ } else {
+ editing.parentElement.append(item);
+ }
+ }
+
+ // Bring the kept item's view up to date, leaving the editor's text alone.
+ editing.className = `${update.className} editing`;
+ editing.querySelector('.view').replaceWith(update.querySelector('.view'));
+ editing.querySelector('.edit').defaultValue = update.querySelector('.edit').defaultValue;
+}
+
app.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.target;