notes from /dev/null

by Charles Choi 최민수


Enhancing Dired Sorting With Transient

16 Jan 2024  Charles Choi

Out of the box, sorting a Dired buffer is rather underwhelming. In this post I describe how to improve that. But before we get into details, let’s see what the end-result looks like. For the keyboard-driven workflow, the following Transient menu is shown below.

Dired buffer with Transient sort menu.

Note that setting the time-format offers different choices which you can select via completion.

Dired buffer with Transient sort menu and time-style option completion.

The mouse-only driven workflow is shown below.

Dired buffer with "Sort By" context menu.

Both workflows work without surprise.

The Problem

Dired’s default behavior is to use the keybinding s (bound to dired-sort-toggle-or-edit) to toggle between alphabetical or date/time order. If you want different sorting you can use the prefix C-u before s which will offer a minibuffer prompt where you’ll then need to enter the command-line arguments for ls or whatever file listing program you’ve configured Dired to use. Even worse from a user experience point of view is that there are different variants of ls with different command-line arguments that accomplish the same thing or are specific only to that variant. Many of these arguments are arcane, so you’ll likely need to run man to recall them. Topping it off is a single type-in field for arguments, precluding any possible form validation to keep you out of trouble.

While there are other packages like Dired Plus or Dirvish that can offer an improved sorting UI over what is described above, given my recent enchantment with Transient, I wanted to see if I could use it to implement a richer Dired sorting experience without a lot of overhead/dependencies.

Caveats

Given the diversity of ls implementations, rather than supporting them all I’ve decided to focus on the one that I use: GNU ls from coreutils. This means if your system doesn’t have it, you’ll need to install it. On macOS, I use MacPorts to install coreutils. Homebrew users can go here. GNU/Linux users are all set. As I’m not so familiar with Windows nowadays, I suppose Windows Subsystem for Linux or Cygwin are the way to go now.

Another caveat is that for Dired to work with Tramp, you’ll need to have GNU ls installed on that remote system.

Requirements

Let’s capture some lightweight requirements to clarify goals:

  1. Menus must support the following sort criteria for both ascending and descending order:

    • Name of the file or directory (ASCII sort order)
    • Kind (extension) of the file
    • Date the file was last opened
    • Date the file was added (created)
    • Date the file was last modified
    • Date the file metadata was last changed
    • Version file name sort (numbered file names are sorted in cardinal fashion, e.g. a1, a2, …, a9, a10, …)
    • File size
  2. A keyboard-driven menu to sort a Dired buffer must be supported.

  3. A mouse-only-driven menu to sort a Dired buffer must be supported.

    • This workflow can be initiated from a context menu using context-menu-mode.
  4. This implementation must work using Emacs 29.1 or higher.

  5. The implementation will only work with GNU ls.

Implementation

The implementation design of the workflows described above is straight-forward:

  1. Build a private function that passes the command-line arguments to ls.
  2. Build two separate user interface functions (one for keyboard, the other for mouse) to collect the desired arguments and pass them to the private function described above.

With GNU ls, here’s a survey of the arguments we’ll use to support the requirements, and then some. Note that not all GNU ls arguments and their choice values are supported here. Motivated readers are welcome to modify this implementation to their taste.

Argument Description
-a, --all do not ignore entries starting with .
--group-directories-first group directories before files
-h, --human-readable with -l, print sizes like 1K 234M 2G etc.
-l use a long listing format
-r, --reverse reverse order while sorting
--sort=WORD sort by WORD instead of name (choices: size, time, version, extension)
--time=WORD select which timestamp used to display or sort (choices: access, status, modification, creation)
--time-style=TIME_STYLE time/date format (choices: full-iso, long-iso, iso, locale)
-S sort by size

Note is that there is a Dired variable dired-listing-switches which initializes the sort order when viewing a directory for the first time. To avoid dependency on dired-listing-switches, I've created a new customizable variable cc-dired-listing-switches as a list of strings which the functions below will refer to. For consistent behavior, it is recommended to initialize dired-listing-switches to correspond to the value of cc-dired-listing-switches.

Private Function Implementation

