From c687fdd1ccfc4f7e559d7c492588b736d6444c32 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 12 Jul 2026 12:32:21 +0300 Subject: [PATCH] Run cljr-rename-file-or-dir asynchronously Renaming a file rewrites references to it across the whole project, which can take a while. It used a blocking cljr--call-middleware-sync, freezing Emacs until the middleware responded. It now fires the request through a new cljr--call-middleware-async-collect helper and does the rest - the completion message, ast-cache warming, revisiting affected buffers - in the callback, so Emacs stays responsive. cljr--call-middleware-async-collect is the non-blocking counterpart to cljr--call-middleware-sync: it accumulates the response messages, and on done reports any middleware error to the user (rather than letting it escape the nREPL process filter) and hands the result to the callback. A generous safety-net timeout keeps a hung request from waiting forever. Validated live against a real refactor-nrepl connection: the command returns immediately, then renames the file, updates its ns, and rewrites a requiring file's :require once the middleware responds. --- CHANGELOG.md | 2 ++ clj-refactor.el | 82 ++++++++++++++++++++++++++++++++++++---------- tests/unit-test.el | 29 ++++++++++++++++ 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84880c1..8fec1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- `cljr-rename-file-or-dir` now runs asynchronously. Renaming a file rewrites references to it across the whole project, which can take a while; instead of freezing Emacs on a synchronous round-trip, the command returns immediately and finishes (renaming the file, revisiting affected buffers) once the middleware responds. + - Preview project-wide refactorings before they touch disk. `cljr-rename-symbol`, `cljr-change-function-signature` and `cljr-inline-symbol` now gather all their edits first and show them as a diff, and nothing is written until you confirm. Controlled by the new `cljr-preview-refactorings` (default on); set it to nil for the old apply-immediately behavior. - New `cljr-undo-last-refactoring` reverts every file changed by the last applied rename or change-signature in one step (bound to `ur`, and in `clj-refactor-menu`). diff --git a/clj-refactor.el b/clj-refactor.el index f67987f..4a08091 100644 --- a/clj-refactor.el +++ b/clj-refactor.el @@ -1193,24 +1193,30 @@ See: https://github.com/clojure-emacs/clj-refactor.el/wiki/cljr-rename-file-or-d (nrepl-new-path (funcall cider-to-nrepl-filename-function new-path)) (nrepl-old-path (funcall cider-to-nrepl-filename-function old-path))) (when (y-or-n-p (format "Really rename %s to %s?" old-path new-path)) - (let* ((changed-files (cljr--call-middleware-sync - (cljr--create-msg "rename-file-or-dir" - "old-path" nrepl-old-path - "new-path" nrepl-new-path - "print-right-margin" cljr-print-right-margin - "print-miser-width" cljr-print-miser-width) - "touched")) - (changed-files-count (length changed-files))) - (cond - ((null changed-files) (cljr--post-command-message "Rename complete! No files affected.")) - ((= changed-files-count 1) (cljr--post-command-message "Renamed %s to %s." old-path new-path)) - (t (cljr--post-command-message "Rename complete! %s files affected." changed-files-count))) - (when (and (> changed-files-count 0) (not cljr-warn-on-eval)) - (cljr--warm-ast-cache))) - (if affected-buffers - (cljr--revisit-buffers affected-buffers new-path active-buffer) - (kill-buffer active-buffer) - (find-file new-path)))))) + ;; Renaming a file rewrites references to it across the whole project, + ;; which can take a while, so run it asynchronously and finish up in the + ;; callback rather than freezing Emacs on a synchronous round-trip. + (message "Renaming %s..." old-path) + (cljr--call-middleware-async-collect + (cljr--create-msg "rename-file-or-dir" + "old-path" nrepl-old-path + "new-path" nrepl-new-path + "print-right-margin" cljr-print-right-margin + "print-miser-width" cljr-print-miser-width) + "touched" + (lambda (changed-files) + (let ((changed-files-count (length changed-files))) + (cond + ((null changed-files) (message "Rename complete! No files affected.")) + ((= changed-files-count 1) (message "Renamed %s to %s." old-path new-path)) + (t (message "Rename complete! %s files affected." changed-files-count))) + (when (and (> changed-files-count 0) (not cljr-warn-on-eval)) + (cljr--warm-ast-cache))) + (if affected-buffers + (cljr--revisit-buffers affected-buffers new-path active-buffer) + (when (buffer-live-p active-buffer) + (kill-buffer active-buffer)) + (find-file new-path)))))))) ;;;###autoload (defun cljr-rename-file (new-path) @@ -2607,6 +2613,46 @@ If it's present KEY indicates the key to extract from the response." (defun cljr--call-middleware-async (request &optional callback) (cider-nrepl-send-request request callback)) +(defconst cljr--async-request-timeout 120 + "Seconds to wait for an async middleware request before giving up. +A safety net so a hung request or dropped connection doesn't leave a +command waiting forever; generous enough not to cut off a legitimately +slow project-wide analysis.") + +(defun cljr--call-middleware-async-collect (request key callback) + "Send REQUEST to the middleware asynchronously and keep Emacs responsive. + +The response messages are accumulated; once the request is done the merged +response is checked for a middleware error and then CALLBACK is called with +the value of KEY (or the whole response when KEY is nil). A middleware +error is reported to the user rather than signaled out of the nREPL filter, +and the request is abandoned with a message if it doesn't finish within +`cljr--async-request-timeout'. This is the non-blocking counterpart to +`cljr--call-middleware-sync' for commands that just act on a single result." + (let ((accumulator (nrepl-dict)) + (settled nil) + timer) + (setq timer (run-at-time cljr--async-request-timeout nil + (lambda () + (unless settled + (setq settled t) + (message "clj-refactor: middleware request timed out"))))) + (cljr--call-middleware-async + request + (lambda (response) + (setq accumulator (nrepl-dict-merge accumulator response)) + (when (and (not settled) + (member "done" (nrepl-dict-get response "status"))) + (setq settled t) + (when (timerp timer) (cancel-timer timer)) + (condition-case err + (progn + (cljr--maybe-rethrow-error accumulator) + (funcall callback (if key + (nrepl-dict-get accumulator key) + accumulator))) + (error (message "clj-refactor: %s" (error-message-string err))))))))) + (defcustom cljr-artifact-cache-ttl 300 "Number of seconds to cache middleware lookups whose results change rarely. diff --git a/tests/unit-test.el b/tests/unit-test.el index 661dbe5..b0ca837 100644 --- a/tests/unit-test.el +++ b/tests/unit-test.el @@ -940,3 +940,32 @@ str/")))) (expect (buffer-string) :to-equal "(def foo 1)\n(foo)\n;; unsaved work\n") (expect (buffer-modified-p) :to-be-truthy)) (expect (cljr--test-file-contents tmpfile) :to-equal "(def foo 1)\n(foo)\n"))))) + +(describe "cljr--call-middleware-async-collect" + (it "accumulates split response messages and delivers the value on done" + (spy-on 'cljr--call-middleware-async :and-call-fake + (lambda (_request cb) + (funcall cb (nrepl-dict "touched" '("a.clj" "b.clj"))) + (funcall cb (nrepl-dict "status" '("done"))))) + (let (result (calls 0)) + (cljr--call-middleware-async-collect + (cljr--create-msg "rename-file-or-dir") "touched" + (lambda (v) (setq result v calls (1+ calls)))) + (expect calls :to-equal 1) + (expect result :to-equal '("a.clj" "b.clj")))) + (it "fires once when value and done arrive together" + (spy-on 'cljr--call-middleware-async :and-call-fake + (lambda (_request cb) + (funcall cb (nrepl-dict "touched" '("a.clj") "status" '("done"))))) + (let ((calls 0)) + (cljr--call-middleware-async-collect + (cljr--create-msg "x") "touched" (lambda (_v) (setq calls (1+ calls)))) + (expect calls :to-equal 1))) + (it "doesn't fire before the request is done" + (spy-on 'cljr--call-middleware-async :and-call-fake + (lambda (_request cb) + (funcall cb (nrepl-dict "touched" '("a.clj"))))) + (let ((calls 0)) + (cljr--call-middleware-async-collect + (cljr--create-msg "x") "touched" (lambda (_v) (setq calls (1+ calls)))) + (expect calls :to-equal 0))))