Writing a Custom Helm Source in Emacs
While Helm is an invaluable part of the way I use Emacs, I find it not particularly obvious how to extend it, often having to resort to reading a lot of the code to figure out how things work. Previously, I wrote about making custom Helm actions in Emacs; this time, I wanted to make a custom helm source.
I’ve been using EShell a lot recently.
I’ll occasionally use a real terminal emulator for some things, but I’m living mainly in Emacs.
An issue that’s been arising though is that I end up with a bunch of eshell buffers in various directories and I end up trying to remember which was which (M-x eshell
– “oops, not this one”, C-u 2 M-x eshell
– “hm, not this one”, C-u 3 M-x eshell
– “there we are” (although I’m using evil
& have eshell
bound to F3, so it’s actually F3, 2 F3, etc)).
I’m trying out using one Emacs session with a bunch of Emacsclients/frames (instead of what I was previously doing, starting a bunch of separate Emacs processes for multiple projects), so I anticipate this problem getting much worse.
To resolve this, I decided to write my own Helm source, that would let me switch between eshells by their current working directory or create a new one at a given location. While I was able to put together something that almost gave me what I wanted from the instructions on the Helm wiki, there were a few more subtle things that I had to read the code for some of the built-in sources to figure out.
The first thing I actually figured out by accident: When generating the list of candidates for a synchronous source, one can return a list of conses, where the car is what will be displayed and the cdr is the actual candidate. This made it easy to generate the list existing of eshell buffers to switch between as follows:
(cl-loop for buf in (buffer-list) when (string-prefix-p "*eshell*" (buffer-name buf)) collect (cons (buffer-local-value 'default-directory buf) buf))
Just this is enough to put together a simple helm source for switching between existing eshell buffers, like so:
(defun cogent/eshell-helm-v1 () "Switch between eshell buffers using helm" (interactive) (helm :sources (helm-build-sync-source "eshell" :candidates (lambda () (cl-loop for buf in (buffer-list) when (string-prefix-p "*eshell*" (buffer-name buf)) collect (cons (buffer-local-value 'default-directory buf) buf)) :action (list (cons "Switch to eshell" #'switch-to-buffer))))) :buffer "*helm eshell*" :prompt "eshell in: "))
That’s fine, but I also want to be able to create a new eshell buffer instead of switching to an existing one. Furthermore, I want creating that new eshell to default to the current directory, but also let me type in an arbitrary directory name.
My first approach was to change the function generating the candidate to look like this:
(let* ((eshells (cl-loop for buf in (buffer-list) when (string-prefix-p "*eshell*" (buffer-name buf)) collect (cons (buffer-local-value 'default-directory buf) buf))) (new-dir (if (string-blank-p helm-input) default-directory helm-input)) (new-eshell (cons (concat "[+]" new-dir) new-dir))) (cons new-eshell eshells))
and switching the action to look like this:
(list (cons "Switch to eshell" (lambda (candidate) (if (bufferp candidate) (switch-to-buffer candidate) (let ((default-directory candidate)) (eshell t))))))
So now the list of candidates shows a list of existing eshells by cwd, selecting any of which will switch to that eshell buffer, as well as a “new” eshell option, which starts as the current directory, but changes as I type.
However, when I first wrote this, I couldn’t get it to let me set the directory by typing – it turns out that by default, the candidates function is only run once, so what I did above just sets the “new” eshell to be in the current directory.
The solution to this, I eventually figured out, is to set the option :volatile t
on the source.
Putting it together, it now looks like this:
(defun cogent/eshell-helm-v2 () "Switch between or create eshell buffers using helm" (interactive) (helm :sources (helm-build-sync-source "eshell" :candidates (lambda () (let* ((eshells (cl-loop for buf in (buffer-list) when (string-prefix-p "*eshell*" (buffer-name buf)) collect (cons (buffer-local-value 'default-directory buf) buf))) (new-dir (if (string-blank-p helm-input) default-directory helm-input)) (new-eshell (cons (concat "[+]" new-dir) new-dir))) (cons new-eshell eshells))) :action (list (cons "Switch to eshell" (lambda (candidate) (if (bufferp candidate) (switch-to-buffer candidate) (let ((default-directory candidate)) (eshell t)))))) ;; make the candidates get re-generated on input, so one can ;; actually create an eshell in a new directory :volatile t) :buffer "*helm eshell*" :prompt "eshell in: "))
Things are now pretty good in terms of functionality, but there’s one other thing that I’m missing.
With, for example, helm-find-files
, the first candidate in the list is the exact thing you’re typing (even if it’s a nonexistant file), but the focus is on the second candidate by default.
I find this very convienent, because it lets me just type & hit enter for the common case where I’m jumping to an extant file, but if I want to create a new file I can just type the name I want, press ↑ once, then press enter.
With the way this eshell source is now, however, the default selection is on the “create new eshell” candidate, which is awkward for the common case of switching between eshells.
I could just put the “new eshell” option at the end of the list, but then it means I have to scroll all the way to the bottom to pick that option and who’s got time for that?
Instead, I read through the source of helm-find-files
to figure out how it does it’s trick.
It turns out to be pretty simple:
It defines a function to “move to the first real candidate”, installs it in the helm-after-update-hook
when launching, then removes the hook in the :cleanup
of the source.
The function is more complicated in the find-files version, but for these purposes, it can be very simple:
(defun cogent/eshell-helm-move-to-first-real-candidate () (let ((sel (helm-get-selection nil nil (helm-get-current-source)))) (unless (bufferp sel) (helm-next-line))))
Putting it all together and stealing one other trick from helm-find-files
(using propertize
to differentiate the “create new eshell” option), it looks like this:
(require 'helm) (require 'helm-lib) (require 'cl-lib) (defun cogent/eshell-helm--get-candidates () (let* ((eshells (cl-loop for buf in (buffer-list) when (string-prefix-p "*eshell*" (buffer-name buf)) collect (cons (pwd-replace-home (buffer-local-value 'default-directory buf)) buf))) (new-dir (if (string-blank-p helm-input) default-directory helm-input)) (new-eshell (cons (concat (propertize " " 'display (propertize "[+]" 'font-lock-face '(:background "#ff69c6" :foreground "#282a36"))) " " (pwd-replace-home new-dir)) new-dir))) (cons new-eshell eshells))) (defun cogent/eshell-helm-move-to-first-real-candidate () (let ((sel (helm-get-selection nil nil (helm-get-current-source)))) (unless (bufferp sel) (helm-next-line)))) ;;;###autoload (defun cogent/eshell-helm () "Switch between or create eshell buffers using helm" (interactive) (add-hook 'helm-after-update-hook #'cogent/eshell-helm-move-to-first-real-candidate) (helm :sources (helm-build-sync-source "eshell" :candidates #'cogent/eshell-helm--get-candidates :action (list (cons "Switch to eshell" (lambda (candidate) (if (bufferp candidate) (switch-to-buffer candidate) (let ((default-directory candidate)) (eshell t)))))) ;; make the candidates get re-generated on input, so one can ;; actually create an eshell in a new directory :volatile t :cleanup (lambda () (remove-hook 'helm-after-update-hook #'cogent/eshell-helm-move-to-first-real-candidate)) ) :buffer "*helm eshell*" :prompt "eshell in: "))
It took some fiddling around, but it was honestly pretty fun. In the process of doing this, I realized how much I appreciate all that Helm does, so I signed up for the Helm Patreon. I don’t have a ton of money to spare, but as I learn more about how much Helm can do, the more I appreciate the giant amount of work that’s gone into it and how much I would miss Helm if I lost it at this point.
If you read to the end of this, you presumably also use Helm and/or care about Emacs, so why not chip a few bucks in too?