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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user