From 13c1f99ef8ffe7f5d6faf6c6d18bcfcf4e039053 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Wed, 8 Jul 2026 11:29:30 +0300 Subject: [PATCH] Add other-tab command variants Helm 4.x gained tab-bar actions (`helm-ff-find-file-other-tab'); mirror the existing other-window/other-frame plumbing so finding files, finding directories and switching projects can each open in a new tab: - `helm-projectile-find-file-other-tab' (with the sync/streaming and Dired source variants, so it honors `helm-projectile-find-file-strategy' like the other find-file commands), - `helm-projectile-find-dir-other-tab' (via a new `helm-projectile-dired-find-dir-other-tab' action using `dired-other-tab'), - `helm-projectile-switch-project-other-tab' (which switches with `helm-projectile-find-file-other-tab' as the project action). "Open in other tab" is also added to the project and directory action lists and bound to `C-c t' inside those Helm sessions. Projectile has no `projectile-*-other-tab' commands to remap onto, so these are provided as autoloaded commands (and in-Helm actions) rather than bound onto `projectile-command-map'. --- CHANGELOG.md | 4 ++ README.md | 8 +++ helm-projectile.el | 100 ++++++++++++++++++++++++++++++++++- test/helm-projectile-test.el | 40 ++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e2bf4..237faf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## master (unreleased) +### New features + +* Add other-tab variants (`helm-projectile-find-file-other-tab`, `helm-projectile-find-dir-other-tab`, `helm-projectile-switch-project-other-tab`), matching Helm 4.x's tab-bar support alongside the existing other-window and other-frame variants. "Open in other tab" is also available as an action (bound to `C-c t`) in the project and directory sources. + ### Changes * Require Emacs 28.1 and Projectile 3.1.0. Projectile 3.1.0 raised its own minimum to Emacs 28.1 (and pulls in `compat`), so helm-projectile can no longer be installed on Emacs 27. The CI matrix drops the 27.2 job accordingly. diff --git a/README.md b/README.md index e022eb4..eab722a 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,14 @@ so it doesn't fold in submodule files or drop deleted-but-unstaged ones. The `helm-projectile-find-file-streaming` command streams regardless of this setting. +Finding files, finding directories and switching projects each come in +`-other-window`, `-other-frame` and `-other-tab` variants +(e.g. `helm-projectile-find-file-other-tab`), matching Emacs' own +`C-x 4` / `C-x 5` / `C-x t` families. Inside a Helm session the same +targets are available as actions (and on `C-c o`, `C-c C-o` and `C-c t` +in the project and directory sources). The `-other-tab` commands need a +recent Helm and Emacs' `tab-bar-mode`. + If you want to use these commands, you have to activate it to replace the normal Projectile commands: diff --git a/helm-projectile.el b/helm-projectile.el index 6728ed1..4d4d13e 100644 --- a/helm-projectile.el +++ b/helm-projectile.el @@ -243,6 +243,12 @@ When `projectile-switch-project-action' is `projectile-find-file' a (projectile-switch-project-action #'helm-projectile-find-file-other-frame)) (projectile-switch-project-by-name project))) +(defun helm-projectile-switch-project-by-name-other-tab (project) + "Switch to PROJECT and find file in it in other tab." + (let ((projectile-completion-system 'helm) + (projectile-switch-project-action #'helm-projectile-find-file-other-tab)) + (projectile-switch-project-by-name project))) + (defvar helm-projectile-projects-map (let ((map (make-sparse-keymap))) (set-keymap-parent map helm-map) @@ -250,6 +256,7 @@ When `projectile-switch-project-action' is `projectile-find-file' a (kbd "C-d") #'dired (kbd "C-c o") #'helm-projectile-switch-project-by-name-other-window (kbd "C-c C-o") #'helm-projectile-switch-project-by-name-other-frame + (kbd "C-c t") #'helm-projectile-switch-project-by-name-other-tab (kbd "M-g") #'helm-projectile-vc (kbd "M-e") #'helm-projectile-switch-to-shell (kbd "C-s") #'helm-projectile-grep @@ -267,6 +274,7 @@ When `projectile-switch-project-action' is `projectile-find-file' a "Switch to project" #'helm-projectile-switch-project-by-name "Switch to project other window `C-c o'" #'helm-projectile-switch-project-by-name-other-window "Switch to project other frame `C-c C-o'" #'helm-projectile-switch-project-by-name-other-frame + "Switch to project other tab `C-c t'" #'helm-projectile-switch-project-by-name-other-tab "Open Dired in project's directory `C-d'" #'dired "Open project root in vc-dir or magit `M-g'" #'helm-projectile-vc "Switch to Eshell `M-e'" #'helm-projectile-switch-to-shell @@ -335,6 +343,16 @@ to be specific to `helm-projectile-projects-source'." helm-source-projectile-projects-actions '(helm-projectile-switch-project-by-name-other-frame . :make-first)))) +(defclass helm-projectile-projects-other-tab-source (helm-projectile-projects-source) + ()) + +(cl-defmethod helm-setup-user-source :after ((source helm-projectile-projects-other-tab-source)) + "Set `helm-projectile-switch-project-by-name-other-tab' as the first action." + (setf (slot-value source 'action) + (helm-projectile-hack-actions + helm-source-projectile-projects-actions + '(helm-projectile-switch-project-by-name-other-tab . :make-first)))) + (define-key helm-etags-map (kbd "C-c p f") (lambda () (interactive) @@ -670,6 +688,20 @@ Meant to be added to `helm-cleanup-hook', from which it removes (helm-make-source "Projectile files" 'helm-source-projectile-file-other-frame) "Helm source definition for Projectile files.") +(defclass helm-source-projectile-file-other-tab (helm-source-projectile-file) + ()) + +(cl-defmethod helm-setup-user-source ((source helm-source-projectile-file-other-tab)) + "Make `helm-ff-find-file-other-tab' the first action in SOURCE." + (setf (slot-value source 'action) + (helm-projectile-hack-actions + helm-projectile-file-actions + '(helm-ff-find-file-other-tab . :make-first)))) + +(defvar helm-source-projectile-files-other-tab-list + (helm-make-source "Projectile files" 'helm-source-projectile-file-other-tab) + "Helm source definition for Projectile files.") + (defvar helm-source-projectile-files-in-all-projects-list (helm-build-sync-source "Projectile files in all Projects" :candidates (lambda () @@ -753,6 +785,21 @@ Meant to be added to `helm-cleanup-hook', from which it removes 'helm-source-projectile-dired-file-other-frame) "Helm source definition for Projectile delete files.") +(defclass helm-source-projectile-dired-file-other-tab (helm-source-projectile-dired-file) + ()) + +(cl-defmethod helm-setup-user-source ((source helm-source-projectile-dired-file-other-tab)) + "Make `helm-ff-find-file-other-tab' the first action in SOURCE." + (setf (slot-value source 'action) + (helm-projectile-hack-actions + helm-projectile-dired-file-actions + '(helm-ff-find-file-other-tab . :make-first)))) + +(defvar helm-source-projectile-dired-files-other-tab-list + (helm-make-source "Projectile files in current Dired buffer" + 'helm-source-projectile-dired-file-other-tab) + "Helm source definition for Projectile delete files.") + (defun helm-projectile-dired-find-dir (dir) "Jump to a selected directory DIR from `helm-projectile'." (dired (expand-file-name dir (projectile-project-root))) @@ -768,10 +815,16 @@ Meant to be added to `helm-cleanup-hook', from which it removes (dired-other-frame (expand-file-name dir (projectile-project-root))) (run-hooks 'projectile-find-dir-hook)) +(defun helm-projectile-dired-find-dir-other-tab (dir) + "Jump to a selected directory DIR from `helm-projectile' (in other tab)." + (dired-other-tab (expand-file-name dir (projectile-project-root))) + (run-hooks 'projectile-find-dir-hook)) + (defvar helm-source-projectile-directory-actions '(("Open Dired" . helm-projectile-dired-find-dir) ("Open Dired in other window `C-c o'" . helm-projectile-dired-find-dir-other-window) ("Open Dired in other frame `C-c C-o'" . helm-projectile-dired-find-dir-other-frame) + ("Open Dired in other tab `C-c t'" . helm-projectile-dired-find-dir-other-tab) ("Switch to Eshell `M-e'" . helm-projectile-switch-to-shell) ("Grep in projects `C-s'" . helm-projectile-grep) ("Create Dired buffer from files `C-c f'" . helm-projectile-dired-files-new-action) @@ -797,6 +850,7 @@ Meant to be added to `helm-cleanup-hook', from which it removes (kbd "") #'helm-next-source (kbd "C-c o") #'helm-projectile-dired-find-dir-other-window (kbd "C-c C-o") #'helm-projectile-dired-find-dir-other-frame + (kbd "C-c t") #'helm-projectile-dired-find-dir-other-tab (kbd "M-e") #'helm-projectile-switch-to-shell (kbd "C-c f") #'helm-projectile-dired-files-new-action (kbd "C-c a") #'helm-projectile-dired-files-add-action @@ -840,6 +894,20 @@ Meant to be added to `helm-cleanup-hook', from which it removes (helm-make-source "Projectile directories" 'helm-source-projectile-directory-other-frame) "Helm source for listing project directories.") +(defclass helm-source-projectile-directory-other-tab (helm-source-projectile-directory) + ()) + +(cl-defmethod helm-setup-user-source ((source helm-source-projectile-directory-other-tab)) + "Make `helm-projectile-dired-find-dir-other-tab' the first action in SOURCE." + (setf (slot-value source 'action) + (helm-projectile-hack-actions + helm-source-projectile-directory-actions + '(helm-projectile-dired-find-dir-other-tab . :make-first)))) + +(defvar helm-source-projectile-directories-other-tab-list + (helm-make-source "Projectile directories" 'helm-source-projectile-directory-other-tab) + "Helm source for listing project directories.") + (defvar helm-projectile-buffers-list-cache nil) (defclass helm-source-projectile-buffer (helm-source-sync helm-type-buffer) @@ -1015,6 +1083,13 @@ Defaults is `helm-projectile-truncate-lines'." 'helm-projectile-projects-other-frame-source) "Switch to project: " t) +;;;###autoload(autoload 'helm-projectile-switch-project-other-tab "helm-projectile" nil t) +(helm-projectile-command "switch-project-other-tab" + (helm-make-source + "Projectile projects" + 'helm-projectile-projects-other-tab-source) + "Switch to project: " t) + (defcustom helm-projectile-find-file-strategy 'sync "How `helm-projectile-find-file' lists a project's files. @@ -1030,8 +1105,8 @@ indexing command prints, so - like `helm-projectile-find-file-streaming' \(which see) - on Git projects it doesn't fold in submodule files or drop deleted-but-unstaged ones. -This governs `helm-projectile-find-file' and its other-window and -other-frame variants. The dwim commands and +This governs `helm-projectile-find-file' and its other-window, +other-frame and other-tab variants. The dwim commands and `helm-projectile-find-file-in-known-projects' always build the full file list, because they need it (to match the file at point, or to span every known project)." @@ -1072,6 +1147,14 @@ effect immediately." 'helm-source-projectile-files-other-frame-list) "Find file (other frame): ") +;;;###autoload(autoload 'helm-projectile-find-file-other-tab "helm-projectile" nil t) +(helm-projectile-command "find-file-other-tab" + (helm-projectile--file-sources + 'helm-source-projectile-files-streaming-other-tab + 'helm-source-projectile-dired-files-other-tab-list + 'helm-source-projectile-files-other-tab-list) + "Find file (other tab): ") + ;;;###autoload(autoload 'helm-projectile-find-file-in-known-projects "helm-projectile" nil t) (helm-projectile-command "find-file-in-known-projects" 'helm-source-projectile-files-in-all-projects-list "Find file in projects: " t) @@ -1093,6 +1176,12 @@ effect immediately." helm-source-projectile-directories-other-frame-list) "Find dir (other frame): ") +;;;###autoload(autoload 'helm-projectile-find-dir-other-tab "helm-projectile" nil t) +(helm-projectile-command "find-dir-other-tab" + '(helm-source-projectile-dired-files-other-tab-list + helm-source-projectile-directories-other-tab-list) + "Find dir (other tab): ") + ;;;###autoload(autoload 'helm-projectile-recentf "helm-projectile" nil t) (helm-projectile-command "recentf" 'helm-source-projectile-recentf-list "Recently visited file: ") @@ -1199,6 +1288,13 @@ or drop deleted-but-unstaged files.") '(find-file-other-frame . :make-first))) "Like `helm-source-projectile-files-streaming', but opening in another frame.") +(defvar helm-source-projectile-files-streaming-other-tab + (helm-projectile--make-streaming-source + (helm-projectile-hack-actions + helm-projectile-file-actions + '(helm-ff-find-file-other-tab . :make-first))) + "Like `helm-source-projectile-files-streaming', but opening in another tab.") + ;;;###autoload(autoload 'helm-projectile-find-file-streaming "helm-projectile" nil t) (helm-projectile-command "find-file-streaming" 'helm-source-projectile-files-streaming diff --git a/test/helm-projectile-test.el b/test/helm-projectile-test.el index c7a34ef..dedd514 100644 --- a/test/helm-projectile-test.el +++ b/test/helm-projectile-test.el @@ -480,6 +480,46 @@ (when (get-buffer "hp-test-dired") (kill-buffer "hp-test-dired"))))))) +(describe "helm-projectile other-tab support" + ;; Helm 4.x gained tab-bar actions; helm-projectile exposes them as + ;; other-tab command/source variants alongside other-window/other-frame. + (it "defines the other-tab commands" + (expect (commandp 'helm-projectile-find-file-other-tab) :to-be-truthy) + (expect (commandp 'helm-projectile-find-dir-other-tab) :to-be-truthy) + (expect (commandp 'helm-projectile-switch-project-other-tab) :to-be-truthy)) + + (it "promotes the other-tab action as the default in each source" + (cl-flet ((first-action (src) (cdar (helm-get-attr 'action src)))) + (expect (first-action helm-source-projectile-files-other-tab-list) + :to-be 'helm-ff-find-file-other-tab) + (expect (first-action helm-source-projectile-files-streaming-other-tab) + :to-be 'helm-ff-find-file-other-tab) + (expect (first-action helm-source-projectile-directories-other-tab-list) + :to-be 'helm-projectile-dired-find-dir-other-tab))) + + (it "installs the other-tab finder as the project switch action" + (let (captured) + (cl-letf (((symbol-function 'projectile-switch-project-by-name) + (lambda (&rest _) (setq captured projectile-switch-project-action)))) + (helm-projectile-switch-project-by-name-other-tab "/proj/")) + (expect captured :to-be 'helm-projectile-find-file-other-tab))) + + (it "dispatches find-file-other-tab sync/streaming like the other variants" + (spy-on 'helm) + (spy-on 'projectile-project-p :and-return-value t) + (spy-on 'projectile-maybe-invalidate-cache) + (spy-on 'projectile-project-name :and-return-value "demo") + (spy-on 'projectile-prepend-project-name :and-call-fake #'identity) + (helm-projectile-find-file-other-tab) + (expect (helm-projectile-test--sources) + :to-equal '(helm-source-projectile-dired-files-other-tab-list + helm-source-projectile-files-other-tab-list)) + (spy-calls-reset 'helm) + (let ((helm-projectile-find-file-strategy 'streaming)) + (helm-projectile-find-file-other-tab)) + (expect (helm-projectile-test--sources) + :to-be 'helm-source-projectile-files-streaming-other-tab))) + (describe "user-facing error conditions" ;; Normal situations (no project, no other file, ...) should signal ;; `user-error', not `error', so they don't trip the debugger when a user