aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design/vamp-music-player.org340
-rw-r--r--modules/coverage-core.el33
-rw-r--r--modules/dirvish-config.el99
-rw-r--r--modules/jumper.el45
-rw-r--r--modules/system-utils.el3
-rw-r--r--tests/test-coverage-core--relativize-keys.el123
-rw-r--r--tests/test-dirvish-config-popup.el248
-rw-r--r--tests/test-jumper--register-hygiene.el179
-rw-r--r--todo.org109
9 files changed, 1142 insertions, 37 deletions
diff --git a/docs/design/vamp-music-player.org b/docs/design/vamp-music-player.org
new file mode 100644
index 000000000..12b92443b
--- /dev/null
+++ b/docs/design/vamp-music-player.org
@@ -0,0 +1,340 @@
+#+TITLE: Design: VAMP — a standalone Emacs music player
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-22
+#+OPTIONS: toc:nil num:nil
+
+Status: Draft
+
+VAMP = "VAMP Audio Music Player" (recursive backronym; /vamp/ is itself a
+musical term — a short repeated passage). Namespace =vamp-=, repo =~/code/vamp=.
+
+This design came out of a 2026-06-22 brainstorm. It supersedes parts of the
+earlier EMMS-removal work and confirms others — see "Relationship to Prior
+Work" below.
+
+* Problem
+
+=modules/music-config.el= (925 lines) is an EMMS configuration layer welded
+into =.emacs.d=: it mixes genuinely reusable logic (M3U management, fuzzy add,
+random-history navigation, radio-station creation, consume mode) with personal
+config (ncmpcpp-aligned keybindings, paths, dashboard wiring), and it depends
+on the EMMS package for its playlist model, player backend, and track info.
+The goal is a standalone, publishable Emacs music player — derived from a
+maintained subset of EMMS, depending on the EMMS package not at all — that
+Craig uses as his primary player, launchable from Hyprland like dirvish.
+
+* Relationship to Prior Work
+
+A spec and a detailed review already exist and remain partly authoritative:
+
+- =docs/specs/music-config-without-emms-spec.org= — the EMMS-removal spec.
+- =docs/design/music-config-without-emms-review.org= — third-pass review
+ (2026-05-15) with a go/no-go and a 14-item decision punch list.
+
+This brainstorm *confirms* four of that review's decisions, independently
+re-derived: long-running MPV + JSON IPC from day one (B1); a state-change hook
+contract firing STARTED/STOPPED/PAUSED/RESUMED/FINISHED (B2); a fake-backend
+testutil with an events ledger (B4); metadata via MPV IPC on the STARTED event
+(S3).
+
+It *pivots* the direction in four ways the prior work assumed otherwise:
+
+- Publishable from the start (old spec: personal-first, public "later").
+- Two adapters — MPV and mpd — behind a generalized adapter API (old: MPV-only,
+ a single backend protocol). This is the largest change; it turns their single
+ "backend protocol" into a real multi-backend seam.
+- Name VAMP (old candidate: =cadenza=).
+- Desktop integration as a first-class concern: a Hyprland Super+/ launcher, a
+ daemon-singleton instance model, q-closes-frame-while-playback-continues, and
+ an m3u MIME association — none of which the prior work addressed.
+
+The prior review's cross-platform decision is absorbed unchanged: Linux + macOS
+ship full-feature; Windows is best-effort (play/stop/next/previous only) per
+Craig's 2026-05-15 call.
+
+Next step (tracked below): revise the spec to this direction before
+=/start-work=.
+
+* Non-Goals
+
+- No music-library database, tag index, or browser UI (light metadata only).
+- No mid-track position resume on backend switch (v1 re-cues from track start).
+- No persisted session state across daemon restarts (M3U save/load is the only
+ way a playlist comes back).
+- The package does not install OS wiring — the Hyprland bind, the launcher
+ script, window rules, the =.desktop= file, and =xdg-mime= defaults all live
+ in archsetup.
+- No full tag-reading in v1 (deferred to the first post-v1 enhancement).
+
+* Assumptions
+
+Researched facts (verified this session):
+
+- EMMS is GPLv3+ (read from =emms.el=); any code derived from it makes VAMP
+ GPLv3+. Fine for MELPA, which prefers GPL.
+- The EMMS core subset VAMP would draw from is ~6–7k lines: =emms.el= (1741),
+ =emms-playlist-mode.el= (685), =emms-player-mpv.el= (772),
+ =emms-player-mpd.el= (1367), the M3U sources (~800), native tag readers
+ (~1080), playing-time (258). The ~16k excluded surface is browser, filters,
+ tag-editor, lyrics, mpris, scrobblers, musicbrainz.
+- The dirvish-popup / quick-capture launcher pattern (emacsclient named frame +
+ Hyprland window rules + q-to-close, single-instance focus-existing) is the
+ established model on Craig's machine.
+- mpd is installed and running; Craig will use it to test the second adapter.
+
+Assumptions to confirm before/early in build:
+
+- mpd driven as a "dumb" single-file player (clear queue → add one file → play
+ → idle for end-of-track) behaves cleanly. mpd is designed to own a queue;
+ the dumb-player contract must be validated against real mpd behavior.
+- The m3u XDG MIME association works on Craig's exact Hyprland/xdg setup
+ (mechanism is standard; prototype the =.desktop= early per Craig's request).
+
+* Approaches Considered
+
+** Recommended: B/A hybrid — clean core, ported adapter internals
+
+Write a small, fresh playlist/playback/navigation core with an adapter API of
+VAMP's own design (B); port only the fiddly MPV-IPC and mpd-protocol internals
+from EMMS as reference (A), since that protocol handling is the hard-won part
+not worth reinventing. The core owns the queue and all play-modes; adapters are
+thin single-file players.
+
+Pros: a small core Craig fully understands and can maintain solo; a clean
+adapter API shaped for the two-backend goal; reuses EMMS's proven IPC/protocol
+code without inheriting its whole design.
+
+Cons: GPLv3+ (from the ported adapter code); real upfront design effort on the
+core + adapter API before any feature lands; risk of missing subtle
+player-process lifecycle behavior EMMS already handles.
+
+What it trades away: the option of a non-GPL license, and a fast feature-first
+start.
+
+** Rejected: vendor-and-trim (A alone)
+
+Copy the ~8 core EMMS files, delete the rest, renamespace, keep EMMS's backend
+pattern as VAMP's own. Fastest to feature-complete, but inherits 6–7k lines of
+someone else's idioms to "maintain yourself" — works against the maintainability
+goal that motivated the project.
+
+** Rejected: thin core, delegate to backends (C)
+
+Lean hard on the backend (mpd owns its queue; MPV gets a minimal one). Least
+code, but backend asymmetry leaks into inconsistent behavior and fat adapters —
+and the brainstorm chose core-owns-queue precisely for uniform behavior and
+seamless backend switching. C survives only as an adapter-capability detail
+(let mpd do server-side work as a future optimization).
+
+** Rejected: wrap existing client libraries (D)
+
+Build on mpdel + mpv.el as a thin UX layer. Directly contradicts the
+"depend on nothing / maintain it myself" goal.
+
+** Rejected: MPRIS/D-Bus as the one universal adapter (E)
+
+Drive any MPRIS player over D-Bus. "Many players" almost free, but MPRIS is
+control-only — it can't reliably own playlists or load arbitrary files across
+players. Kept in the back pocket as a possible future adapter class, not a v1
+foundation.
+
+** Rejected: external daemon + JSON-RPC (F)
+
+Move player logic to an external process, Emacs as thin client. Ships a
+non-Elisp component — packaging burden, not a pure-Elisp MELPA package. Overkill
+for local playback.
+
+* Design
+
+** Architecture
+
+Standalone repo at =~/code/vamp=, Eask-based like pearl (Eask, Makefile,
+autoloads, =tests/=, README, LICENSE — GPLv3+). Three layers:
+
+- *Core* (backend-agnostic, owns all stateful logic): the queue model (track
+ list, current index, play-modes — shuffle, repeat-playlist, repeat-track,
+ random + history ring, consume); the playback controller (orchestrates
+ load + play on the current track, handles end-of-track, advances per mode);
+ sources (add files/dirs/recursive, URLs, M3U load/save/clear/reload/edit;
+ radio-station creation); the playlist-mode buffer + window toggle/show;
+ light metadata.
+- *Adapter layer* (the extensibility seam): a =cl-defgeneric= protocol every
+ backend implements. Ships with MPV (spawned subprocess + JSON IPC socket) and
+ mpd (daemon connection, driven as a dumb single-file player).
+- *=.emacs.d= glue* (=vamp-config.el=): keybindings (C-; m map, playlist-mode
+ keys), music-root path, dashboard wiring, customize values. No logic.
+
+Three-project split: VAMP ships the elisp + entry points; =.emacs.d= keeps
+keybindings/glue; archsetup owns the OS wiring (Super+/ bind, launcher script,
+window rules, =.desktop=, =xdg-mime=).
+
+** Adapter API
+
+A backend is a class implementing generic methods. The contract is deliberately
+narrow (transport + metadata), because the core owns the queue and modes:
+
+- =load-file= — load a track URL/path (do not advance anything)
+- =play= / =pause= / =stop=
+- =seek= — to an absolute or relative position
+- =position= — current playback position
+- =report-metadata= — title/artist/album/duration the backend knows about
+- an *end-of-track notification* — each adapter translates its native
+ "track finished" signal (MPV: the IPC =end-file= event; mpd: the idle
+ =player= subsystem) into one uniform core callback
+
+This is the review's B2 state-change contract, generalized across backends. A
+new backend is a new class + method implementations; nothing in the core
+changes.
+
+** Backend switching
+
+The payoff of core-owns-queue: the queue and current track are backend-agnostic
+state in the core, so a runtime switch is just — stop the outgoing adapter
+(kill the MPV subprocess / drop the mpd connection), set the active adapter,
+and the next play re-issues =load-file= to the new backend on the same current
+track. Nothing in the queue moves; the selected song stays selected. An
+interactive =vamp-switch-backend= command (completing-read over self-registered
+adapters) is bound under C-; m and in playlist-mode. v1 re-cues from track
+start on switch; mid-track position-resume is a post-v1 addition (the contract
+already has =seek=, so it's additive).
+
+** Data flow / control loop
+
+A user action (play / next / previous) updates queue state in the core (current
+index advanced per the active play-mode), then the core calls the active
+adapter's =load-file= + =play=. End-of-track is the one hard cross-backend
+signal: the adapter fires the uniform callback, and the core's handler consults
+the play-mode and advances — repeat-track replays, repeat-playlist wraps, random
+pushes history and picks next, consume drops the finished track, normal advances
+or stops at the end. A track is a struct (url/path + type slot + cached light
+metadata); the queue is an ordered track list + current index + mode flags + the
+random-history ring.
+
+** Presentation / faces
+
+Every stateful UI surface gets a named =defface=, so status is shown by face,
+not hardcoded color: playlist current/played/consumed lines and metadata
+columns; play state (playing/paused/stopped); each play-mode with a lit (on) and
+dimmed (off) face; the active-backend indicator (MPV vs mpd); backend
+health (e.g. mpd-disconnected as an error face). These render in a header-line
+status strip in the playlist buffer (and feed the mode-line); the mode/transport
+indicators light via their on-face and dim via their off-face.
+
+Base palette: faces ship with defaults that *inherit from standard Emacs faces*
+(=success=, =warning=, =error=, =shadow=, =highlight=, =font-lock-*=) so they
+look right under any user theme out of the box and adapt automatically. A
+separate, optional =vamp-theme.el= carries the opinionated palette. Every face
+stays individually overridable. The selected-track line uses a single reused
+overlay repositioned on each STARTED event (review B3).
+
+Testing the palette: because they're standard deffaces, theme studio (the
+=.emacs.d= tool) renders them directly — load the VAMP faces, preview the
+playlist buffer + status strip, check legibility against the modus contrast
+targets, iterate.
+
+** Desktop integration + instance model
+
+Launcher: a =vamp-popup= script (mirror of dirvish-popup) bound to Super+/ in
+Hyprland; ncmpcpp moves to Shift+Super+/. The script focuses an existing
+"vamp" frame if one is open, else spawns a floating frame running
+=(vamp-popup)=; Hyprland window rules float/size/center the "vamp"-named frame.
+
+Instance model: one player instance = the daemon's global state (queue, active
+adapter, the live MPV subprocess / mpd connection). Super+/ attaches a view
+frame to it. q closes that frame but *playback continues in the daemon* — close
+the window, music plays on, reopen to see it again. A separate command (or Q)
+fully stops and tears down the player. The launcher's focus-existing behavior
+enforces an at-most-one view frame, so there are no competing instances. The
+non-daemon case (standalone Emacs) is its own instance — an edge case, since
+Craig runs the daemon.
+
+m3u MIME association: a =.desktop= file with
+=MimeType=audio/x-mpegurl;audio/mpegurl;application/x-mpegurl;application/vnd.apple.mpegurl=
+and =Exec=music-open %f= (wrapper → emacsclient … =(vamp-open-m3u "%f")=), then
+=xdg-mime default=. Opening any =.m3u= from a file manager or =xdg-open= then
+launches/raises VAMP and loads that playlist. The package only needs the
+=vamp-open-m3u FILE= entry point; the =.desktop= + =xdg-mime= live in archsetup.
+
+** Persistence
+
+Playlists: M3U save/load/clear/reload/edit, file-based, same as today. No
+session state — each daemon start is empty (today's behavior).
+
+** Metadata
+
+v1: adapter-reported metadata for the playing track only (MPV =get_property
+metadata= on STARTED; mpd reports tags from its DB). The playlist shows
+filename/path-derived labels (today's =track-description= behavior); the current
+track shows the real title/duration the backend reports. Post-v1: vendor
+=emms-info-native= (~1080 lines; mp3/ogg/flac) for real artist/album tags across
+the whole playlist, which is what unlocks sort-by-tag.
+
+** Error handling
+
+Failures surface via =user-error= / =message=, never silently — the
+music-config history (the silent Slack-notify and lock-screen bugs) is the
+cautionary tale. A missing/dead backend (mpd not running, mpv binary absent)
+reports clearly and is reflected in the header-line health face.
+
+** Testing
+
+The adapter is the system boundary (subprocess / IPC / network), so that is the
+only thing mocked — never the core. A *test adapter* (null backend, review B4's
+=testutil-music-backend.el=) implements the protocol, records
+=load-file=/=play=/=stop= calls in an events ledger, and lets a test fire the
+end-of-track callback on demand. With it, the entire control loop and every
+play-mode is testable as pure logic — no MPV, no mpd, no audio. This is
+dependency-injection rather than primitive-mocking, which also sidesteps the
+native-comp subr-mock trap the suite recently fought (see
+=docs/native-comp-subr-mocking.org=): a fake adapter is injected, not a subr
+=cl-letf='d. Core-logic tests (queue, navigation, M3U parse/write, fuzzy add,
+source expansion) are largely the existing ~193 music-config tests, ported with
+renamed symbols. Per-adapter tests mock the IPC socket / protocol connection and
+assert the native-event → uniform-callback translation. One or two integration
+tests drive the real core through the test adapter.
+
+** Observability
+
+A debug log buffer captures raw adapter I/O (the IPC/protocol traffic) for
+diagnosing backend issues (EMMS has this for mpv; worth keeping). State changes
+surface in the header-line + mode-line. A =vamp-doctor= command reports backend
+availability and, on Windows, the degraded-mode limitation.
+
+** Cross-platform stance
+
+Linux + macOS ship full-feature (IPC over unix domain sockets). Windows is
+best-effort — play/stop/next/previous only, no pause/seek/volume — via
+=start-process= + stdin or one-shot =call-process=, because Emacs's
+=make-network-process= doesn't natively support Windows named pipes. Documented
+in the README and surfaced by =vamp-doctor=. (Craig, 2026-05-15.)
+
+* Open Questions
+
+- [ ] mpd dumb-single-file-player contract — validate that clear-queue → add →
+ play → idle-for-end behaves cleanly against real mpd; decide the exact command
+ sequence. Candidate for an early spike.
+- [ ] Exact mpd end-of-track signal handling (idle =player= vs polling) and how
+ it maps to the uniform callback without races.
+- [ ] =.desktop= + =xdg-mime= prototype on Craig's Hyprland setup — confirm m3u
+ opens VAMP early (Craig asked to de-risk this first).
+- [ ] Floating-frame geometry / Hyprland window rules for the "vamp" frame
+ (archsetup detail).
+- [ ] v1 parity catalog — confirm the 13 EMMS features from the review's S1 all
+ carry into the playlist-mode keymap (seek, volume, one-shot shuffle, info,
+ center, kill-track, bury, append-to-M3U, active-window tint, dired/dirvish
+ add).
+
+* Next Steps
+
+- *Reconcile the spec.* Revise =docs/specs/music-config-without-emms-spec.org=
+ to this direction — publishable-now, two adapters + generalized adapter API,
+ VAMP name, desktop integration + instance model — keeping the review's
+ confirmed B1/B2/B4/S3 decisions and the 14-item punch list still relevant.
+- *Spike the risky assumptions* (the two mpd open questions, the m3u =.desktop=)
+ before committing the adapter API shape.
+- Open questions that are genuine decisions → =arch-decide= as ADRs.
+- Implementation → =/start-work= against the revised spec; pure-helper
+ extraction (review's Migration Plan step 1) is the safe first phase and can
+ start independently.
+- Link this doc from the =todo.org= task "Extract music-config into a standalone
+ plugin."
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index 687a042fe..9b102bb7b 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -249,6 +249,27 @@ Signals `user-error' for any other SCOPE."
(maphash (lambda (k _v) (push k keys)) table)
(sort keys #'<)))
+(defun cj/--coverage-relativize-keys (table root)
+ "Return a copy of TABLE with each file-path key made relative to ROOT.
+An absolute key is relativized against ROOT via `file-relative-name'; an
+already-relative key is kept as-is. Line-set values are shared, not copied.
+
+`cj/--coverage-parse-simplecov' emits absolute path keys (simplecov reports
+absolute source paths) while `cj/--coverage-parse-diff-output' emits
+repo-relative keys (git's \"+++ b/<path>\"). Both must be normalized to
+repo-relative before `cj/--coverage-intersect' joins them by key, or every
+diff-aware match misses and each changed file reads `:tracked nil'."
+ (let ((result (make-hash-table :test 'equal)))
+ (when table
+ (maphash
+ (lambda (path lines)
+ (let ((key (if (file-name-absolute-p path)
+ (file-relative-name path root)
+ path)))
+ (puthash key lines result)))
+ table))
+ result))
+
(defun cj/--coverage-intersect (covered changed)
"Combine COVERED (LCOV) with CHANGED (git diff) into per-file records.
COVERED and CHANGED are each hash tables from file path to a hash table
@@ -479,10 +500,14 @@ line in the simplecov data — the intersect then classifies each line
as covered or uncovered. For diff-aware scopes, the changed set
comes from `git diff' via `cj/--coverage-changed-lines'."
(let* ((report-path (funcall (plist-get backend :report-path)))
- (covered (cj/--coverage-parse-simplecov report-path))
- (changed (if (eq scope 'whole-project)
- (cj/--coverage-simplecov-executable-lines report-path)
- (cj/--coverage-changed-lines scope)))
+ (root (cj/--coverage-project-root))
+ (covered (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-simplecov report-path) root))
+ (changed (cj/--coverage-relativize-keys
+ (if (eq scope 'whole-project)
+ (cj/--coverage-simplecov-executable-lines report-path)
+ (cj/--coverage-changed-lines scope))
+ root))
(records (cj/--coverage-intersect covered changed)))
(cj/--coverage-render-to-buffer records scope)))
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index c86f3d1bf..04f9ce20e 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -411,6 +411,101 @@ Uses feh on X11, swww on Wayland."
(message "Wallpaper set: %s (%s)"
(file-name-nondirectory file) (car cmd))))))
+;;; ------------------------- Dirvish Hyprland Popup ----------------------------
+
+;; The Hyprland Super+F popup opens an emacsclient frame named "dirvish" (window
+;; rules float/size/center it by that name) and runs `cj/dirvish-popup', rooted
+;; at home. `q' in that frame runs `cj/dirvish-popup-quit', which quits Dirvish
+;; and deletes the popup frame so a stray launch never orphans it; `q' in any
+;; other frame quits Dirvish normally. The launcher script calls this command
+;; instead of plain `dirvish'. This mirrors the Super+Shift+N quick-capture
+;; popup (see `cj/quick-capture' in org-capture-config.el).
+
+(defun cj/--dirvish-popup-frame ()
+ "Return a live frame named \"dirvish\" (the Hyprland popup), or nil."
+ (seq-find (lambda (f)
+ (and (frame-live-p f)
+ (equal (frame-parameter f 'name) "dirvish")))
+ (frame-list)))
+
+(defun cj/dirvish-popup ()
+ "Open Dirvish in the Hyprland popup frame (frame \"dirvish\"), rooted at home.
+The launcher script calls this through =emacsclient -c -e=. `q'
+(`cj/dirvish-popup-quit') closes the frame.
+
+Selects the \"dirvish\" frame by name before opening rather than trusting the
+ambient selected frame: the launching =emacsclient -c -e= runs before Hyprland
+settles focus on the new float, so =(selected-frame)= is still the daemon's main
+frame and Dirvish would otherwise open there."
+ (interactive)
+ (let ((frame (cj/--dirvish-popup-frame)))
+ (when frame (select-frame-set-input-focus frame))
+ (dirvish (expand-file-name "~/"))))
+
+(defun cj/dirvish-popup-focus-existing ()
+ "Raise and focus the live dirvish popup frame, returning t; nil if none.
+The launcher script calls this before creating a frame, so a second Super+F
+re-uses the open popup instead of spawning a second one (the popup is a
+single-instance, transient launcher -- use =C-x d= for several independent
+Dirvish sessions)."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (when popup
+ (select-frame-set-input-focus popup)
+ t)))
+
+(defun cj/dirvish-popup-quit ()
+ "Quit Dirvish. In the Hyprland popup frame (\"dirvish\"), delete the frame too.
+Bound to `q' in `dirvish-mode-map'. A normal Dirvish session (any other frame)
+quits as usual; only the popup frame is torn down, so the Super+F launch never
+leaves an empty frame behind."
+ (interactive)
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (if (and popup (eq popup (selected-frame)))
+ (progn
+ (ignore-errors (dirvish-quit))
+ (when (frame-live-p popup) (delete-frame popup)))
+ (dirvish-quit))))
+
+(defun cj/--dirvish-popup-selected-p ()
+ "Return non-nil when the selected frame is the dirvish popup frame."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (and popup (eq popup (selected-frame)))))
+
+(defun cj/dirvish-popup-find-file ()
+ "Open the file at point.
+In the Hyprland popup frame the popup is a context-free launcher: files open
+through the OS handler (`cj/xdg-open' -> xdg-open), so nothing lands inside the
+throwaway frame and the launch is independent of the running Emacs session (a
+text/code file opens its own new emacsclient frame, not your working session --
+use =C-x d= when you want a file in the session you're in). Directories are
+entered normally so you can keep browsing. The popup then dismisses itself on
+focus loss. Outside the popup this is exactly `dired-find-file'."
+ (interactive)
+ (if (cj/--dirvish-popup-selected-p)
+ (let ((file (dired-get-file-for-visit)))
+ (if (file-directory-p file)
+ (dired-find-file)
+ (cj/xdg-open file)))
+ (dired-find-file)))
+
+(defun cj/--dirvish-popup-focus-watch (&rest _)
+ "Dismiss the dirvish popup frame once it loses focus.
+Armed only after the popup has actually held focus (a per-frame flag), so the
+frame is never torn down during its own creation, before Hyprland settles focus
+on the new float. Installed on `after-focus-change-function'; a no-op whenever
+no popup frame is live."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (when popup
+ (if (frame-focus-state popup)
+ (set-frame-parameter popup 'cj-dirvish-popup-had-focus t)
+ (when (frame-parameter popup 'cj-dirvish-popup-had-focus)
+ (delete-frame popup))))))
+
+;; Install idempotently: remove any prior copy before adding, so re-loading the
+;; module updates the watch rather than stacking duplicate copies.
+(remove-function after-focus-change-function #'cj/--dirvish-popup-focus-watch)
+(add-function :after after-focus-change-function #'cj/--dirvish-popup-focus-watch)
+
;;; ---------------------------------- Dirvish ----------------------------------
(use-package dirvish
@@ -515,7 +610,8 @@ Uses feh on X11, swww on Wayland."
("bg" . cj/set-wallpaper)
("/" . dirvish-narrow)
("<left>" . dired-up-directory)
- ("<right>" . dired-find-file)
+ ("RET" . cj/dirvish-popup-find-file) ; popup: launch file externally; else normal
+ ("<right>" . cj/dirvish-popup-find-file)
("C-," . dirvish-history-go-backward)
("C-." . dirvish-history-go-forward)
("F" . dirvish-file-info-menu)
@@ -537,6 +633,7 @@ Uses feh on X11, swww on Wayland."
("O" . cj/open-file-with-command) ; Prompts for command to run
("p" . (lambda () (interactive) (cj/dired-copy-path-as-kill nil t)))
("P" . cj/dirvish-print-file)
+ ("q" . cj/dirvish-popup-quit) ; quit; in the Hyprland popup frame, close it
("r" . dirvish-rsync)
("S" . cj/dirvish-drill-file) ; Study: org-drill the .org file at point
("s" . dirvish-quicksort)
diff --git a/modules/jumper.el b/modules/jumper.el
index de270de66..d5d0cf7a7 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -114,7 +114,8 @@ marker's buffer with point at the marker (within `save-current-buffer' and
marker."
(let* ((reg (aref jumper--registers index))
(marker (get-register reg)))
- (when (and marker (markerp marker))
+ (when (and marker (markerp marker)
+ (buffer-live-p (marker-buffer marker)))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -156,6 +157,20 @@ Indices whose marker is no longer valid are skipped (their
for fmt = (jumper--format-location i)
when fmt collect (cons fmt i)))
+(defun jumper--first-free-register ()
+ "Return the lowest register char in 0..N-1 not held by a live slot.
+N is `jumper-max-locations'. Only the live slice (indices 0 through
+`jumper--next-index' minus 1) is consulted, so a char freed by a removal is
+reused on the next store instead of colliding with a surviving slot's
+register and silently overwriting its marker."
+ (let ((used (make-hash-table :test 'eql)))
+ (dotimes (i jumper--next-index)
+ (let ((r (aref jumper--registers i)))
+ (when r (puthash r t used))))
+ (cl-loop for c from ?0 below (+ ?0 jumper-max-locations)
+ unless (gethash c used)
+ return c)))
+
(defun jumper--do-store-location ()
"Store current location in the next free register.
Returns: \\='already-exists if location is already stored,
@@ -165,7 +180,7 @@ Returns: \\='already-exists if location is already stored,
((jumper--location-exists-p) 'already-exists)
((not (jumper--register-available-p)) 'no-space)
(t
- (let ((reg (+ ?0 jumper--next-index)))
+ (let ((reg (jumper--first-free-register)))
(point-to-register reg)
(aset jumper--registers jumper--next-index reg)
(setq jumper--next-index (1+ jumper--next-index))
@@ -190,7 +205,13 @@ Returns: \\='no-locations if no locations stored,
;; Toggle behavior when target-idx is nil and only 1 location
((and (null target-idx) (= jumper--next-index 1))
(if (jumper--location-exists-p)
- 'already-there
+ ;; Already at the only location: toggle back to where we came from
+ ;; when a last-location is recorded, otherwise report no movement.
+ (if (get-register jumper--last-location-register)
+ (progn
+ (jump-to-register jumper--last-location-register)
+ 'jumped-back)
+ 'already-there)
(let ((reg (aref jumper--registers 0)))
(point-to-register jumper--last-location-register)
(jump-to-register reg)
@@ -217,6 +238,7 @@ Returns: \\='no-locations if no locations stored,
((= jumper--next-index 1)
(pcase (jumper--do-jump-to-location nil)
('already-there (message "You're already at the stored location"))
+ ('jumped-back (message "Jumped back to previous location"))
('jumped (message "Jumped to location"))))
;; Multiple locations - prompt user
(t
@@ -233,13 +255,16 @@ Returns: \\='no-locations if no locations stored,
(message "Jumped to location")))))
(defun jumper--reorder-registers (removed-idx)
- "Reorder registers after removing the one at REMOVED-IDX."
- (when (< removed-idx (1- jumper--next-index))
- ;; Shift all higher registers down
- (cl-loop for i from removed-idx below (1- jumper--next-index)
- do (let ((next-reg (aref jumper--registers (1+ i))))
- (aset jumper--registers i next-reg))))
- (setq jumper--next-index (1- jumper--next-index)))
+ "Reorder registers after removing the one at REMOVED-IDX.
+Shift the higher registers down and clear the freed register so its marker
+no longer pins its buffer."
+ (let ((freed (aref jumper--registers removed-idx)))
+ (when (< removed-idx (1- jumper--next-index))
+ ;; Shift all higher registers down
+ (cl-loop for i from removed-idx below (1- jumper--next-index)
+ do (aset jumper--registers i (aref jumper--registers (1+ i)))))
+ (setq jumper--next-index (1- jumper--next-index))
+ (when freed (set-register freed nil))))
(defun jumper--do-remove-location (index)
"Remove location at INDEX.
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 254a2f502..c76193a71 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -123,7 +123,8 @@ detached from Emacs."
read-char-history
face-name-history
bookmark-history
- file-name-history))
+ file-name-history
+ wttrin--location-history))
(put 'minibuffer-history 'history-length 50)
(put 'file-name-history 'history-length 50)
diff --git a/tests/test-coverage-core--relativize-keys.el b/tests/test-coverage-core--relativize-keys.el
new file mode 100644
index 000000000..82031cd15
--- /dev/null
+++ b/tests/test-coverage-core--relativize-keys.el
@@ -0,0 +1,123 @@
+;;; test-coverage-core--relativize-keys.el --- Tests for path-key normalization -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit + integration tests for `cj/--coverage-relativize-keys', the helper
+;; that normalizes a file-path-keyed coverage table to repo-relative paths.
+;;
+;; The bug it fixes: `cj/--coverage-parse-simplecov' returns ABSOLUTE path
+;; keys (simplecov/undercover emit absolute source paths), while
+;; `cj/--coverage-parse-diff-output' returns repo-RELATIVE keys (git's
+;; "+++ b/<path>"). `cj/--coverage-intersect' joins the two by exact string
+;; key, so for the diff-aware scopes every changed file was classified
+;; ":tracked nil" — zero matches ever. Normalizing both tables to
+;; repo-relative before the intersect makes the join work.
+;;
+;; The integration test drives the real parsers (a simplecov JSON fixture
+;; with an absolute key + a git-diff string with the relative key) through
+;; relativize + intersect, and asserts the file is tracked with the right
+;; covered/uncovered split — the end-to-end reproduction of the bug.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'coverage-core)
+
+(defun test-coverage-relativize--hash-of-lines (pairs)
+ "Build a file → line-set hash table from PAIRS.
+Each pair is (FILE . (LINES...)); LINES becomes a hash-table of line → t."
+ (let ((result (make-hash-table :test 'equal)))
+ (dolist (pair pairs)
+ (let ((lines (make-hash-table :test 'eql)))
+ (dolist (line (cdr pair))
+ (puthash line t lines))
+ (puthash (car pair) lines result)))
+ result))
+
+;;; Normal cases
+
+(ert-deftest test-coverage-relativize-absolute-key-made-relative ()
+ "Normal: an absolute key is relativized against ROOT."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("/home/u/.emacs.d/modules/foo.el" 10 11))))
+ (out (cj/--coverage-relativize-keys table "/home/u/.emacs.d")))
+ (should (gethash "modules/foo.el" out))
+ (should (null (gethash "/home/u/.emacs.d/modules/foo.el" out)))))
+
+(ert-deftest test-coverage-relativize-preserves-line-set ()
+ "Normal: the line-set value travels unchanged to the new key."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("/r/modules/foo.el" 4 8 15))))
+ (out (cj/--coverage-relativize-keys table "/r"))
+ (lines (gethash "modules/foo.el" out)))
+ (should (hash-table-p lines))
+ (should (gethash 4 lines))
+ (should (gethash 8 lines))
+ (should (gethash 15 lines))))
+
+;;; Boundary cases
+
+(ert-deftest test-coverage-relativize-already-relative-unchanged ()
+ "Boundary: an already-relative key is left as-is, not re-relativized."
+ (let* ((table (test-coverage-relativize--hash-of-lines
+ '(("modules/foo.el" 1 2))))
+ (out (cj/--coverage-relativize-keys table "/home/u/.emacs.d")))
+ (should (gethash "modules/foo.el" out))
+ (should (= 1 (hash-table-count out)))))
+
+(ert-deftest test-coverage-relativize-empty-table ()
+ "Boundary: an empty table yields an empty table."
+ (let ((out (cj/--coverage-relativize-keys (make-hash-table :test 'equal) "/r")))
+ (should (hash-table-p out))
+ (should (= 0 (hash-table-count out)))))
+
+;;; Error cases
+
+(ert-deftest test-coverage-relativize-nil-table-returns-empty ()
+ "Error: a nil table returns an empty table rather than erroring."
+ (let ((out (cj/--coverage-relativize-keys nil "/r")))
+ (should (hash-table-p out))
+ (should (= 0 (hash-table-count out)))))
+
+;;; Integration — the real bug reproduction
+
+(ert-deftest test-coverage-integration-absolute-report-relative-diff-tracks ()
+ "Integration: a simplecov report (absolute keys) and a git diff (relative
+keys) for the same file intersect as TRACKED once both are relativized.
+This is the diff-aware-scope bug: without normalization the file reads
+\":tracked nil\"."
+ (let* ((root "/tmp/cov-root")
+ (abs-path (concat root "/modules/foo.el"))
+ (report (make-temp-file "cov-report-" nil ".json"))
+ (diff (concat
+ "diff --git a/modules/foo.el b/modules/foo.el\n"
+ "index 1111111..2222222 100644\n"
+ "--- a/modules/foo.el\n"
+ "+++ b/modules/foo.el\n"
+ "@@ -2,0 +2,3 @@\n"
+ "+line two\n"
+ "+line three\n"
+ "+line four\n")))
+ (unwind-protect
+ (progn
+ ;; simplecov array: index1=null, 2=hit, 3=0-hits, 4=hit
+ ;; → covered lines {2, 4}
+ (with-temp-file report
+ (insert (format "{\"t\":{\"coverage\":{%S:[null,1,0,2]}}}" abs-path)))
+ (let* ((covered (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-simplecov report) root))
+ (changed (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-diff-output diff) root))
+ (records (cj/--coverage-intersect covered changed))
+ (record (car records)))
+ (should (= 1 (length records)))
+ (should (equal "modules/foo.el" (plist-get record :path)))
+ (should (eq t (plist-get record :tracked)))
+ (should (equal '(2 3 4) (plist-get record :changed-lines)))
+ (should (equal '(2 4) (plist-get record :covered-lines)))
+ (should (equal '(3) (plist-get record :uncovered-lines)))))
+ (delete-file report))))
+
+(provide 'test-coverage-core--relativize-keys)
+;;; test-coverage-core--relativize-keys.el ends here
diff --git a/tests/test-dirvish-config-popup.el b/tests/test-dirvish-config-popup.el
new file mode 100644
index 000000000..2bd3a192c
--- /dev/null
+++ b/tests/test-dirvish-config-popup.el
@@ -0,0 +1,248 @@
+;;; test-dirvish-config-popup.el --- Dirvish Hyprland popup tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the Hyprland Super+F dirvish popup. The launcher opens an
+;; emacsclient frame named "dirvish" (window rules float/size/center it by that
+;; name) and runs `cj/dirvish-popup', which opens Dirvish rooted at home. `q'
+;; runs `cj/dirvish-popup-quit': in the popup frame it quits Dirvish and deletes
+;; the frame; in any other frame it quits Dirvish normally. Covered here: frame
+;; discovery by name, the emacsclient focus race on open, and the quit dispatch
+;; on every frame condition.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'dirvish-config)
+
+;;; cj/--dirvish-popup-frame (find the popup frame by name)
+
+(ert-deftest test-dirvish-config-popup-frame-found ()
+ "Normal: returns the live frame whose name is \"dirvish\"."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f _p) (if (eq f 'fb) "dirvish" "other"))))
+ (should (eq (cj/--dirvish-popup-frame) 'fb))))
+
+(ert-deftest test-dirvish-config-popup-frame-none ()
+ "Boundary: no popup frame present yields nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "other")))
+ (should-not (cj/--dirvish-popup-frame))))
+
+(ert-deftest test-dirvish-config-popup-frame-skips-dead ()
+ "Boundary: a dead frame named \"dirvish\" is skipped."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb)))
+ ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb))))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "dirvish")))
+ (should (eq (cj/--dirvish-popup-frame) 'fa))))
+
+;;; cj/dirvish-popup (open dirvish in the named frame)
+
+(ert-deftest test-dirvish-config-popup-selects-named-frame ()
+ "Integration: cj/dirvish-popup focuses the \"dirvish\" frame found by name,
+not whatever frame happens to be selected (the emacsclient -c focus race).
+
+Components integrated:
+- cj/dirvish-popup (real)
+- cj/--dirvish-popup-frame (MOCKED — returns a sentinel frame)
+- select-frame-set-input-focus (MOCKED — records the focused frame)
+- dirvish (MOCKED — records the path opened)"
+ (let ((focused nil) (opened nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup-frame))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'dirvish) (lambda (&optional p) (setq opened (or p t)))))
+ (cj/dirvish-popup))
+ (should (eq focused 'popup-frame))
+ (should opened)))
+
+(ert-deftest test-dirvish-config-popup-no-frame-still-opens ()
+ "Integration: with no popup frame found, cj/dirvish-popup skips the focus call
+and still opens Dirvish (no error)."
+ (let ((focused 'unset) (opened nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f)))
+ ((symbol-function 'dirvish) (lambda (&optional _p) (setq opened t))))
+ (cj/dirvish-popup))
+ (should (eq focused 'unset))
+ (should opened)))
+
+;;; cj/dirvish-popup-quit (quit; delete the popup frame only when in it)
+
+(ert-deftest test-dirvish-config-popup-quit-in-popup-deletes-frame ()
+ "Normal: in the popup frame, q quits Dirvish and deletes the popup frame."
+ (let ((quit 0) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'popup))))
+
+(ert-deftest test-dirvish-config-popup-quit-normal-frame-keeps-frame ()
+ "Boundary: with no popup frame, q quits Dirvish and deletes nothing."
+ (let ((quit 0) (deleted 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'selected-frame) (lambda () 'main))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'unset))))
+
+(ert-deftest test-dirvish-config-popup-quit-popup-not-selected-keeps-frame ()
+ "Boundary: the popup exists but a different frame is selected — q quits Dirvish
+in that frame and does not delete the popup."
+ (let ((quit 0) (deleted 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'main))
+ ((symbol-function 'dirvish-quit) (lambda () (cl-incf quit)))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (= quit 1))
+ (should (eq deleted 'unset))))
+
+(ert-deftest test-dirvish-config-popup-quit-survives-dirvish-quit-error ()
+ "Error: a signal from dirvish-quit in the popup still deletes the frame."
+ (let ((deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'dirvish-quit) (lambda () (error "boom")))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/dirvish-popup-quit))
+ (should (eq deleted 'popup))))
+
+;;; cj/dirvish-popup-focus-existing (second-launch re-use guard)
+
+(ert-deftest test-dirvish-config-popup-focus-existing-found ()
+ "Normal: an existing popup is focused and t is returned."
+ (let ((focused nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f))))
+ (should (eq (cj/dirvish-popup-focus-existing) t))
+ (should (eq focused 'popup)))))
+
+(ert-deftest test-dirvish-config-popup-focus-existing-none ()
+ "Boundary: no popup present — returns nil and focuses nothing."
+ (let ((focused 'unset))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f &rest _) (setq focused f))))
+ (should-not (cj/dirvish-popup-focus-existing))
+ (should (eq focused 'unset)))))
+
+;;; cj/--dirvish-popup-selected-p
+
+(ert-deftest test-dirvish-config-popup-selected-p-true ()
+ "Normal: true when the selected frame is the popup frame."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'popup)))
+ (should (cj/--dirvish-popup-selected-p))))
+
+(ert-deftest test-dirvish-config-popup-selected-p-false-other-frame ()
+ "Boundary: false when a different frame is selected."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'selected-frame) (lambda () 'main)))
+ (should-not (cj/--dirvish-popup-selected-p))))
+
+(ert-deftest test-dirvish-config-popup-selected-p-false-no-popup ()
+ "Boundary: false when no popup frame exists."
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'selected-frame) (lambda () 'main)))
+ (should-not (cj/--dirvish-popup-selected-p))))
+
+;;; cj/dirvish-popup-find-file (popup = launcher; outside = plain find-file)
+
+(ert-deftest test-dirvish-config-popup-find-file-in-popup-file-launches-external ()
+ "Normal: in the popup, a file at point opens via cj/xdg-open, not in-frame."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () t))
+ ((symbol-function 'dired-get-file-for-visit) (lambda () "/tmp/a.mp4"))
+ ((symbol-function 'file-directory-p) (lambda (_f) nil))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should (equal opened "/tmp/a.mp4"))
+ (should-not visited)))
+
+(ert-deftest test-dirvish-config-popup-find-file-in-popup-dir-navigates ()
+ "Boundary: in the popup, a directory at point is entered normally."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () t))
+ ((symbol-function 'dired-get-file-for-visit) (lambda () "/tmp/dir/"))
+ ((symbol-function 'file-directory-p) (lambda (_f) t))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should visited)
+ (should-not opened)))
+
+(ert-deftest test-dirvish-config-popup-find-file-outside-popup-is-plain-find-file ()
+ "Boundary: outside the popup, behaves exactly like dired-find-file."
+ (let ((opened nil) (visited nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-selected-p) (lambda () nil))
+ ((symbol-function 'cj/xdg-open) (lambda (f) (setq opened f)))
+ ((symbol-function 'dired-find-file) (lambda () (setq visited t))))
+ (cj/dirvish-popup-find-file))
+ (should visited)
+ (should-not opened)))
+
+;;; cj/--dirvish-popup-focus-watch (dismiss on focus loss, armed after focus)
+
+(ert-deftest test-dirvish-config-popup-focus-watch-focused-arms-flag ()
+ "Normal: while the popup is focused, the watch sets the had-focus flag and
+deletes nothing."
+ (let ((params '()) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should (plist-get params 'cj-dirvish-popup-had-focus))
+ (should-not deleted)))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-unfocused-after-arming-deletes ()
+ "Normal: lost focus after having held it — the popup is deleted."
+ (let ((params (list 'cj-dirvish-popup-had-focus t)) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should (eq deleted 'popup))))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-unfocused-before-arming-keeps ()
+ "Boundary: not focused and never armed (the creation race) — NOT deleted."
+ (let ((params '()) (deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () 'popup))
+ ((symbol-function 'frame-focus-state) (lambda (_f) nil))
+ ((symbol-function 'frame-parameter) (lambda (_f p) (plist-get params p)))
+ ((symbol-function 'set-frame-parameter)
+ (lambda (_f p v) (setq params (plist-put params p v))))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should-not deleted)))
+
+(ert-deftest test-dirvish-config-popup-focus-watch-no-popup-is-noop ()
+ "Error: with no popup frame, the watch does nothing and doesn't raise."
+ (let ((deleted nil))
+ (cl-letf (((symbol-function 'cj/--dirvish-popup-frame) (lambda () nil))
+ ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f))))
+ (cj/--dirvish-popup-focus-watch))
+ (should-not deleted)))
+
+(provide 'test-dirvish-config-popup)
+;;; test-dirvish-config-popup.el ends here
diff --git a/tests/test-jumper--register-hygiene.el b/tests/test-jumper--register-hygiene.el
new file mode 100644
index 000000000..8fc430ac5
--- /dev/null
+++ b/tests/test-jumper--register-hygiene.el
@@ -0,0 +1,179 @@
+;;; test-jumper--register-hygiene.el --- Tests for jumper register hygiene -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for three related jumper.el defects from the 2026-06 config audit:
+;;
+;; 1. Register collisions on removal — removal shifted the vector but never
+;; freed the dropped register char, and a later store allocated by
+;; `jumper--next-index' (a char a surviving slot might still hold),
+;; silently overwriting that slot's marker. Store now allocates the first
+;; free char in the live slice; removal clears the freed register.
+;; 2. Dead-marker errors — `jumper--with-marker-at' guarded `markerp' but not
+;; buffer liveness, so after the buffer holding a location was killed,
+;; store/jump signaled wrong-type errors. Dead entries are now skipped.
+;; 3. Single-location toggle never toggled back — the `already-there' branch
+;; did nothing; it now jumps to the last-location register when set.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'jumper)
+
+(defvar test-jumper-hyg--orig-registers nil)
+(defvar test-jumper-hyg--orig-index nil)
+
+(defun test-jumper-hyg-setup ()
+ "Reset jumper state and the registers it uses to a clean slate."
+ (setq test-jumper-hyg--orig-registers jumper--registers)
+ (setq test-jumper-hyg--orig-index jumper--next-index)
+ (setq jumper--registers (make-vector jumper-max-locations nil))
+ (setq jumper--next-index 0)
+ (dotimes (i jumper-max-locations)
+ (set-register (+ ?0 i) nil))
+ (set-register jumper--last-location-register nil))
+
+(defun test-jumper-hyg-teardown ()
+ "Restore jumper state."
+ (setq jumper--registers test-jumper-hyg--orig-registers)
+ (setq jumper--next-index test-jumper-hyg--orig-index))
+
+;;; Defect 1 — register collisions on removal
+
+(ert-deftest test-jumper-hyg-store-after-remove-reuses-freed-register ()
+ "Normal: storing after a removal reuses the freed char, not next-index.
+Removing index 0 of [0 1 2] leaves the live slice holding chars 1 and 2;
+the next store must take the freed char 0, never 2 (which slot 1 still holds)."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3\nline 4")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0 @ line 1
+ (forward-line 1) (jumper--do-store-location) ; ?1 @ line 2
+ (forward-line 1) (jumper--do-store-location) ; ?2 @ line 3
+ (jumper--do-remove-location 0) ; live slice now [?1 ?2]
+ (forward-line 1) ; line 4
+ (let ((reg (jumper--do-store-location)))
+ (should (= reg ?0)) ; freed char reused
+ (should (= (aref jumper--registers 2) ?0))
+ (should (= jumper--next-index 3))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-store-after-remove-preserves-survivor ()
+ "Normal: the surviving slot's marker is not clobbered by the reused store.
+After removing index 0 and storing a new location, jumping to the slot that
+holds the old top register must still land on its original line."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3\nline 4")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0 @ line 1
+ (forward-line 1) (jumper--do-store-location) ; ?1 @ line 2
+ (let ((line3 (progn (forward-line 1) (point))))
+ (jumper--do-store-location) ; ?2 @ line 3
+ (jumper--do-remove-location 0) ; slot1 now holds ?2 @ line3
+ (goto-char (point-max)) (jumper--do-store-location) ; reuse ?0
+ (goto-char (point-min))
+ (jumper--do-jump-to-location 1) ; slot1 = old line-3 marker
+ (should (= (point) line3))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-remove-clears-freed-register ()
+ "Boundary: removing a location clears its register so the marker is freed."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "test")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; ?0
+ (should (get-register ?0))
+ (jumper--do-remove-location 0)
+ (should (null (get-register ?0))))
+ (test-jumper-hyg-teardown)))
+
+;;; Defect 2 — dead-marker entries are skipped, not errored
+
+(ert-deftest test-jumper-hyg-with-marker-at-dead-buffer-returns-nil ()
+ "Error: a marker whose buffer was killed yields nil, not a wrong-type error."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location)) ; ?0 points into buf
+ (kill-buffer buf) ; marker now detached
+ (should (null (jumper--with-marker-at 0 (lambda () 'ran)))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+(ert-deftest test-jumper-hyg-location-exists-p-survives-dead-buffer ()
+ "Boundary: location-exists-p does not error when a stored buffer is dead."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test-2")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location))
+ (kill-buffer buf)
+ (should (null (jumper--location-exists-p))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+(ert-deftest test-jumper-hyg-candidates-skip-dead-buffer ()
+ "Boundary: the candidate list omits a location whose buffer was killed."
+ (test-jumper-hyg-setup)
+ (let ((buf (generate-new-buffer "jumper-dead-test-3")))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf
+ (insert "content")
+ (goto-char (point-min))
+ (jumper--do-store-location))
+ (kill-buffer buf)
+ (should (null (jumper--location-candidates))))
+ (when (buffer-live-p buf) (kill-buffer buf))
+ (test-jumper-hyg-teardown))))
+
+;;; Defect 3 — single-location toggle returns to the previous spot
+
+(ert-deftest test-jumper-hyg-toggle-back-when-last-set ()
+ "Normal: toggling at the only location jumps back to the last-location register.
+Jump to the location (which records the prior spot in 'z); toggling again while
+sitting on the location returns to that prior spot."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; store @ line 1
+ (let ((away (point-max)))
+ (goto-char away)
+ (jumper--do-jump-to-location nil) ; jump to line 1, 'z := away
+ (should (= (point) (point-min)))
+ (let ((result (jumper--do-jump-to-location nil))) ; toggle back
+ (should (eq result 'jumped-back))
+ (should (= (point) away)))))
+ (test-jumper-hyg-teardown)))
+
+(ert-deftest test-jumper-hyg-toggle-at-location-no-last-stays ()
+ "Boundary: toggling at the location with no last-location set returns
+'already-there and does not move point."
+ (test-jumper-hyg-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((result (jumper--do-jump-to-location nil)))
+ (should (eq result 'already-there))
+ (should (= (point) (point-min)))))
+ (test-jumper-hyg-teardown)))
+
+(provide 'test-jumper--register-hygiene)
+;;; test-jumper--register-hygiene.el ends here
diff --git a/todo.org b/todo.org
index 588450bca..eb8d6a776 100644
--- a/todo.org
+++ b/todo.org
@@ -55,6 +55,41 @@ Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
+** TODO [#C] VAMP — extract music-config into a standalone player :feature:refactor:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
+Build VAMP ("VAMP Audio Music Player"), a standalone, publishable Emacs music player at =~/code/vamp= — derived from a maintained subset of EMMS, depending on the EMMS package not at all, with MPV and mpd behind a generalized adapter API. =.emacs.d= keeps thin glue (=vamp-config.el=: keybindings, paths, dashboard); archsetup owns OS wiring (Super+/ launcher, m3u MIME). Models the =linear-config= → =pearl= migration.
+
+Brainstorm complete 2026-06-22 — validated design at [[file:docs/design/vamp-music-player.org][docs/design/vamp-music-player.org]]. It builds on the prior EMMS-removal work ([[file:docs/specs/music-config-without-emms-spec.org][spec]] + [[file:docs/design/music-config-without-emms-review.org][2026-05-15 review]]), confirming its B1/B2/B4/S3 decisions and pivoting four things (publishable-now, two adapters + generalized API, VAMP name, desktop integration).
+
+Next: (1) revise the spec to the new direction; (2) spike the risky assumptions (mpd dumb-single-file-player contract; m3u =.desktop= on Hyprland); (3) =/start-work= against the revised spec — pure-helper extraction (review Migration Plan step 1) is the safe first phase. Priority [#C] is a placeholder pending Craig's call.
+
+
+** TODO [#C] ai-term: step between running ai-terms even when detached :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-22
+:END:
+The step-to-next-agent family (s-F9 and friends) should cycle to a running ai-term even when that ai-term is currently detached, instead of skipping it. Today the step only lands on attached/visible ai-terms, so a detached-but-running agent gets passed over and there's no keyboard path back to it — re-attach/display it on landing. From the roam inbox.
+
+** TODO [#C] ai-term: multi-backend (Claude / Codex / local ollama) :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-22
+:END:
+Allow creating an ai-term backed by any of Claude, Codex, or a local LLM via ollama, with the backend chosen seamlessly at the start of the session. ai-term currently assumes Claude; generalize the launch path so the agent backend is a selectable parameter and switching between them at session start is frictionless. Routed here from the rulesets roam-inbox item "multiple agent source improvements" (its bullet 3 asked to send emacs this note); the item's other bullets — naming the agent so non-Claude agents aren't called "Claude", and tightening workflow wording for Codex's more literal reading — stay with rulesets.
+
+** TODO [#C] Compare terminal themeability: EAT vs vterm vs ghostel :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-22
+:END:
+Research how completely each of EAT, vterm, and ghostel can be themed — in particular how far theme studio can theme each terminal and what it leaves out. Produce a comparison document, then review it with an eye to whether ai-term should move off ghostel (current) to EAT or vterm. Connects to the chime/emacs-wttrin/pearl face-exposure theme-studio thread. From the roam inbox.
+
+** TODO [#C] term: M-<arrow> enters tmux copy-mode :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-22
+:END:
+Bind meta + arrow keys in the terminal to enter tmux copy-mode, sidestepping the current C-; x c chord — Craig flags this as a "huge timesaver" for scrollback navigation. Needs deciding which terminal(s) it covers (ghostel ai-term, vterm) and how to send the tmux copy-mode entry through the pty. From the roam inbox.
+
** TODO [#B] Un-pin ghostel from 0.33.0 once upstream fixes #422/#423 :bug:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-20
@@ -89,7 +124,8 @@ From the 2026-06 config audit, =modules/transcription-config.el=:
- =:210= — =make-process :stderr= with a file PATH creates a BUFFER named like the path (verified by probe); the "Errored. Logs in <file>" notification points at a log without the error text, and the hidden stderr buffer leaks per transcription. Route stderr into the process buffer or write it out in the sentinel.
- =:370-374= — video path derives txt/log from the temp mp3's /tmp path; the transcript lands in /tmp and dies on reboot, contradicting the "alongside the source" docstring. Pass the video's path as the output base.
-** VERIFY [#C] page-signal pager account deregistered — re-registration needs your hands
+** CANCELLED [#C] page-signal pager account deregistered — re-registration needs your hands
+CLOSED: [2026-06-21 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-12
:END:
@@ -109,7 +145,8 @@ Needs from Craig: the task says "confirm the exact set to keep before unbinding.
=modules/mail-config.el:217-220= — the cmail context (primary account) sets only drafts/sent, so D falls back to default "/trash" which doesn't exist under ~/.mail (=/cmail/Trash= does); and NO context sets =mu4e-refile-folder=, so r targets nonexistent "/archive" everywhere. Accepting mu4e's offer to create the maildir strands mail in a directory mbsync never syncs — messages silently vanish from the server's view. Add =mu4e-trash-folder= to cmail + per-context =mu4e-refile-folder=. From the 2026-06 config audit.
Fixed 2026-06-13: cmail gets =mu4e-trash-folder= "/cmail/Trash"; refile is a per-message function (=cj/mu4e--refile-folder=) instead of a per-context string — mu4e context :vars are sticky, so a per-context refile leaks one account's archive folder into another. cmail → "/cmail/Archive"; gmail/dmail signal a =user-error= rather than move mail into an unsynced phantom folder (Craig chose the fail-safe over syncing [Gmail]/All Mail — the All Mail option means a multi-GB pull + cross-folder duplicates; revisit if local Gmail archiving is wanted). Applies on next mu4e open; pure dispatch helper covered by tests.
-** DOING [#C] Lock screen silently fails — slock is X11-only :bug:quick:
+** CANCELLED [#C] Lock screen silently fails — slock is X11-only :bug:quick:
+CLOSED: [2026-06-21 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
@@ -3442,7 +3479,7 @@ Task: survey the modes/modules Craig works in and identify where a =?= -> curate
** TODO [#B] Dupre diff-changed / diff-refine-changed legibility :bug:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
+:LAST_REVIEWED: 2026-06-21
:END:
Surfaced 2026-06-07 from a pearl session designing its modified-ticket indicator (pearl marks a changed field by inheriting =diff-changed=). dupre's =diff-refine-changed= is bright gold (#ffd700) under near-white text (#f0fef0) -- WCAG contrast ~1.35, unreadable as a plain background. It only looks fine inside diff-mode because diff-mode overlays its own dark foreground. =diff-changed= (#875f00 amber) is ~5.49, readable but off the modus model. Every modus variant keeps both faces legible (contrast 9-16) by pairing a dark low-saturation background with a hue-matched foreground.
@@ -3453,10 +3490,12 @@ Ask:
Reference values -- modus-vivendi: refine-changed bg #4a4a00 fg #efef80, changed bg #363300 fg #efef80. modus-operandi: refine-changed bg #fac090 fg #553d00, changed bg #ffdfa9 fg #553d00.
Side-by-side legibility render: [[file:assets/2026-06-07-dupre-diff-face-legibility-compare.png][assets/2026-06-07-dupre-diff-face-legibility-compare.png]].
-** TODO [#B] F7 diff-aware coverage classifies every changed file "not tracked" :bug:solo:
+** DONE [#B] F7 diff-aware coverage classifies every changed file "not tracked" :bug:solo:
+CLOSED: [2026-06-22 Mon]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-20
:END:
+Fixed 2026-06-22: simplecov keys are absolute, git-diff keys repo-relative, so the exact-key intersect never matched. Added =cj/--coverage-relativize-keys= and normalize both tables to repo-relative in =cj/--coverage-read-and-display= before the intersect; intersect unchanged. New =test-coverage-core--relativize-keys.el= (5 unit + 1 integration through the real parsers). Full suite green.
=modules/coverage-core.el:252= — =cj/--coverage-intersect= joins covered×changed by exact string key, but simplecov.json keys are ABSOLUTE paths while the git-diff parser returns repo-RELATIVE ones — zero matches ever, so working-tree/staged/branch scopes report ":tracked nil" for everything and F7's main feature is inert (whole-project scope works, same-source keys). Unit tests hand-build matching keys so they pass; add one integration test feeding a real undercover report + real diff. Normalize both sides to repo-relative. From the 2026-06 config audit.
** TODO [#C] Migrate tests off mocking primitives (native-comp robustness) :test:refactor:
@@ -3471,9 +3510,9 @@ This task is the durable fix the ecosystem and =elisp-testing.md= point to: rest
Full mechanism, the three failure modes, the research (Emacs bug#51140, bug#61880, buttercup #230, Debian #1021842, the emacs-29 redefine-primitive warning, the manual on =native-comp-enable-subr-trampolines=), and the decision: [[file:docs/native-comp-subr-mocking.org][docs/native-comp-subr-mocking.org]].
-** TODO [#B] Fix up test runner :bug:
+** TODO [#B] Fix up test runner :feature:refactor:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-06
+:LAST_REVIEWED: 2026-06-21
:END:
*** 2026-05-16 Sat @ 11:15:51 -0500 Ideas
**** Current State
@@ -3975,10 +4014,12 @@ Tie this into the existing coverage work:
- Tests cover adapter detection, command building, scope resolution, result
storage, and key interactive paths.
-** TODO [#B] jumper: register collisions and dead-marker errors :bug:solo:
+** DONE [#B] jumper: register collisions and dead-marker errors :bug:solo:
+CLOSED: [2026-06-22 Mon]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-13
:END:
+Fixed 2026-06-22: (1) store now allocates the first unused register char in the live slice (=jumper--first-free-register=) instead of by next-index, and removal clears the freed register, so a store after a removal no longer overwrites a surviving slot's marker; (2) =jumper--with-marker-at= guards =(buffer-live-p (marker-buffer marker))= so killed-buffer entries are skipped instead of signaling wrong-type errors; (3) the single-location toggle jumps back to the last-location register when set (returns =jumped-back=). New =test-jumper--register-hygiene.el= (8 tests); all 42 jumper tests green. Pre-existing unused-lexical =i= warning in =jumper--location-exists-p= left alone (separate nit).
Two related defects from the 2026-06 config audit:
- =modules/jumper.el:155= — removal shifts the vector without renumbering registers, so a later store allocates a register still held by a surviving location and silently overwrites it. Allocate the first free register char in the live slice; =set-register nil= on removal so freed markers don't pin buffers.
- =modules/jumper.el:117,132= — guards check =(markerp marker)= but not =(buffer-live-p (marker-buffer marker))=; after killing a buffer holding a location, M-SPC SPC and M-SPC j signal wrong-type errors. Treat dead entries as skippable/removable.
@@ -3991,15 +4032,24 @@ Also =jumper.el:178= — the promised single-location toggle never toggles back
Spec: [[id:540bf06b-16b8-46c6-b459-c40d1b9c795d][keybinding-console-safety-spec-doing.org]]. Phase 0 (revert 4a1ecf64) is done and pushed. Decisions D1-D5 are open TODOs in the spec; D2/D4/D5 gate the primary work (Phase 1 prune via Appendix D, Phase 2 consolidate + retire the translation block), while D1/D3 (the console-safe prefix) gate only the optional Phase 3 and can stay open indefinitely. Resolve D2/D4/D5, then run Phase 1-2. Appendix D is the keybinding pruning checklist. Add a =#+TODO: TODO | DONE SUPERSEDED CANCELLED= header line to the spec if adopting those decision keywords (rulesets convention update, 2026-06-12).
** TODO [#B] ledger-config is orphaned — ledger-mode never configured :bug:quick:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
Nothing requires =modules/ledger-config.el= (verified by grep), so .dat/.ledger/.journal open without ledger-mode, reports, or flycheck-ledger. The module looks finished, not staged (unlike duet-config, which documents its pre-alpha orphaning). Decide: wire into init.el (+ =cj/executable-find-or-warn= for the ledger binary) or delete. From the 2026-06 config audit.
** TODO [#C] buffer-differs save prompt: 4-way yes/no/diff/cancel :feature:next:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
The "buffer differs from file" confirmation currently gives only yes/no. Craig wants a 4-way choice with explicit consequences: yes (be explicit it overwrites), no (be explicit it discards this action and continues), diff (show a graphical difftastic diff, then return to this prompt), cancel (stop the action, leave the buffer untouched). Needs the exact prompt identified first (which save/overwrite path raises "buffer differs") and a design for the diff-then-return loop. difftastic + cj/diff-buffer-with-file infrastructure already exist. From the roam inbox 2026-06-16.
** TODO [#C] emacs: tag tasks by module name for sorting :refactor:studio:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
Replace topic tagging with single-word module tags: :studio: for everything under scripts/theme-studio/, module-named tags elsewhere, :multi: for cross-area work. Drop bug/enhancement-style tags since work should be chosen on other bases. This changes the current six-tag convention, so update the priority-scheme section to document it, rewrite the task-audit workflow to reconcile tasks against the module scheme, then run the audit. Queue for end of session. From the roam inbox.
** TODO [#C] Build an Org-native API workspace :feature:test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-02
+:LAST_REVIEWED: 2026-06-21
:END:
Build an Emacs-native API workspace layer that keeps =restclient.el= useful for
@@ -4334,9 +4384,9 @@ First pass can skip or mark as unsupported:
6. Open scratch buffer (C-; R n), type a request manually, execute
7. which-key shows "REST client" menu under C-; R
-** TODO [#C] Build debug-profiling.el module :feature:
+** TODO [#C] Build debug-profiling.el module :feature:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-02
+:LAST_REVIEWED: 2026-06-21
:END:
Reusable profiling infrastructure for targeted slow-command investigation. Consolidates scattered profiler bindings (currently in =modules/config-utilities.el=) and adds two pure-helper-backed entry points: "profile next command" and "time region or sexp." Designed via =/brainstorm= 2026-04-26.
@@ -4345,15 +4395,15 @@ Design: [[id:c713b431-ae14-498d-aba9-b84d52f981b6][docs/specs/debug-profiling-sp
Implement via =/start-work= against the design — branch =feat/debug-profiling=, commits decomposed along the test-first split-for-testability boundary. Once shipped, use it as the v1 exercise on the queued [#B] org-capture target-building investigation.
-** TODO [#C] Evaluate jamescherti essential-emacs-packages list :quick:
+** TODO [#C] Evaluate jamescherti essential-emacs-packages list :quick:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-11
+:LAST_REVIEWED: 2026-06-21
:END:
Review [[https://www.jamescherti.com/essential-emacs-packages/][James Cherti's essential Emacs packages]] for anything worth installing. Cross-check each candidate against what is already in the config (=modules/= + =init.el=), skip the ones already present, and shortlist the genuinely new ones with a one-line rationale. Future-installation research, not a commitment to install.
** TODO [#C] Extend F2 "preview" convention across modes :feature:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-02
+:LAST_REVIEWED: 2026-06-21
:END:
F2 is the universal preview key. Currently bound only in markdown-mode (markdown-preview, in =modules/markdown-config.el=). Org-reveal lives on =C-; o R= via =cj/org-map=, not F2. Extend F2 to other modes where a "preview" action is natural:
@@ -4365,22 +4415,37 @@ F2 is the universal preview key. Currently bound only in markdown-mode (markdown
Keep the binding mode-local so F2 stays available as a global candidate where no preview makes sense.
-** TODO [#C] face-diagnostic: face-name buttons + header allowlist :feature:
+** TODO [#C] face-diagnostic: face-name buttons + header allowlist :feature:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
Two v1 follow-ups on the shipped face/font diagnostic: render the face names in the report as buttons that call describe-face (the spec's "For the user" buttons; v1 shows them as plain text), and add face-diagnostic to the module-header allowlist in tests/test-init-module-headers.el now that it's required in init.el. Spec: [[id:98f065cf-8bd5-46a0-ac24-da94d66855ad][face-font-diagnostic-popup-spec-implemented.org]].
** TODO [#C] Gold text in auto-dimmed buffers :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
Some auto-dimmed document buffers render text in gold; source unknown. Likely a face-remapping or overlay interaction with the theme. Blocked on the face/font diagnostic tool above for diagnosis. From the roam inbox.
** TODO [#C] Google Contacts ↔ org-contacts sync investigation :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
From the 2026-06-11 brainstorm. Goal: keep [[file:~/sync/org/contacts.org][contacts.org]] (real org-contacts: PROPERTIES drawers, mu4e completion, org-roam links) in sync with Google Contacts. Google side is solid — official People API (OAuth2, incremental syncToken) or CardDAV; no ToS risk. The hard parts are local: (1) identity — entries have no UID, so two-way needs a GOOGLE_ID property per entry plus a one-time fuzzy reconciliation of the two populated datasets (name/email/phone matching); (2) field mapping — space-separated multi-email in one property, free-text body notes, inconsistent phone formats (normalization decision); (3) conflict policy. First decision gates the rest: one-way Google→org read model (simple) vs true two-way. Candidate architectures: vdirsyncer (proven two-way engine w/ Google support; build only the vCard↔org translation, evaluate org-vcard fidelity) vs a direct People API script with sync state in org properties. Output: recommendation doc in docs/design/ naming direction + the normalization/conflict decisions for Craig. Not :solo: — the one-way-vs-two-way call and normalization policy are Craig's.
** TODO [#C] Google Voice in Emacs — SMS + dialer investigation :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
From the 2026-06-11 messenger-unification brainstorm. Google Voice has no official API; the viable routes ride the Matrix bridge ecosystem's reverse engineering (mautrix-gvoice). Research pass to establish the 2026 state of play: (1) is mautrix-gvoice healthy and what does its auth flow look like now; (2) any better-maintained alternative (CLI/daemon) for the signel-pattern architecture (external daemon + JSON-RPC + thin Emacs chat client); (3) does call initiation (ring-linked-phone-then-connect, Emacs as dialer) survive in the current protocol — two-way audio in Emacs is out of scope (WebRTC); (4) ToS/account-flag risk assessment for Craig's account. Output: a recommendation doc in docs/design/ naming the architecture (signel-pattern daemon vs Matrix bridge + ement.el) or a no-go with reasons. If go, GV becomes a registered backend under the messenger-unification convention (see the [#B] task below).
** TODO [#C] latexmk workflow never activates (two breaks) :bug:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-06-21
+:END:
=modules/latex-config.el:66= — =:hook (TeX-mode-hook . ...)= gets use-package's =-hook= suffix appended (unbound symbol not ending in =-mode=), registering on nonexistent =TeX-mode-hook-hook=, so =TeX-command-default "latexmk"= is never set. Independently =:80= auctex-latexmk is =:defer t= with no trigger, so =auctex-latexmk-setup= never runs and "latexmk" isn't in TeX-command-list. Fix hook name to =TeX-mode=; change auctex-latexmk to =:after tex=. From the 2026-06 config audit.
** TODO [#C] Org-noter custom workflow — fix and finish :feature:bug:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-02
+:LAST_REVIEWED: 2026-06-21
:END:
Continue debugging and testing the custom org-noter workflow from 2025-11-21 session.
@@ -4429,7 +4494,7 @@ The core functionality is implemented but needs debugging before it's production
** TODO [#C] Pick and wire a debug backend for F5 :feature:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-01
+:LAST_REVIEWED: 2026-06-21
:END:
#+begin_src emacs-lisp
@@ -4453,7 +4518,7 @@ Do this after the F-key rework ticket ships so F5 is the only hole left.
** TODO [#C] Review and rebind M-S- keybindings :refactor:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-01
+:LAST_REVIEWED: 2026-06-21
:END:
Changed from M-uppercase to M-S-lowercase for terminal compatibility.
@@ -4480,14 +4545,16 @@ These may override useful defaults - review and pick better bindings:
** TODO [#C] Slack message buffers in a reused popup window :quick:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-05
+:LAST_REVIEWED: 2026-06-21
:END:
Display slack.el message and thread buffers in a dedicated popup window (side or bottom) and reuse that one window instead of spawning a new window per buffer. Likely a =display-buffer-alist= rule (or popper integration) in =modules/slack-config.el=.
-** TODO [#C] the preview splits an already split window into 3 temporarily. :bug:
+** CANCELLED [#C] the preview splits an already split window into 3 temporarily. :bug:
+CLOSED: [2026-06-21 Sun]
looks strange. potentially problematic for ai-terms.
-** TODO [#C] TRAMP/dirvish "?" for remote dates — verify the fix per host :bug:
+** CANCELLED [#C] TRAMP/dirvish "?" for remote dates — verify the fix per host :bug:
+CLOSED: [2026-06-21 Sun]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-02
:END:
@@ -7812,7 +7879,7 @@ Filed 2026-06-02 from a C-f8/C-f9 mix-up. Priority set [#C] (UX polish) — re-g
** TODO [#C] Color dashboard navigator independently of list items :feature:ux:
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-06
+:LAST_REVIEWED: 2026-06-21
:END:
The dashboard navigator (icons + labels) and the recentf/project/bookmark list items are both painted by =dashboard-items-face=: the navigator gets a =dashboard-items-face= overlay, and overlays beat text properties, so the per-button =dashboard-navigator= face is inert. To color the navigator independently of the items, override where that overlay is applied — advise or redefine =dashboard-insert-navigator=, or strip/replace the overlay's face.
Triggered by: 2026-05-22 dashboard color work (L105).