aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 18:09:04 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 18:09:04 -0500
commitfbd69990f7e3731a6df4ed992f1aefaef3748750 (patch)
treeaea5e905e9a9a1101358e3683b410a64475f3306 /modules
parent125e28ee1073a4b83a9c933d747ecdbba4c17b4b (diff)
downloaddotemacs-fbd69990f7e3731a6df4ed992f1aefaef3748750.tar.gz
dotemacs-fbd69990f7e3731a6df4ed992f1aefaef3748750.zip
fix(dev): repair hunk jump, F4 hook leak, async clone, and nil guards
- vc-config: C-; v d matched literal +/- source text via consult-line, not gutter hunks. It now completes over git-gutter's hunk list and jumps to the chosen line. - vc-config: clipboard clone ran git synchronously, freezing every frame for the whole clone. It now runs async with a sentinel that opens the clone on success and surfaces the process buffer on failure. - vc-config: magit-blame bound D and S to the same command. D is now difftastic-magit-diff, matching the transient. - vc-config: dropped the :commands autoload for git-timemachine-show-selected-revision, a function the package never defined. The phantom appeared in M-x and errored. - dev-fkeys: both F4 chained-compile handlers armed a hook on the global compilation-finish-functions before their compile ran, so a quit left it live and the next unrelated compile fired the chain. The one-shot hook now installs buffer-locally in the compilation buffer, same shape as the projectile cache-revert hooks. - test-runner: outside a project with no global test directory, three commands crashed with wrong-type-argument on a nil path. Discovery now returns nil and the commands signal user-error. - diff-config: removed the global "-w" ediff default, which made every session ignore whitespace (indentation-only changes compared as identical). Whitespace-ignore stays available as ediff's per-session toggle. - restclient-config: the C-; R bindings went through raw global-set-key, silently depending on keybindings.el loading first. They now use a prefix keymap registered like the other C-; prefixes. - httpd-config: simple-httpd loaded on a 1s timer and created www/ on every startup. It now defers until impatient-mode needs it, and the doc root is created at package load.
Diffstat (limited to 'modules')
-rw-r--r--modules/dev-fkeys.el40
-rw-r--r--modules/diff-config.el1
-rw-r--r--modules/httpd-config.el10
-rw-r--r--modules/restclient-config.el13
-rw-r--r--modules/test-runner.el13
-rw-r--r--modules/vc-config.el99
6 files changed, 123 insertions, 53 deletions
diff --git a/modules/dev-fkeys.el b/modules/dev-fkeys.el
index 80b43600..0f120a8d 100644
--- a/modules/dev-fkeys.el
+++ b/modules/dev-fkeys.el
@@ -82,13 +82,25 @@ recognized markers both return nil."
;; ---------- Action handlers ----------
+(defun cj/--f4-install-once-hook (buffer then-fn)
+ "Install a one-shot buffer-local compilation finish hook in BUFFER.
+Installing in the compilation buffer itself (rather than globally)
+means a quit before the compile starts, or an unrelated concurrent
+compile, can never fire the chained THEN-FN. No-op when BUFFER is not
+a live buffer."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (add-hook 'compilation-finish-functions
+ (cj/--f4-make-once-hook then-fn) nil t))))
+
(defun cj/--f4-compile-and-run-impl ()
"Run `projectile-compile-project', then `projectile-run-project' on success.
-Installs a one-shot `compilation-finish-functions' hook to chain the run."
- (add-hook 'compilation-finish-functions
- (cj/--f4-make-once-hook
- (lambda () (projectile-run-project nil))))
- (projectile-compile-project nil))
+Chains the run via a one-shot finish hook installed buffer-locally in
+the compilation buffer projectile returns."
+ (let ((result (projectile-compile-project nil)))
+ (cj/--f4-install-once-hook
+ (cj/--projectile-compilation-buffer result)
+ (lambda () (projectile-run-project nil)))))
(defun cj/--f4-dispatch (action)
"Route ACTION (a symbol from `cj/--f4-candidates') to its handler.
@@ -109,24 +121,26 @@ command (prompted-and-cached by projectile) drives the build."
(let ((clean-cmd (cj/--f4-derive-clean-cmd root)))
(unless clean-cmd
(user-error "Clean + Rebuild: no clean command for this project type"))
- (add-hook 'compilation-finish-functions
- (cj/--f4-make-once-hook
- (lambda () (projectile-compile-project nil))))
- (let ((default-directory root))
- (compile clean-cmd))))
+ (let* ((default-directory root)
+ (buffer (compile clean-cmd)))
+ (cj/--f4-install-once-hook
+ buffer (lambda () (projectile-compile-project nil))))))
;; ---------- One-shot compilation-finish hook ----------
(defun cj/--f4-make-once-hook (then-fn)
"Build a one-shot `compilation-finish-functions' hook that chains THEN-FN.
The returned lambda removes itself from `compilation-finish-functions' on
-first invocation regardless of status, then calls THEN-FN only if the
-status string starts with \"finished\" (the convention used by compile.el
-for a successful compile)."
+first invocation regardless of status — from both the global value and
+the running buffer's local value, so it is one-shot wherever it was
+installed — then calls THEN-FN only if the status string starts with
+\"finished\" (the convention used by compile.el for a successful
+compile)."
(let (hook)
(setq hook
(lambda (_buf status)
(remove-hook 'compilation-finish-functions hook)
+ (remove-hook 'compilation-finish-functions hook t)
(when (and (stringp status)
(string-prefix-p "finished" status))
(funcall then-fn))))
diff --git a/modules/diff-config.el b/modules/diff-config.el
index 0c09b951..75911587 100644
--- a/modules/diff-config.el
+++ b/modules/diff-config.el
@@ -40,7 +40,6 @@
:custom
(ediff-window-setup-function 'ediff-setup-windows-plain)
(ediff-split-window-function 'split-window-horizontally)
- (ediff-diff-options "-w")
(ediff-highlight-all-diffs nil)
:bind-keymap ("C-c D" . cj/ediff-map)
:init
diff --git a/modules/httpd-config.el b/modules/httpd-config.el
index 1a2a5c61..a3ae0fac 100644
--- a/modules/httpd-config.el
+++ b/modules/httpd-config.el
@@ -5,8 +5,9 @@
;;
;; Layer: 4 (Optional).
;; Category: O/D/P.
-;; Load shape: eager.
-;; Eager reason: none; local web server, a command-loaded deferral candidate.
+;; Load shape: deferred.
+;; Defer reason: impatient-mode requires simple-httpd on demand; nothing
+;; needs the server (or its www/ root) at startup.
;; Top-level side effects: package configuration via use-package.
;; Runtime requires: none.
;; Direct test load: yes.
@@ -17,14 +18,15 @@
;;;; -------------------------- Simple-Httpd -------------------------
(use-package simple-httpd
- :defer 1
+ :defer t
:preface
(defconst cj/httpd-wwwdir (concat user-emacs-directory "www"))
(defun cj/httpd-check-or-create-wwwdir ()
(unless (file-exists-p cj/httpd-wwwdir)
(make-directory cj/httpd-wwwdir)))
- :init (cj/httpd-check-or-create-wwwdir)
:config
+ ;; Create the doc root only when the server package actually loads.
+ (cj/httpd-check-or-create-wwwdir)
(setq httpd-root cj/httpd-wwwdir)
(setq httpd-show-backtrace-when-error t)
(setq httpd-serve-files t))
diff --git a/modules/restclient-config.el b/modules/restclient-config.el
index 0511eddb..497f54d0 100644
--- a/modules/restclient-config.el
+++ b/modules/restclient-config.el
@@ -8,7 +8,7 @@
;; Load shape: eager.
;; Eager reason: none; API exploration, a command-loaded deferral candidate.
;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: none (configures packages via use-package).
+;; Runtime requires: keybindings (C-; R prefix registration).
;; Direct test load: yes.
;;
;; Integrates restclient.el for interactive API exploration from within Emacs.
@@ -23,6 +23,8 @@
;;; Code:
+(require 'keybindings) ;; cj/register-prefix-map
+
;; --------------------------------- Constants ---------------------------------
(defvar cj/restclient-data-dir (expand-file-name "data/" user-emacs-directory)
@@ -61,12 +63,15 @@
;; -------------------------------- Keybindings --------------------------------
-(global-set-key (kbd "C-; R n") #'cj/restclient-new-buffer)
-(global-set-key (kbd "C-; R o") #'cj/restclient-open-file)
+(defvar-keymap cj/restclient-map
+ :doc "Keymap for restclient operations"
+ "n" #'cj/restclient-new-buffer
+ "o" #'cj/restclient-open-file)
+
+(cj/register-prefix-map "R" cj/restclient-map "REST client")
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
- "C-; R" "REST client"
"C-; R n" "new scratch buffer"
"C-; R o" "open .rest file"))
diff --git a/modules/test-runner.el b/modules/test-runner.el
index 7f157f1c..6cb35275 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -139,9 +139,11 @@ if not found or not in a project."
(t cj/test-global-directory))))))
(defun cj/test--get-test-files ()
- "Return list of test file names (without path) in test directory."
+ "Return list of test file names (without path) in test directory.
+Returns nil when no test directory is available (outside a project
+with `cj/test-global-directory' unset)."
(let ((dir (cj/test--get-test-directory)))
- (when (file-directory-p dir)
+ (when (and dir (file-directory-p dir))
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
@@ -169,6 +171,8 @@ Returns: (cons \\='success loaded-count) on success,
(interactive)
(cj/test--ensure-test-dir-in-load-path)
(let ((dir (cj/test--get-test-directory)))
+ (unless dir
+ (user-error "No test directory: not in a project and cj/test-global-directory is unset"))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
(let ((test-files (directory-files dir t "^test-.*\\.el$")))
@@ -200,11 +204,12 @@ Returns: \\='success if added successfully,
(cj/test--ensure-test-dir-in-load-path)
(let* ((focused-files (cj/test--current-focused-files))
(dir (cj/test--get-test-directory))
- (available-files (when (file-directory-p dir)
+ (available-files (when (and dir (file-directory-p dir))
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
(if (null available-files)
- (user-error "No test files found in %s" dir)
+ (user-error "No test files found in %s"
+ (or dir "any test directory (not in a project)"))
(let* ((unfocused-files (cl-set-difference available-files
focused-files
:test #'string=))
diff --git a/modules/vc-config.el b/modules/vc-config.el
index 60fcaeb8..3da9266f 100644
--- a/modules/vc-config.el
+++ b/modules/vc-config.el
@@ -37,9 +37,13 @@
(defvar forge-pull-notifications)
(defvar forge-topic-list-limit)
+;; External package variables (buffer-local hunk list from git-gutter).
+(defvar git-gutter:diffinfos)
+
;; External package functions (from lazily-loaded packages).
(declare-function git-gutter:next-hunk "git-gutter")
(declare-function git-gutter:previous-hunk "git-gutter")
+(declare-function git-gutter-hunk-start-line "git-gutter")
(declare-function git-timemachine--start "git-timemachine")
(declare-function git-timemachine--revisions "git-timemachine")
(declare-function git-timemachine-show-revision "git-timemachine")
@@ -100,8 +104,7 @@
(use-package git-timemachine
:commands (git-timemachine
- git-timemachine-show-revision
- git-timemachine-show-selected-revision)
+ git-timemachine-show-revision)
:init
(defun cj/git-timemachine-show-selected-revision ()
"Displays git revisions of file in chronological order adding metadata."
@@ -157,13 +160,33 @@
(forge-create-issue)
(user-error "Not in a forge repository")))
+(defun cj/--git-gutter-hunk-candidates (start-lines)
+ "Build completion candidates for hunk START-LINES in the current buffer.
+Each candidate is a cons of a \"LINE: text\" label and the line number."
+ (mapcar (lambda (line)
+ (cons (format "%4d: %s" line
+ (save-excursion
+ (goto-char (point-min))
+ (forward-line (1- line))
+ (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ line))
+ start-lines))
+
(defun cj/goto-git-gutter-diff-hunks ()
- "Jump to git-gutter diff hunks using consult.
-Searches for lines starting with + or - (diff markers) and allows
-interactive selection to jump to any changed line in the buffer."
+ "Jump to a git-gutter hunk in the current buffer chosen with completion."
(interactive)
(require 'git-gutter)
- (consult-line "^[+\\-]"))
+ (let ((candidates (cj/--git-gutter-hunk-candidates
+ (mapcar #'git-gutter-hunk-start-line
+ (and (boundp 'git-gutter:diffinfos)
+ git-gutter:diffinfos)))))
+ (unless candidates
+ (user-error "No git-gutter hunks in this buffer"))
+ (let ((line (cdr (assoc (completing-read "Hunk: " candidates nil t)
+ candidates))))
+ (goto-char (point-min))
+ (forward-line (1- line)))))
;; ------------------------------ Git Clone Clipboard -----------------------------
;; Quick git clone from clipboard URL
@@ -180,6 +203,33 @@ scp form."
(last (car (last (split-string trimmed "[/:]" t)))))
(and last (file-name-sans-extension last))))
+(defun cj/--git-clone-open (clone-dir)
+ "Open CLONE-DIR's README when one exists, else `dired' the directory."
+ (let ((readme (seq-find
+ (lambda (file)
+ (string-match-p "\\`README" (upcase file)))
+ (directory-files clone-dir))))
+ (if readme
+ (find-file (expand-file-name readme clone-dir))
+ (dired clone-dir))))
+
+(defun cj/--git-clone-make-sentinel (url clone-dir)
+ "Return a sentinel reporting the git clone of URL into CLONE-DIR.
+On a zero exit the sentinel announces success and opens the clone; on
+any other exit or a signal it surfaces the process buffer."
+ (lambda (process _event)
+ (when (memq (process-status process) '(exit signal))
+ (if (and (eq (process-status process) 'exit)
+ (zerop (process-exit-status process)))
+ (progn
+ (message "Cloned %s into %s" url clone-dir)
+ (cj/--git-clone-open clone-dir))
+ (let ((buf (process-buffer process)))
+ (when (buffer-live-p buf)
+ (pop-to-buffer buf))
+ (message "git clone of %s failed (status %s)"
+ url (process-exit-status process)))))))
+
(defun cj/git-clone-clipboard-url (url target-dir)
"Clone git repository from clipboard URL to TARGET-DIR.
@@ -187,11 +237,13 @@ With no prefix argument: uses first directory in `cj/git-clone-dirs'.
With \\[universal-argument]: choose from `cj/git-clone-dirs'.
With \\[universal-argument] \\[universal-argument]: choose any directory.
-Clones with a direct `git' process (no shell), into a path derived
-robustly from URL. Aborts with a clear message when the clipboard is
-empty, the target is not a writable directory, the destination already
-exists, or `git' exits non-zero. After a successful clone, opens the
-repository's README if found, else `dired's the clone."
+Clones with a direct asynchronous `git' process (no shell, no frozen
+frames), into a path derived robustly from URL. Aborts with a clear
+message when the clipboard is empty, the target is not a writable
+directory, or the destination already exists. The process sentinel
+reports the result: on success it opens the repository's README if
+found (else `dired's the clone); on failure it surfaces the process
+buffer."
(interactive
(list (current-kill 0) ;; Get URL from clipboard
(cond
@@ -219,21 +271,14 @@ repository's README if found, else `dired's the clone."
(when (file-exists-p clone-dir)
(user-error "Clone destination already exists: %s" clone-dir))
(message "Cloning %s into %s..." url clone-dir)
- ;; Direct process, no shell. `--' stops option parsing so a URL
- ;; beginning with `-' can't be read as a git flag.
- (let ((status (call-process "git" nil "*git-clone*" nil
- "clone" "--" url clone-dir)))
- (unless (zerop status)
- (pop-to-buffer "*git-clone*")
- (user-error "git clone failed (exit %d); see *git-clone*" status)))
- ;; Find and open README
- (let ((readme (seq-find
- (lambda (file)
- (string-match-p "\\`README" (upcase file)))
- (directory-files clone-dir))))
- (if readme
- (find-file (expand-file-name readme clone-dir))
- (dired clone-dir))))))
+ ;; Direct async process, no shell, so no emacsclient frame blocks
+ ;; for the duration of the clone. `--' stops option parsing so a
+ ;; URL beginning with `-' can't be read as a git flag.
+ (make-process
+ :name "git-clone"
+ :buffer "*git-clone*"
+ :command (list "git" "clone" "--" url clone-dir)
+ :sentinel (cj/--git-clone-make-sentinel url clone-dir)))))
;; -------------------------------- Difftastic ---------------------------------
;; Structural diffs for better git change visualization
@@ -243,7 +288,7 @@ repository's README if found, else `dired's the clone."
:defer t
:commands (difftastic-magit-diff difftastic-magit-show)
:bind (:map magit-blame-read-only-mode-map
- ("D" . difftastic-magit-show)
+ ("D" . difftastic-magit-diff)
("S" . difftastic-magit-show))
:config
(eval-after-load 'magit-diff