The private function cc/--dired-sort-by manages the above ls arguments by abstracting subsets of them into Elisp keywords that correspond to the different sort criteria. Note that in Elisp, “private” is a naming convention. Doing the heavy lifting is the call to dired-sort-other which passes the arguments to ls.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
(defun cc/--dired-sort-by (criteria &optional prefix-args)
  "Sort current Dired buffer according to CRITERIA and PREFIX-ARGS.

This function will invoke `dired-sort-other' with arguments built from
CRITERIA and PREFIX-ARGS.

CRITERIA is a keyword of which the following are supported:
  :name             :date-added             :version
  :kind             :date-metadata-changed  :size
  :date-last-opened :date-modified

PREFIX-ARGS is a list of GNU ls arguments. If nil, then it will use the value
of `cc-dired-listing-switches'. Otherwise this is typically populated by the
Transient menu `cc/dired-sort-by'.

This function requires GNU ls from coreutils installed.

See the man page `ls(1)' for details."
  (let ((arg-list (list "-l")))
    (if prefix-args
        (nconc arg-list prefix-args)
      (nconc arg-list cc-dired-listing-switches))
    (cond
     ((eq criteria :name)
      (message "Sorted by name"))

     ((eq criteria :kind)
      (message "Sorted by kind")
      (push "--sort=extension" arg-list))

     ((eq criteria :date-last-opened)
      (message "Sorted by date last opened")
      (push "--sort=time" arg-list)
      (push "--time=access" arg-list))

     ((eq criteria :date-added)
      (message "Sorted by date added")
      (push "--sort=time" arg-list)
      (push "--time=creation" arg-list))

     ((eq criteria :date-modified)
      (message "Sorted by date modified")
      (push "--sort=time" arg-list)
      (push "--time=modification" arg-list))

     ((eq criteria :date-metadata-changed)
      (message "Sorted by date metadata changed")
      (push "--sort=time" arg-list)
      (push "--time=status" arg-list))

     ((eq criteria :version)
      (message "Sorted by version")
      (push "--sort=version" arg-list))

     ((eq criteria :size)
      (message "Sorted by size")
      (push "-S" arg-list))

     (t
      (message "Default sorted by name")))

    (dired-sort-other (mapconcat 'identity arg-list " "))))

Keyboard-Driven Workflow

The design of the Transient menu presents two separate sections:

  • Arguments which are not directly related to the sorting criteria
  • Sort by criteria

The implementation of the Transient menu cc/dired-sort-by is shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
(transient-define-prefix cc/dired-sort-by ()
  "Transient menu to sort Dired buffer by different criteria.

This function requires GNU ls from coreutils installed."
  :value '("--human-readable"
           "--group-directories-first"
           "--time-style=long-iso")
                                     ; TODO: support cc-dired-listing-switches
  [["Arguments"
    ("-a" "all" "--all")
    ("g" "group directories first" "--group-directories-first")
    ("-r" "reverse" "--reverse")
    ("-h" "human readable" "--human-readable")
    ("t" "time style" "--time-style="
     :choices ("full-iso" "long-iso" "iso" "locale"))]

   ["Sort By"
    ("n"
     "Name"
     (lambda () (interactive)
       (cc/--dired-sort-by :name
                           (transient-args transient-current-command)))
     :transient nil)
    ("k"
     "Kind"
     (lambda () (interactive)
       (cc/--dired-sort-by :kind
                           (transient-args transient-current-command)))
     :transient nil)
    ("l"
     "Date Last Opened"
     (lambda () (interactive)
       (cc/--dired-sort-by :date-last-opened
                           (transient-args transient-current-command)))
     :transient nil)
    ("a"
     "Date Added"
     (lambda () (interactive)
       (cc/--dired-sort-by :date-added
                           (transient-args transient-current-command)))
     :transient nil)
    ("m"
     "Date Modified"
     (lambda () (interactive)
       (cc/--dired-sort-by :date-modified
                           (transient-args transient-current-command)))
     :transient nil)
    ("M"
     "Date Metadata Changed"
     (lambda () (interactive)
       (cc/--dired-sort-by :date-metadata-changed
                           (transient-args transient-current-command)))
     :transient nil)
    ("v"
     "Version"
     (lambda () (interactive)
       (cc/--dired-sort-by :version
                           (transient-args transient-current-command)))
     :transient nil)
    ("s"
     "Size"
     (lambda () (interactive)
       (cc/--dired-sort-by :size
                           (transient-args transient-current-command)))
     :transient nil)]])

Note that the :value keyword is hard-coded with arguments that initialize the menu UI. As I am new to the Transient API, I don’t know if this can be dynamically changed so that the next time the menu is invoked, it is initialized with a different set of values. Ideally I’d like for it to take the current value of a customizable variable I've defined ( cc-dired-listing-switches). For those readers who can provide guidance on this, I would greatly appreciate it.

Mouse-Only Driven Workflow

To support a mouse-only driven workflow, we can define a keymap using easy-menu-define. The keymap implementation cc/dired-sort-menu shown below does just that. This keymap can then be incorporated into a context menu. Note that arguments unrelated to sorting are initialized from the customizable variable cc-dired-listing-switches which is referred to in the implementation of cc/--dired-sort-by.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
(easy-menu-define cc/dired-sort-menu nil
  "Keymap for Dired sort by menu."
  '("Sort By"
    :visible (derived-mode-p 'dired-mode)
    ["Name"
     (lambda () (interactive) (cc/--dired-sort-by :name))
     :help "Sort by name"]
    ["Kind"
     (lambda () (interactive) (cc/--dired-sort-by :kind))
     :help "Sort by kind"]
    ["Date Last Opened"
     (lambda () (interactive) (cc/--dired-sort-by :date-last-opened))
     :help "Sort by date last opened"]
    ["Date Added"
     (lambda () (interactive) (cc/--dired-sort-by :date-added))
     :help "Sort by date added"]
    ["Date Modified"
     (lambda () (interactive) (cc/--dired-sort-by :date-modified))
     :help "Sort by date modified"]
    ["Date Metadata Changed"
     (lambda () (interactive) (cc/--dired-sort-by :date-metadata-changed))
     :help "Sort by date metadata changed"]
    ["Version"
     (lambda () (interactive) (cc/--dired-sort-by :version))
     :help "Sort by version"]
    ["Size"
     (lambda () (interactive) (cc/--dired-sort-by :size))
     :help "Sort by size"]
    "--"
    ("Reverse Sort By"
     ["Name"
      (lambda () (interactive) (cc/--dired-sort-by
                                :name
                                (append '("--reverse") cc-dired-listing-switches)))

      :help "Reverse sort by name"]
     ["Kind"
      (lambda () (interactive) (cc/--dired-sort-by
                                :kind
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by kind"]
     ["Date Last Opened"
      (lambda () (interactive) (cc/--dired-sort-by
                                :date-last-opened
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by date last opened"]
     ["Date Added"
      (lambda () (interactive) (cc/--dired-sort-by
                                :date-added
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by date added"]
     ["Date Modified"
      (lambda () (interactive) (cc/--dired-sort-by
                                :date-modified
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by date modified"]
     ["Date Metadata Changed"
      (lambda () (interactive) (cc/--dired-sort-by
                                :date-metadata-changed
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by date metadata changed"]
     ["Version"
      (lambda () (interactive) (cc/--dired-sort-by
                                :version
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by version"]
     ["Size"
      (lambda () (interactive) (cc/--dired-sort-by
                                :size
                                (append '("--reverse") cc-dired-listing-switches)))
      :help "Reverse sort by size"])))

The full source for the above can be found here and is verified for Emacs 29.1. Energized readers are encouraged to try it out!

Closing Thoughts

Given the constrained requirement to only support GNU ls, this exercise turned out to be a day’s worth of work resulting in two different but I think compelling workflows for sorting a Dired buffer. From this vantage point, I’ve got what I wanted and can easily declare victory and possibly never think about this problem again. That said, I imagine I’m not singular in thinking that the default sorting UX for Dired should be improved.

Dired is old. I would not be surprised if the design the Dired maintainers took for the default sorting UX was in response to the wide diversity of Unix ls implementations there were back in the 90’s, without any one being overwhelmingly predominant. It was too hard then to figure out a cross-platform solution, so a least common denominator tack was taken. But it’s 2024 now and the number of platforms to support now are vastly less. From Emacs Survey 2022, GNU/Linux, macOS, Windows, and BSD are the predominant platforms used by Emacs users. For future Emacs users, I think there is a compelling argument to revisit Dired sorting in core.

On thinking about next steps, perhaps I can try to publish this source on MELPA. The only reservation I have in doing that is that it only supports GNU ls and that I'm not inclined to QA other platforms due to time and resources. Perhaps GNU is enough and I don’t need to support others?

Anyways, if you’ve gotten to this point and want to offer feedback, please feel free to do so by replying to a thread linked to this post on my Mastodon account. And thanks for being here!

References

emacs

 

AboutMastodonInstagramGitHub

Feeds & TagsGet Captee for macOS

Powered by Pelican