Emacs-DIYer: A Built-in dired-collapse Replacement

Apr 15, 2026 : 1633 words
emacs linux 🏷️ emacs 2026

I have been slowly chipping away at my Emacs-DIYer project, which is basically my ongoing experiment in rebuilding popular Emacs packages using only what ships with Emacs itself, no external dependencies, no MELPA, just the built-in pieces bolted together in a literate README.org that tangles to init.el. The latest addition is a DIY version of dired-collapse from the dired-hacks family, which is one of those packages I did not realise I leaned on until I started browsing a deeply-nested Java project and felt the absence immediately.

If you have ever opened a dired buffer on something like a Maven project, or node_modules, or a freshly generated resource bundle, you will know the pain, src/ contains a single main/ which contains a single java/ which contains a single com/ which contains a single example/, and you are pressing RET four times just to get to anything interesting. The dired-collapse minor mode from dired-hacks solves this beautifully, it squashes that whole single-child chain into one dired line so src/main/java/com/example/ shows up as a single row and one RET drops you straight into the deepest directory.

So, as always with the Emacs-DIYer project, I wondered, can I implement this in a few elisp defuns?

Right, so what is the plan?, dired already draws a nice listing with permissions, sizes, dates and filenames, all I really need to do is walk each line, look at the directory, figure out the deepest single-child descendant, and then rewrite the filename column in place with the collapsed path. The trick, and this is the bit that took me a minute to convince myself of, is that dired uses a dired-filename text property to know where the filename lives on the line, and dired-get-filename happily accepts relative paths containing slashes. So if I can rewrite the text and reapply the property, everything else, RET, marking, copying, should just work without me having to touch the rest of dired at all!

First function, my/dired-collapse--deepest, which just walks the directory chain as long as each directory contains exactly one accessible child directory. I added a 100-iteration guard so a pathological symlink cycle cannot wedge the whole thing, which, you know, future me might thank present me for:

