feat: wrap palette selection; fix push classing, file walk, card load

- Palette Ctrl-N/P (and half-page keys) wrap around the result list
  instead of clamping at the ends; tests updated to the new semantics.
- try_push maps libgit2's negotiation-time NotFastForward error to
  PushFailure::Rejected so the reconcile path runs instead of failing
  as a transport error.
- walk_files decodes esp-idf d_type values (DT_REG=1/DT_DIR=2) that
  libc 0.2.178's generic unix table misreads as fifo/chardev — the
  palette walk was silently dropping every file on the card.
- `just load` refuses to mirror over unpublished device edits listed
  in the card's .typoena-dirty journal (backup+discard via
  TW_DISCARD_UNPUBLISHED=1), gains --force for a from-scratch copy,
  and a flash-only recipe flashes the already-built ELF.
This commit is contained in:
Julien Calixte
2026-07-13 20:15:14 +02:00
parent 5f50cf1b45
commit 05706478a2
4 changed files with 150 additions and 31 deletions

View File

@@ -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]

View File

@@ -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

View File

@@ -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));

View File

@@ -596,11 +596,20 @@ fn walk_files(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) {
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<String>) {
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);
}
}