fix(haskell): keep an edit across list refreshes; listen only on 127.0.0.1

app.js now carries an edit in progress (value, selection, focus) into
the fresh list a response brings, instead of dropping it. --listen
accepts exactly 127.0.0.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a4df260afc75604ca9836b372d5e1124495832f6 parent 529b0e8
2 files changed, +44 −14
  • apps/haskell/app/Main.hs +4 −11
  • apps/haskell/static/app.js +40 −3
diff --git a/apps/haskell/app/Main.hs b/apps/haskell/app/Main.hs
index 44fedc2..d2dd394 100644
--- a/apps/haskell/app/Main.hs
+++ b/apps/haskell/app/Main.hs
@@ -94,21 +94,14 @@ parseArgs args o = case args of
"--db" : path : rest -> parseArgs rest o {optDb = Just path}
flag : _ -> Left ("unexpected argument " <> show flag)
--- | Only an IPv4 loopback address: this app never listens anywhere else.
+-- | Only 127.0.0.1: this app never listens anywhere else.
parseListen :: String -> Either String Bind
parseListen s = case break (== ':') s of
- (host, ':' : portText)
- | Just [a, b, c, d] <- traverse readMaybe (splitOn '.' host)
- , Just port <- readMaybe portText
- , a == 127
- , all (<= 255) [b, c, d]
+ ("127.0.0.1", ':' : portText)
+ | Just port <- readMaybe portText
, port > 0 && port < (65536 :: Int) ->
- Right (Loopback (fromIntegral a, fromIntegral b, fromIntegral c, fromIntegral (d :: Int)) (fromIntegral port))
+ Right (Loopback (127, 0, 0, 1) (fromIntegral port))
_ -> Left ("--listen wants 127.0.0.1:<port>, not " <> show s)
- where
- splitOn sep str = case break (== sep) str of
- (part, []) -> [part]
- (part, _ : rest) -> part : splitOn sep rest
showAddr :: (Word8, Word8, Word8, Word8) -> PortNumber -> String
showAddr (a, b, c, d) port = concat [show a, ".", show b, ".", show c, ".", show d, ":", show port]
diff --git a/apps/haskell/static/app.js b/apps/haskell/static/app.js
index f4519ff..d92cf6e 100644
--- a/apps/haskell/static/app.js
+++ b/apps/haskell/static/app.js
@@ -11,6 +11,9 @@ document.documentElement.classList.add('js');
const app = document.querySelector('.todoapp');
let queue = Promise.resolve();
+// True while a response replaces the list, so the edit input it removes
+// isn't taken for one the user left.
+let swapping = false;
function send(action, body) {
queue = queue
@@ -19,15 +22,49 @@ function send(action, body) {
if (!res.ok) throw new Error(`${action}: ${res.status}`);
const next = document.createElement('template');
next.innerHTML = await res.text();
- for (const part of ['.main', '.footer']) {
- app.querySelector(part).replaceWith(next.content.querySelector(part));
+ const edit = pendingEdit();
+ swapping = true;
+ try {
+ for (const part of ['.main', '.footer']) {
+ app.querySelector(part).replaceWith(next.content.querySelector(part));
+ }
+ } finally {
+ swapping = false;
}
+ if (edit) resumeEdit(edit);
})
// The page is the source of truth: if anything went wrong, show it.
.catch(() => location.reload());
return queue;
}
+// An edit the user is in the middle of, captured before a response
+// replaces the list, and put back into the fresh copy of that item.
+function pendingEdit() {
+ const li = app.querySelector('.todo-list li.editing');
+ if (!li) return null;
+ const input = li.querySelector('.edit');
+ return {
+ id: li.dataset.id,
+ value: input.value,
+ start: input.selectionStart,
+ end: input.selectionEnd,
+ focused: document.activeElement === input,
+ };
+}
+
+function resumeEdit(edit) {
+ const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(edit.id)}"]`);
+ if (!li) return;
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.value = edit.value;
+ if (edit.focused) {
+ input.focus();
+ input.setSelectionRange(edit.start, edit.end);
+ }
+}
+
const fields = (form) => new URLSearchParams(new FormData(form));
const filter = () => app.querySelector('input[name="filter"]').value;
@@ -68,7 +105,7 @@ app.addEventListener('keydown', (e) => {
});
app.addEventListener('focusout', (e) => {
- if (e.target.matches('.edit')) finishEditing(e.target.closest('li'), true);
+ if (!swapping && e.target.matches('.edit')) finishEditing(e.target.closest('li'), true);
});
// Ends an edit once: Enter submits and then blurs, and Escape blurs too.