diff options
| -rw-r--r-- | docs/design/vamp-music-player.org | 340 | ||||
| -rw-r--r-- | modules/coverage-core.el | 33 | ||||
| -rw-r--r-- | modules/dirvish-config.el | 99 | ||||
| -rw-r--r-- | modules/jumper.el | 45 | ||||
| -rw-r--r-- | modules/system-utils.el | 3 | ||||
| -rw-r--r-- | tests/test-coverage-core--relativize-keys.el | 123 | ||||
| -rw-r--r-- | tests/test-dirvish-config-popup.el | 248 | ||||
| -rw-r--r-- | tests/test-jumper--register-hygiene.el | 179 | ||||
| -rw-r--r-- | todo.org | 109 |
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 @@ -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). |
