From 60caafb3b24dfe725cc13fcbb081a87aaf584320 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 29 Sep 2026 09:56:40 +0300 Subject: [PATCH 1/3] [#2194] Define the dispatch menu as soon as transient loads projectile-dispatch was a stub until its first invocation, so transient-insert-suffix and friends failed on it with "Not a transient prefix command" unless the user called the private projectile--dispatch-define by hand. Defining the real menu from a with-eval-after-load on transient keeps transient lazily loaded, but makes the menu extensible the moment anything (the user's own require, magit, the stub) loads it. --- projectile.el | 27 ++++++++++++++++++--------- test/projectile-dispatch-test.el | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/projectile.el b/projectile.el index 41255b6a8..5c1fe8976 100644 --- a/projectile.el +++ b/projectile.el @@ -17430,10 +17430,12 @@ Like `projectile-dispatch-search-review', but for the replace reviewer." ;; The menu keys deliberately match the `projectile-command-map' bindings. ;; The transient prefix is defined lazily: loading `transient' costs a few ;; milliseconds and some memory for every session, while the menu is only -;; needed once invoked. `projectile-dispatch' below is a stub that loads -;; `transient', evaluates the real definition (replacing itself), and -;; re-invokes it; `transient' is required at compile time (see the top of -;; the file) so the macro still expands during byte-compilation. +;; needed once invoked. `projectile-dispatch' below is a stub; the real +;; definition replaces it as soon as `transient' is loaded, whether by the +;; stub itself or by anything else, so user code can extend the menu with +;; `transient-append-suffix' right after `(require 'transient)'. +;; `transient' is required at compile time (see the top of the file) so the +;; macro still expands during byte-compilation. (defun projectile--dispatch-define () "Define the `projectile-dispatch' transient prefix, replacing the stub." (transient-define-prefix projectile-dispatch () @@ -17562,17 +17564,24 @@ search/replace case-sensitive, `--word' makes it match whole words, (shells), and `--display' opens the result in another window or frame (file/buffer/project commands)." (interactive) - ;; Loading `transient' is deferred until the menu is first used; this - ;; stub is replaced by the real transient prefix on that first call. + ;; Loading `transient' is deferred until the menu is first used. Loading + ;; it defines the real menu (see the `with-eval-after-load' below); the + ;; stub's marker property still being there means that didn't happen. (require 'transient) - (projectile--dispatch-define) + (when (eq (get 'projectile-dispatch 'transient--prefix) t) + (projectile--dispatch-define)) (call-interactively 'projectile-dispatch)) ;; Mark the stub as a transient prefix so `projectile--transient-command-p' -;; recognizes it before the first invocation replaces the stub (and this -;; property) with the real definition. +;; recognizes it before the real definition replaces the stub (and this +;; property). (put 'projectile-dispatch 'transient--prefix t) +;; Replace the stub once `transient' is loaded. This must come after the +;; stub: it runs immediately when `transient' is already loaded. +(with-eval-after-load 'transient + (projectile--dispatch-define)) + (defvar projectile-mode-map (let ((map (make-sparse-keymap))) (when projectile-keymap-prefix diff --git a/test/projectile-dispatch-test.el b/test/projectile-dispatch-test.el index ac6a0c4e4..ffe17cce9 100644 --- a/test/projectile-dispatch-test.el +++ b/test/projectile-dispatch-test.el @@ -178,6 +178,25 @@ (expect (plist-get captured :case-fold) :to-be t) (expect (plist-get captured :word) :to-be nil))) +(describe "projectile-dispatch definition" + (it "is the real transient prefix once transient is loaded" + (require 'transient) + (expect (transient-prefix-p (get 'projectile-dispatch 'transient--prefix)) + :to-be-truthy)) + + (it "can be extended with transient-insert-suffix before its first use" + (require 'transient) + (unwind-protect + (progn + (transient-insert-suffix 'projectile-dispatch "f" + '("RET" "find-file" find-file)) + ;; The layout entry's shape varies across transient versions, + ;; but looking up a missing key signals an error. + (expect (transient-get-suffix 'projectile-dispatch "RET") + :to-be-truthy)) + ;; Put the stock layout back for the specs that follow. + (projectile--dispatch-define)))) + (provide 'projectile-dispatch-test) ;;; projectile-dispatch-test.el ends here From e6a70a03bd453ce4e009d583b79e8da44fb91494 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 29 Sep 2026 09:56:49 +0300 Subject: [PATCH 2/3] [#2194] Show how to move commander methods to the dispatch menu The upgrade guide only pointed at transient-append-suffix, which leaves out the parts that actually trip people up: the prefix is the projectile-dispatch symbol, the location is an existing entry's key, and transient has to be loaded first. --- .../ROOT/pages/upgrading_to_projectile_3.adoc | 44 ++++++++++++++++++- doc/modules/ROOT/pages/usage.adoc | 31 +++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/doc/modules/ROOT/pages/upgrading_to_projectile_3.adoc b/doc/modules/ROOT/pages/upgrading_to_projectile_3.adoc index 969094ccd..79a29074f 100644 --- a/doc/modules/ROOT/pages/upgrading_to_projectile_3.adoc +++ b/doc/modules/ROOT/pages/upgrading_to_projectile_3.adoc @@ -26,8 +26,48 @@ The single-key "Commander" (`projectile-commander` and `projectile-dispatch`, a `transient` menu bound to kbd:[s-p m]. A prefix argument on project switch (kbd:[C-u s-p p]) also opens it. -If you defined your own commander methods, add them to the dispatch transient -instead (see `transient-append-suffix` and the `projectile-dispatch` definition). +If you defined your own commander methods, add them to the dispatch menu +instead. Say you had this: + +[source,elisp] +---- +(def-projectile-commander-method ?\C-m + "Find file in project." + (call-interactively #'find-file)) +---- + +The dispatch version is: + +[source,elisp] +---- +(with-eval-after-load 'projectile + (require 'transient) + (transient-insert-suffix 'projectile-dispatch "f" + '("RET" "find-file" find-file))) +---- + +`projectile-dispatch` is a transient prefix, so the standard +`transient-insert-suffix` and `transient-append-suffix` work on it. The second +argument says where the new entry goes: the key of an existing entry (`"f"` +here, the menu's "file" entry). `transient-insert-suffix` puts it before that +entry and `transient-append-suffix` after it. A commander method's body becomes +a command of its own: + +[source,elisp] +---- +(defun my-projectile-magit-log () + "Show the log of the current project." + (interactive) + (magit-log-head)) + +(with-eval-after-load 'projectile + (require 'transient) + (transient-append-suffix 'projectile-dispatch "v" + '("L" "magit log" my-projectile-magit-log))) +---- + +See xref:usage.adoc#extending-the-dispatch-menu[Extending the dispatch menu] +for more on how this works. === The idle timer diff --git a/doc/modules/ROOT/pages/usage.adoc b/doc/modules/ROOT/pages/usage.adoc index 9a76bbb2a..9a5fc455f 100644 --- a/doc/modules/ROOT/pages/usage.adoc +++ b/doc/modules/ROOT/pages/usage.adoc @@ -617,6 +617,37 @@ NOTE: `projectile-dispatch` is powered by `transient`, which is bundled with Emacs 28.1+ (Projectile's minimum), so the menu is always available. It's bound to kbd:[s-p m], and kbd:[C-u s-p p] opens it when switching projects. +=== Extending the dispatch menu + +`projectile-dispatch` is an ordinary transient prefix, so you can add your own +entries with `transient-insert-suffix` (before an existing entry) or +`transient-append-suffix` (after one), naming the existing entry by its key: + +[source,elisp] +---- +(with-eval-after-load 'projectile + (require 'transient) + ;; RET runs a plain `find-file', listed before the "file" entry (f) + (transient-insert-suffix 'projectile-dispatch "f" + '("RET" "find-file" find-file))) +---- + +The suffix is any interactive command. It runs with the project current, so +`default-directory` is the project root when you open the menu from project +switching (kbd:[C-u s-p p]). + +The `(require 'transient)` is there because Projectile defers loading +`transient` until the menu is first opened, and the menu only exists once +`transient` is loaded. If you'd rather keep `transient` out of your startup, put +the customization in `(with-eval-after-load 'transient ...)` inside the +`with-eval-after-load 'projectile` form instead; it then runs whenever +something first loads `transient`. + +NOTE: Before Projectile 3.5 loading `transient` wasn't enough; the menu was only +defined on its first use. On 3.1 through 3.4 call +`(projectile--dispatch-define)` after `(require 'transient)` and before +changing the menu. + == Finding changed files `projectile-find-changed-file` (kbd:[s-p C]) completes over the files you have From 4436d4a80743c7ebb8157fb8411cb74072c939c5 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 29 Sep 2026 11:50:57 +0300 Subject: [PATCH 3/3] Add CHANGELOG entry for the dispatch menu fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 584f762bd..33df6becf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Bugs fixed +- [#2195](https://github.com/bbatsov/projectile/pull/2195): `projectile-dispatch` can be extended with `transient-insert-suffix`/`transient-append-suffix` as soon as `transient` is loaded, instead of failing with "Not a transient prefix command" until the menu had been opened once ([#2194](https://github.com/bbatsov/projectile/discussions/2194)). - [#2191](https://github.com/bbatsov/projectile/pull/2191): The test suite passes under native compilation with an empty eln cache, as in distro package builds, instead of failing two dirconfig specs with `native-lisp-load-failed` ([#2100](https://github.com/bbatsov/projectile/issues/2100)). - [#2190](https://github.com/bbatsov/projectile/pull/2190): Alien indexing no longer hands git exclude pathspecs to an `fd` whose `projectile-git-fd-args` lacks `--strip-cwd-prefix`, which made it fail with `No valid search paths given` ([#2187](https://github.com/bbatsov/projectile/issues/2187)). - [#2183](https://github.com/bbatsov/projectile/pull/2183): `projectile-run-test-at-point` now builds a usable file path when the project is reached through a symlink (anything under `/tmp` on macOS, or a symlinked `~/src`), instead of one that climbs out of the project and gets rejected by the test runner.