diff --git a/editor/src/lib.rs b/editor/src/lib.rs index b200153..b12c835 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -1855,12 +1855,19 @@ impl Editor { self.palette_sel = 0; } // Ctrl-n/Ctrl-p move the selection (fzf-style); Ctrl-d/Ctrl-u do too. - // Clamped to the current result list (files, `>` commands, `$` snippets). + // Wraps around the current result list (files, `>` commands, `$` snippets). Key::Down | Key::HalfPageDown => { let n = self.palette_len(); - self.palette_sel = (self.palette_sel + 1).min(n.saturating_sub(1)); + if n > 0 { + self.palette_sel = (self.palette_sel + 1) % n; + } + } + Key::Up | Key::HalfPageUp => { + let n = self.palette_len(); + if n > 0 { + self.palette_sel = self.palette_sel.checked_sub(1).unwrap_or(n - 1); + } } - Key::Up | Key::HalfPageUp => self.palette_sel = self.palette_sel.saturating_sub(1), // Enter acts on the selection by mode: insert a `$` snippet, run a `>` // command, or open the selected file. Key::Enter => { @@ -5292,7 +5299,7 @@ mod tests { } #[test] - fn half_page_keys_move_the_selection_clamped() { + fn half_page_keys_move_the_selection_wrapping() { let mut e = palette_editor(&["/sd/repo/a.md", "/sd/repo/b.md", "/sd/repo/c.md"]); e.handle(Key::Palette); for ch in "md".chars() { @@ -5302,10 +5309,10 @@ mod tests { e.handle(Key::HalfPageDown); assert_eq!(e.palette_sel, 1); e.handle(Key::HalfPageDown); - e.handle(Key::HalfPageDown); // clamps at the last row + e.handle(Key::HalfPageDown); // wraps past the last row to the top + assert_eq!(e.palette_sel, 0); + e.handle(Key::HalfPageUp); // wraps back to the last row assert_eq!(e.palette_sel, 2); - e.handle(Key::HalfPageUp); - assert_eq!(e.palette_sel, 1); } #[test] @@ -5318,10 +5325,10 @@ mod tests { e.handle(Key::Down); // Ctrl-n assert_eq!(e.palette_sel, 1); e.handle(Key::Down); - e.handle(Key::Down); // clamps at the last row + e.handle(Key::Down); // wraps past the last row to the top + assert_eq!(e.palette_sel, 0); + e.handle(Key::Up); // Ctrl-p wraps back to the last row assert_eq!(e.palette_sel, 2); - e.handle(Key::Up); // Ctrl-p - assert_eq!(e.palette_sel, 1); } #[test] @@ -5703,12 +5710,16 @@ mod tests { } #[test] - fn ctrl_n_moves_the_command_selection_within_bounds() { + fn ctrl_n_moves_the_command_selection_wrapping() { let mut e = palette_type(&["/sd/repo/notes.md"], ">"); - for _ in 0..10 { - e.handle(Key::Down); // Ctrl-N; clamps at the last command + for _ in 0..PALETTE_CMDS.len() - 1 { + e.handle(Key::Down); // Ctrl-N down to the last command } assert_eq!(e.palette_sel, PALETTE_CMDS.len() - 1); + e.handle(Key::Down); // wraps back to the top + assert_eq!(e.palette_sel, 0); + e.handle(Key::Up); // and Ctrl-P wraps back to the bottom + assert_eq!(e.palette_sel, PALETTE_CMDS.len() - 1); } #[test] @@ -6069,12 +6080,14 @@ mod tests { } #[test] - fn ctrl_n_clamps_to_the_snippet_result_list() { + fn ctrl_n_wraps_around_the_snippet_result_list() { let mut e = snippet_palette(TWO_SNIPPETS, "$"); e.handle(Key::Down); - e.handle(Key::Down); - e.handle(Key::Down); // past the end — clamps assert_eq!(e.palette_sel, 1); // two snippets → last index is 1 + e.handle(Key::Down); // past the end — wraps to the top + assert_eq!(e.palette_sel, 0); + e.handle(Key::Up); // before the top — wraps to the bottom + assert_eq!(e.palette_sel, 1); } #[test] diff --git a/firmware/justfile b/firmware/justfile index 0dfcd4e..1dbb7a5 100644 --- a/firmware/justfile +++ b/firmware/justfile @@ -52,6 +52,12 @@ build-light: flash-light: {{esp_env}} cargo run --release --bin firmware +# flash + monitor the ALREADY-BUILT firmware — no cargo, no rebuild. Flashes +# whatever `build`/`build-light` last produced (both write the same ELF). +# Same espflash flags as the cargo runner in .cargo/config.toml. +flash-only: + espflash flash --monitor --baud 921600 {{elf}} + # serial monitor only, with decoded backtraces monitor: espflash monitor --elf {{elf}} @@ -202,11 +208,27 @@ init repo_src sd_volume="": # (Re)copy just the notes repo to /sd/repo: fast-forward the source clone from # its remote first, then rsync it across (full clone, gitignored paths excluded) # and eject — e.g. to refresh the card after the device pushed new notes. -load repo_src sd_volume="": +# Refuses if the card's .typoena-dirty journal lists unpublished device edits +# (:sync from the device first, or TW_DISCARD_UNPUBLISHED=1 to back up+discard). +# --force wipes the card's repo/ + dirty journal first for a from-scratch copy +# (recovers a corrupt or half-written card clone; journaled files still get the +# rescue backup). Scoped to repo/: typoena.conf and /sd/local notes survive. +# just load ~/code/notes --force +# just load ~/code/notes SDCARD --force +[positional-arguments] +load repo_src *rest: #!/usr/bin/env bash set -euo pipefail - vol="$(just _card "{{sd_volume}}" | tail -n1)" - just _load-repo "{{repo_src}}" "$vol" + repo_src="$1"; shift + force=""; volname="" + for a in "$@"; do + case "$a" in + --force) force=1 ;; + *) volname="$a" ;; + esac + done + vol="$(just _card "$volname" | tail -n1)" + just _load-repo "$repo_src" "$vol" "$force" just _eject "$vol" # (Re)write just the config (Wi-Fi + PAT + git identity), then eject — e.g. to @@ -274,12 +296,48 @@ _card sd_volume="": # the URL scheme: the card copy's origin is rewritten to HTTPS at the end, # because the device's libgit2 has no SSH transport (HTTPS+PAT over mbedTLS # only). The source clone is never touched. -_load-repo repo_src vol: +_load-repo repo_src vol wipe="": #!/usr/bin/env bash set -euo pipefail src="{{repo_src}}"; src="${src%/}" # strip trailing slash + wipe="{{wipe}}" [ -d "$src/.git" ] || { echo "error: '$src' is not a git repo (no .git/)"; exit 1; } + # ── unpublished-work guard ─────────────────────────────────────────────── + # /sd/.typoena-dirty (card root — outside the repo/ rsync scope) lists the + # repo-relative paths the device saved or `:delete`d but never confirmed + # published; the device clears it only on a confirmed push, so it also + # covers any stranded splice commit in repo/.git. Mirroring repo/ over + # those edits while the journal survives is worse than losing them: the + # device's next :sync splices the journaled paths from the mirrored tree, + # committing Mac-state — or a *deletion* for a path the Mac clone lacks. + # So refuse by default; TW_DISCARD_UNPUBLISHED=1 backs the files up to a + # temp dir, clears the journal, then mirrors. Either way a completed load + # leaves no stale journal behind. + journal="{{vol}}/.typoena-dirty" + if [ -s "$journal" ]; then + echo "card carries unpublished device edits (.typoena-dirty):" >&2 + sed 's/^/ /' "$journal" >&2 + if [ -z "${TW_DISCARD_UNPUBLISHED:-}" ] && [ -z "$wipe" ]; then + echo "error: loading would overwrite these with the Mac clone and strand the journal." >&2 + echo " Put the card back in Typoena and :sync first (the normal path), or re-run" >&2 + echo " with TW_DISCARD_UNPUBLISHED=1 to back them up to a temp dir and discard." >&2 + exit 1 + fi + rescue="$(mktemp -d)/typoena-rescue" + while IFS= read -r p; do + p="$(echo "$p" | xargs)"; [ -n "$p" ] || continue + if [ -f "{{vol}}/{{sd_repo_dir}}/$p" ]; then + mkdir -p "$rescue/$(dirname "$p")" + cp "{{vol}}/{{sd_repo_dir}}/$p" "$rescue/$p" + else + echo " (no file on card for '$p' — was a device :delete, nothing to back up)" >&2 + fi + done < "$journal" + rm -f "$journal" + echo "journal cleared; unpublished files backed up to $rescue" >&2 + fi + origin="$(git -C "$src" remote get-url origin 2>/dev/null || true)" case "$origin" in https://*|git@*|ssh://*|git://*) echo "source origin: $origin" ;; @@ -315,6 +373,18 @@ _load-repo repo_src vol: esac dest="{{vol}}/{{sd_repo_dir}}" + # --force: delete the card's repo/ outright so rsync copies from scratch — + # unlike the mirror, this also clears paths the exclude list protects from + # `--delete` (a stray .env, build junk) and any corrupt .git state. Scoped + # to repo/ + the journal (already cleared above if present): typoena.conf, + # ca.pem and /sd/local never touched. Sanity-check vol first — with an + # empty vol this rm would aim at "/repo". + if [ -n "$wipe" ]; then + [ -n "{{vol}}" ] && [ -d "{{vol}}" ] || { echo "error: bad volume '{{vol}}'" >&2; exit 1; } + echo "--force: wiping $dest for a from-scratch copy" + rm -rf "$dest" + rm -f "$journal" + fi echo "copying repo: $src -> $dest" mkdir -p "$dest" @@ -531,10 +601,16 @@ _write-conf vol repo_src="": echo " git: remote=$(mask "$remote") gh_user=$(mask "$gh_user") pat=$(mask "$pat")" echo " author: name=$(mask "$a_name") email=$(mask "$a_email")" -# Flush + eject so the card can go straight into Typoena. +# Flush + eject so the card can go straight into Typoena. Also strips the +# AppleDouble `._*` companions macOS writes on FAT for any file it decorates +# with xattrs: on a real card they reached .git/objects/pack/._pack-*.idx, +# which git's pack scan (Mac git AND the device's libgit2) picks up via its +# *.idx glob and tries to parse ("non-monotonic index" spam; 2045 of them +# found 2026-07-13). Best-effort — a missing dot_clean never blocks the eject. _eject vol: #!/usr/bin/env bash set -euo pipefail + dot_clean -m "{{vol}}" 2>/dev/null || true sync echo "ejecting {{vol}}" if diskutil eject "{{vol}}" >/dev/null 2>&1; then diff --git a/firmware/src/git_sync.rs b/firmware/src/git_sync.rs index 8c06ad2..a841619 100644 --- a/firmware/src/git_sync.rs +++ b/firmware/src/git_sync.rs @@ -510,9 +510,20 @@ fn try_push(repo: &Repository, refspec: &str) -> Result<(), PushFailure> { let mut opts = PushOptions::new(); opts.remote_callbacks(cbs); - remote - .push(&[refspec], Some(&mut opts)) - .map_err(|e| PushFailure::Other(anyhow::Error::new(e).context("push transport")))?; + // A non-fast-forward can also surface here, not just via the callback: + // libgit2 compares against origin's advertised tips during negotiation and + // errors out of push() with ErrorCode::NotFastForward before sending + // anything, so `push_update_reference` never fires. It's still the + // remote-moved-under-us case — reconcilable, not a transport failure + // (bit the 2026-07-13 run 3: the real-repo rejection surfaced as "push + // transport" and skipped the reconcile built for it). + remote.push(&[refspec], Some(&mut opts)).map_err(|e| { + if e.code() == git2::ErrorCode::NotFastForward { + PushFailure::Rejected(format!("{refspec}: {}", e.message())) + } else { + PushFailure::Other(anyhow::Error::new(e).context("push transport")) + } + })?; if let Some(msg) = rejection.borrow().clone() { return Err(PushFailure::Rejected(msg)); diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 3ca10c8..c1d7f4b 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -596,11 +596,20 @@ fn walk_files(dir: &std::path::Path, depth: usize, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; - // Keep the dirent's own file type: esp-idf's FAT VFS always fills d_type - // (DT_DIR/DT_REG, straight from the FILINFO readdir already holds), so - // `file_type()` is free. A per-entry `metadata()` stat instead re-walks - // the directory by path every time — measured at ~32ms/file on the SD - // card, it turned a 1098-file walk into 35s. + // Keep the dirent's own file type — a per-entry `metadata()` stat re-walks + // the directory by path every time (~32ms/file on the SD card; it turned a + // 1098-file walk into 35s). But the type needs decoding: esp-idf's + // dirent.h says DT_REG=1 / DT_DIR=2, and std was built against libc + // 0.2.178, which had no espidf overrides (they arrived in 0.2.186) and + // falls back to the generic unix table — DT_FIFO=1, DT_CHR=2, DT_DIR=4, + // DT_REG=8. Through std's eyes every card file is a "fifo" and every + // directory a "char device": is_file()/is_dir() never matched, and the + // 2026-07-13 walk dropped all 1157 files in 49ms. FAT can't hold fifos or + // device nodes, so reading fifo-as-file / chardev-as-dir is unambiguous + // here, and the is_file()/is_dir() arms take over the day the toolchain's + // libc catches up. A type matching neither pair pays the one stat rather + // than being silently dropped. + use std::os::unix::fs::FileTypeExt; let children: Vec<_> = entries .flatten() .filter_map(|e| e.file_type().ok().map(|t| (e.path(), t))) @@ -612,11 +621,21 @@ fn walk_files(dir: &std::path::Path, depth: usize, out: &mut Vec) { if name.starts_with('.') { continue; } - if ftype.is_file() { + let (is_file, is_dir) = if ftype.is_file() || ftype.is_fifo() { + (true, false) + } else if ftype.is_dir() || ftype.is_char_device() { + (false, true) + } else { + match std::fs::metadata(&path) { + Ok(m) => (m.is_file(), m.is_dir()), + Err(_) => continue, + } + }; + if is_file { if let Some(p) = path.to_str() { out.push(p.to_string()); } - } else if ftype.is_dir() { + } else if is_dir { walk_files(&path, depth + 1, out); } }