Developing Helm actions

One of the central pieces of my recent return to Helm is helm-ls-git, which empowers helm-browse-project with everything I need to move through my Git projects smoothly. Combine that with helm-projects-history, and you can see why I don’t need Projectile any more. Well, sort of. It’s true that I’ve stopped using Projectile since Helm took over my Emacs configuration, but it’s also true that I had to code the “helmified” version of a couple of features I was heavily relying upon.

In Helm, you can leverage actions to extend the available utilities.

(defun mu--candidate-directory (candidate)
  "Find the directory containing CANDIDATE.
Default to `helm-current-directory' when none can be found."
  (if (fboundp 'helm-ls-git-root-dir)
      (let* ((dir (cond ((stringp candidate)
                         (file-name-directory candidate))
                        ((and (bufferp candidate)
                              (buffer-file-name candidate))
                         (file-name-directory (buffer-file-name candidate)))
                        (t default-directory)))
             (root-dir (helm-ls-git-root-dir dir)))
        (if root-dir

(defun mu-helm-project-dired ()
  "Open Dired buffers at project root from a Helm session."
  (dolist (cand (helm-marked-candidates))
    (dired (mu--candidate-directory cand))))

(defun mu-helm-project-shell ()
  "Open shell buffers at project root from a Helm session."
  (dolist (cand (helm-marked-candidates))
    (let* ((default-directory (mu--candidate-directory cand))
           (buffer (concat "*shell "
                            (directory-file-name default-directory))
      (mu-shell-open buffer))))

The actions are self-explanatory (see Passing the prefix argument around for mu-shell-open). I often need to jump to a Dired or a shell-mode buffer of a project different from the current one, so I want the possibility to use the current candidate in helm-projects-history as a starting point.

To be fair, mu--candidate-directory does a little more than what I need here. That’s because I use mu-helm-project-dired and mu-helm-project-shell in helm-mini and helm-browse-project too. The latter lists special buffers (e.g., *scratch*) as buffers of the current project. Since they do not live in any specific directory, default-directory is the obvious choice.

Now I have to plug the actions into helm-projects-history. Unfortunately, it doesn’t have a keymap to extend, so I need another solution.

(defvar mu-helm-projects-history-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map helm-map)
    (define-key map (kbd "C-c d") (helm-exit-and-run! (mu-helm-project-dired)))
    (define-key map (kbd "C-c s") (helm-exit-and-run! (mu-helm-project-shell)))
  "Keymap for `mu-helm-projects-history'.")

(defun mu-helm-projects-history (arg)
  "A `helm-projects-history' with custom actions."
  (interactive "P")
  (require 'helm-ls-git)
  (helm :sources
        (helm-build-sync-source "Project history"
          :candidates helm-browse-project-history
          :action (lambda (candidate)
                    (with-helm-default-directory candidate
                       (or arg helm-current-prefix-arg))))
          :keymap mu-helm-projects-history-map)
        :buffer "*helm browse project history*"))

mu-helm-projects-history adds only two things to the original helm-projects-history. I require helm-ls-git early because I need its capabilities inside my actions, and I use my own mu-helm-projects-history-map to make them available in the Helm buffer opened by this command.

Last but not least, helm-exit-and-run! is a helpful macro to avoid dry repetitions:

(defmacro helm-exit-and-run! (&rest body)
  "Define an action with BODY to be run after exiting Helm."
  (declare (doc-string 1))
  `(lambda ()
       (helm-exit-and-execute-action (lambda (_candidate) ,@body)))))

Note that in my actions I am ignoring the _candidate parameter on purpose. Since helm-marked-candidates grabs the current candidate when none of them is selected, I can use my actions on multiple candidates if I feel wild enough. Yeah, sometimes I can be crazy like that.