Making Emacs tabs work like in Atom
emacs emacs-lisp ~5 minutes read

Another little piece from my Emacs config that I’ve decided to turn into a small post, following up on previous one. This time, we’re going to make tabs work as in most graphical editors.

Tabs were added with global-tab-line-mode in Emacs 27, and are pretty simple tabs, that are being displayed on the top of a window, and by default their semantics are not very useful in my opinion. The main problem is that you get only two policies of how closing of a tab will behave. It is either bury-buffer, which simply hides the tab, and moves the buffer to the end of buffer list, and kill-buffer which always kills the buffer, and hides the tab, and you can’t really combine those behaviors by default.

This is not very useful, because you can have many windows, with same buffers in each one, and when you want to close the window you can’t do that by closing last tab. For example, in Atom, if we have such configuration:

If we close file-B tab we will be left with one window:

This is useful, and works the same way in VSCode or Sublime Text. Let’s try the same thing in emacs -q. First we create two windows, with tab in each:

Then we press little close button on file-b tab:

And it replaces it with file-A tab. This happens because when we click on the close button it calls bury-buffer, so the tab goes away, new buffer is displayed, and we’re getting new tab for it. This is not great. I think when we close last tab in the window, the window should be killed as in Atom1.

The other thing is this. In Atom we can have two windows with the same buffers opened like this:

And if you close one of the tabs the file will not be closed until all tabs are closed, but window will be:

In Emacs, however, we can’t close tab via close button, because it will cycle buffers, so instead we can switch to second policy of killing buffer instead. But it will not work either, because it will not kill the window, and it will kill buffer in all windows. So how do we fix this?

First let’s establish some logical rules:

  • Buffer can exist without a tab, but tab can not exist without a buffer,
  • If tab was closed buffer should be killed only if there are no tabs for this buffer left,
  • If tab was closed and there are no more tabs in the window, window should be killed.

These three rules are basically how tabs work in modern editors. Now we need to implement this in Emacs Lisp. Since there’s no way of configuring which function is used for closing a tab, we’re going to redefine it locally in our configuration file:

(defun tab-line-close-tab (&optional e)
  "Close the selected tab.
If tab is presented in another window, close the tab by using `bury-buffer` function.
If tab is uniq to all existing windows, kill the buffer with `kill-buffer` function.
Lastly, if no tabs left in the window, it is deleted with `delete-window` function."
  (interactive "e")
  (let* ((posnp (event-start e))
         (window (posn-window posnp))
         (buffer (get-pos-property 1 'tab (car (posn-string posnp)))))
    (with-selected-window window
      (let ((tab-list (tab-line-tabs-window-buffers))
            (buffer-list (flatten-list
                          (seq-reduce (lambda (list window)
                                        (select-window window t)
                                        (cons (tab-line-tabs-window-buffers) list))
                                      (window-list) nil))))
        (select-window window)
        (if (> (seq-count (lambda (b) (eq b buffer)) buffer-list) 1)
              (if (eq buffer (current-buffer))
                (set-window-prev-buffers window (assq-delete-all buffer (window-prev-buffers)))
                (set-window-next-buffers window (delq buffer (window-next-buffers))))
              (unless (cdr tab-list)
                (ignore-errors (delete-window window))))
          (and (kill-buffer buffer)
               (unless (cdr tab-list)
                 (ignore-errors (delete-window window)))))))

Now let’s break this down. First we’re storing our posnp, window and buffer, then with our selected window we get list of tabs via call to tab-line-tabs-window-buffers, which effectively returns a list of buffers that have tabs associated with those. Then we get full list of buffers in all windows and store it to buffer-list variable, that we will need later.

Next we select the window, and count how many times we find buffer in our list of buffers from all windows. If it is more than 1, than we can we’re going to bury that buffer. If it is 1 we’re going to kill it, since it is the last tab of that buffer.

There’s another check in there, that checks if the tab is last in the list of tabs for that window, and if it is, e.g. if (cdr tab-list) returned nil we’ve just closed the last tab in the window, so we can kill it as well.

In the end we force mode-line update, because it updates tab line as well. So now, with this function (and the rest of my config) let’s try previous examples in Emacs:

Now we close file-B:

Buffer file-B and its respective window both were killed. Now we create windows with same buffer:

And if we close right tab, we see that window is deleted, but buffer is not affected:

And if we had tab file-A in first window and two tabs for file-A and file-B in second, closing file-A in second window will not kill file-A buffer, and will not kill the window, because tab for file-B is still there:

Closing file-A tab doesn’t kill file-A buffer or window:

Closing file-B tab kills its buffer and window:

This makes tabs in Emacs behave as in Atom or VSCode.

  1. Atom actually has a setting for it, so when you close last tab it can display an empty window, in which you can later drag and drop new tabs, but in Emacs there are no such thing as empty windows, so it makes sense to always kill the window if there are no tabs left. ↩︎