(defun my/dired-collapse--deepest (dir)
  "Return the deepest single-child descendant directory of DIR.
Walks the directory chain as long as each directory contains exactly
one entry which is itself an accessible directory.  Stops after 100
iterations to guard against symlink cycles."
  (let ((current dir)
        (depth 0))
    (catch 'done
      (while (< depth 100)
        (let ((entries (condition-case nil
                           (directory-files current t
                                            directory-files-no-dot-files-regexp
                                            t)
                         (error nil))))
          (if (and entries
                   (null (cdr entries))
                   (file-directory-p (car entries))
                   (file-accessible-directory-p (car entries)))
              (setq current (car entries)
                    depth (1+ depth))
            (throw 'done current)))))
    current))

directory-files-no-dot-files-regexp is one of those lovely little built-in constants I keep forgetting exists, it filters out . and .. but keeps dotfiles, which is exactly what you want if you are deciding whether a directory is truly single-child.

Second function does the actual buffer surgery, my/dired-collapse iterates each dired line, grabs the filename with dired-get-filename, asks the walker how deep the chain goes, and if there is anything to collapse it replaces the displayed filename with the collapsed relative path:

(defun my/dired-collapse ()
  "Collapse single-child directory chains in the current dired buffer.
A DIY replacement for `dired-collapse-mode' from the dired-hacks
package.  Rewrites the filename portion of each line in place and
reapplies the `dired-filename' text property so that standard dired
navigation still resolves to the deepest directory."
  (when (derived-mode-p 'dired-mode)
    (let ((inhibit-read-only t))
      (save-excursion
        (goto-char (point-min))
        (while (not (eobp))
          (condition-case nil
              (let ((file (dired-get-filename nil t)))
                (when (and file
                           (file-directory-p file)
                           (not (member (file-name-nondirectory
                                         (directory-file-name file))
                                        '("." "..")))
                           (file-accessible-directory-p file))
                  (let ((deepest (my/dired-collapse--deepest file)))
                    (unless (string= deepest file)
                      (when (dired-move-to-filename)
                        (let* ((start (point))
                               (end (dired-move-to-end-of-filename t))
                               (displayed (buffer-substring-no-properties
                                           start end))
                               (suffix (substring deepest
                                                  (1+ (length file))))
                               (new (concat displayed "/" suffix)))
                          (delete-region start end)
                          (goto-char start)
                          (insert (propertize new
                                              'face 'dired-directory
                                              'mouse-face 'highlight
                                              'dired-filename t))))))))
            (error nil))
          (forward-line))))))

The key bit is the propertize call at the end, the new filename text has to carry dired-filename t so that dired-get-filename picks it up, and dired-directory on face keeps the collapsed entry looking the same as a normal directory line. Because dired-get-filename will happily glue a relative path like main/java/com/example onto the dired buffer’s directory, pressing RET on a collapsed line takes you straight to src/main/java/com/example with no extra work from me.

A while back I added a little unicode icon overlay thing to dired (my/dired-add-icons, which puts a little symbol in front of each filename via a zero-length overlay), and I did not want the collapse to fight with it. The icons hook into dired-after-readin-hook as well, so I just gave collapse a negative depth when attaching its hook:

(add-hook 'dired-after-readin-hook #'my/dired-collapse -50)

Lower depth runs earlier, so collapse rewrites the line first, then the icon overlay attaches to the final collapsed filename position. Without this, the icons would happily sit in front of a stub directory that was about to be rewritten, which is, well, fine I suppose, but it felt tidier to have them anchor on the post-collapse text.

Before, a typical Maven project root might look something like this:

drwxr-xr-x 3 jdyer users 4096 Apr  9 08:12 ▶ src
drwxr-xr-x 2 jdyer users 4096 Apr  9 08:11 ▶ target
-rw-r--r-- 1 jdyer users  812 Apr  9 08:10 ◦ pom.xml

After collapse kicks in:

drwxr-xr-x 3 jdyer users 4096 Apr  9 08:12 ▶ src/main/java/com/example
drwxr-xr-x 2 jdyer users 4096 Apr  9 08:11 ▶ target
-rw-r--r-- 1 jdyer users  812 Apr  9 08:10 ◦ pom.xml

One RET and you are in com/example, which is where all the actual code lives anyway. Marking, copying, deleting, renaming, all of it still behaves because the dired-filename text property points at the real deepest path.

One thing that initially bit me, is navigating out of a collapsed chain. If I hit RET on a collapsed src/main/java/com/example line I land in the deepest directory, which is great, but then pressing my usual M-e to go back up was doing the wrong thing. M-e in my config has always been bound to dired-jump, and dired-jump called from inside a dired buffer does a “pop up a level” thing that ended up spawning a fresh dired for com/, bypassing the collapsed view entirely and leaving me staring at a directory I never wanted to see.

My first attempt at fixing this was to put some around-advice on dired-jump so that if an existing dired buffer already had a collapsed line covering the jump target, it would switch to that buffer and land on the collapsed line instead of splicing in a duplicate subdir. It worked, sort of, but dired-jump in general felt a bit janky inside dired, it does a lot of “refresh the buffer and try again” under the hood and the in-dired pop-up-a-level path was always the weak link. So I stepped back and split the two cases apart with a tiny dispatch wrapper:

(defun my/dired-jump-or-up ()
  "If in Dired, go up a directory; otherwise dired-jump for current buffer."
  (interactive)
  (if (derived-mode-p 'dired-mode)
      (dired-up-directory)
    (dired-jump)))

(global-set-key (kbd "M-e") #'my/dired-jump-or-up)

From a file buffer, dired-jump is still exactly the right thing as you want the directory the file is in of course. From inside a dired buffer, dired-up-directory is just a much cleaner operation, it walks up one real level, no refresh, no splicing, nothing weird. But on its own that would lose the collapsed round-trip, so I gave dired-up-directory its own bit of advice that looks for a collapsed-ancestor buffer before falling through to the default behaviour.

(defun my/dired-collapse--find-hit (target-dir)
  "Return (BUFFER . POS) of a dired buffer with a collapsed line covering TARGET-DIR."
  (let ((target (file-name-as-directory (expand-file-name target-dir)))
        hit)
    (dolist (buf (buffer-list))
      (unless hit
        (with-current-buffer buf
          (when (and (derived-mode-p 'dired-mode)
                     (stringp default-directory))
            (let ((buf-dir (file-name-as-directory
                            (expand-file-name default-directory))))
              (when (and (string-prefix-p buf-dir target)
                         (not (string= buf-dir target)))
                (save-excursion
                  (goto-char (point-min))
                  (catch 'found
                    (while (not (eobp))
                      (let ((line-file (ignore-errors
                                         (dired-get-filename nil t))))
                        (when (and line-file
                                   (file-directory-p line-file))
                          (let ((line-dir (file-name-as-directory
                                           (expand-file-name line-file))))
                            (when (string-prefix-p target line-dir)
                              (setq hit (cons buf (point)))
                              (throw 'found nil)))))
                      (forward-line))))))))))
    hit))

The dired-up-directory only fires when the literal parent is not already open as a dired buffer, which keeps normal upward navigation completely unchanged:

(defun my/dired-collapse--up-advice (orig-fn &optional other-window)
  "Around-advice for `dired-up-directory' restoring collapsed round-trip."
  (let* ((dir (and (derived-mode-p 'dired-mode)
                   (stringp default-directory)
                   (expand-file-name default-directory)))
         (up (and dir (file-name-directory (directory-file-name dir))))
         (parent-buf (and up (dired-find-buffer-nocreate up)))
         (hit (and dir (null parent-buf)
                   (my/dired-collapse--find-hit dir))))
    (if hit
        (let ((buf (car hit))
              (pos (cdr hit)))
          (if other-window
              (switch-to-buffer-other-window buf)
            (pop-to-buffer-same-window buf))
          (goto-char pos)
          (dired-move-to-filename))
      (funcall orig-fn other-window))))

(advice-add 'dired-up-directory :around #'my/dired-collapse--up-advice)

If /proj/src/main/java/com/ happens to already exist as a dired buffer, dired-up-directory does its usual thing and just goes there, the up-advice never fires. It is only when the literal parent is absent that the advice kicks in and hands you back to the collapsed ancestor, which I think is the right tradeoff, the advice never surprises you when you were going to get the standard behaviour anyway, it only steps in when the standard behaviour would throw away context you clearly still had in a buffer somewhere.

End result, RET into a collapsed chain drops me deep, M-e walks me back out to the original collapsed line, and none of it requires doing anything clever with dired-jump’s “pop up a level” path, which I am increasingly convinced I should not have been using in the first place.

Everything lives in the Emacs-DIYer project on GitHub, in the literate README.org. If you just want the snippet to drop into your own init file, the two functions and the add-hook line above are the whole thing, no require, no use-package, no MELPA, just built-in dired and a bit of buffer shenanigans, and thats it!, phew, and breathe!