Context Menu in Magit Status Buffers

null

I’m a very happy user of Magit, the amazing git frontend for Emacs. Today I noticed again that I miss one thing from GitUp, a GUI frontend for macOS, that I use when I’m selecting changes for a commit, discarding experimental file and line changes here and there in the process:

Mouse interactivity.

Yeeeees I know, it’s a sin, you’re supposed to glue your wrists to the desk in front of your keyboard and never let go – but for casual, one-handed interaction (the other hand being occupied holding a cup of tea), the mouse is quite good.

I do have context-menu-mode active, but it doesn’t do anything in Magit.

Until today, that is!

Let me introduce to you my very own hacked-together implementation of context menu items in Magit buffers. You can see the code as a Gist on GitHub, and I’ll walk you through everything below.

What it looks like

Check this out and note that the menu explains the key bindings for the stage/unstage/discard functions, too:

The key bindings are shown in the context menu, at least on macOS. Here, you see the context menu for a region when the file is staged.

Context menu setup

You can interact with a lot of things in Magit. Thankfully, Magit sports a lot of useful functions to inspect the thing-at-point or region.

If you peek at magit-stage, magit-unstage, or magit-discard in the code base, you’ll notice how pcase pattern matching is used to decide what to do in the current context.

That’s super useful, because we’re introduced to magit-diff-type and magit-diff-scope, functions that will power the implementation. The magit-diff-* namespace is correct here, because the three actions I want to implement do not make sense in the git log, only in the status view, where we’re (also) dealing with diffs.

Label each menu item with the scope

To prevent confusion in the menu items, I want proper indicative menu item titles: “Discard Hunk”, not just “Discard” and then guess what’ll happen. To achieve that, we translate the scope to a string:

(defun magit-current-section-menu-label ()
  "Menu item label for section at point."
  (pcase (magit-diff-scope)
    ('hunk     "Hunk")
    ('hunks    "Hunks")
    ('file     "File")
    ('files    "Files")
    ('module   "Module")
    ('region   "Region")))

Then we’ll use this to assemble the label, like (concat "Stage " (magit-current-section-menu-label)).

That happens in the magit-context-menu function I will show you below.

The following is the result of copy and pasting from functions like context-menu-ffap, if you wonder how I came up with that.

I’m no expert, this is just how I got this working, so please leave a comment or email me if you want to share more details!

The context menu stuff is a bit weird. When context-menu-mode is active, the variable context-menu-functions is used to assemble the menu. This variable holds a list of function symbols. Each function in that list is called in order to populate the menu. The function receives 2 parameters: the context menu MENU and the mouse CLICK event. Each function modifies the menu to insert new items if needed, then returns the menu again.

Let me illustrate with this amazing function that is bound to super+d:

(defun foo-bar-do-thing-at-point ()
  (interactive)
  (message "Amazing!"))
(global-set-key (kbd "s-d") #'foo-bar-do-thing-at-point)

Here’s a template to modify the context menu and use this interactive function:

(defun context-menu-foo-bar (menu click)
  "Populates context menu MENU for foo-bar at CLICK."
  (save-excursion
    ;; Set point to where the click happened to use thing-at-point functions.
    (mouse-set-point click)

    ;; Add a separator for a named "key binding":
    (define-key-after menu [foo-bar-separator] menu-bar-separator)

    ;; Add menu item with unique name in brackets:
    (define-key-after menu [foo-bar-unique-name]
      '(menu-item "Menu item label"
                 foo-bar-do-thing-at-point
                 :help "Do something at point")))
  ;; Return the (modified) menu!
  menu)

Since there’s a key binding, the context menu will display "s-d" – at least on macOS, where shortcuts of menu items usually are displayed right-aligned in a menu.

So each menu item is added to the menu as a key binding. That’s the weirdest part to me. It kind of makes sense once you realize that the context menu in the terminal is displayed as a list of shortcuts, and only in GUI mode you get, well, a GUI. But still, odd.

My inspiration for the menu producer, context-menu-ffap, uses define-key. But when I used define-key, it’d always add the items in reverse order (i.e. as a stack) on top of the menu.

That’s what define-key-after is for. I learned about this from Philip K’s context-menu-highlight-symbol. That preserves the order of menu items according to the order in the variable that references all menu-item-producing functions, context-menu-functions.

Bottom line here is: Without define-key-after, the order would’ve been broken.

The actual magit-context-menu function

Now that we are familiar with the basic framework, here’s the “factory” that injects the menu items:

(defun magit-context-menu (menu click)
  "Populate MENU with commands that perform actions on magit sections at point."
  (save-excursion
    (mouse-set-point click)
    ;; Ignore log and commit buffers, only apply to status.
    (when (magit-section-match 'status magit-root-section)
      ;; Only apply to supported sub-sections (hunks, files, ...)
      (when-let ((section-label (magit-current-section-menu-label)))
        ;; Always add separator when we get a supported section label.
        (define-key-after menu [magit-separator] menu-bar-separator)

        (when (eq 'staged (magit-diff-type))
          (define-key-after menu [magit-unstage-thing-at-mouse]
            `(menu-item (concat "Unstage " ,section-label)
                        magit-unstag
                        :help "Unstage thing at point")))
        (when (member (magit-diff-type) '(unstaged untracked))
          (define-key-after menu [magit-stage-thing-at-mouse]
            `(menu-item (concat "Stage " ,section-label)
                        magit-stage
                        :help "Stage thing at point")))

        (when (magit-section-match '(magit-module-section
                                     magit-file-section
                                     magit-hunk-section))
          (define-key-after menu [magit-discard-thing-at-mouse]
            `(menu-item (concat "Discard " ,section-label)
                        magit-discard
                        :help "Discard thing at point"))))))
  menu)

We’ve briefly covered magit-diff-scope above to determine, well, the scope: e.g. a file, a hunk, or a region. Here, we’re using magit-diff-type to determine the type, e.g. is the click happening in the untracked files section, or the unstaged or staged section. (All the others we ignore.)

And to figure out if something can be discarded, we use a function similar to magit-diff-scope, but which I discovered earlier and that can also be used to test for values in a list similar to the member function.

To assemble the menu labels dynamically, we can’t use a plain '(menu-item ...) list but require the backquoted form to reference the variable section-label using the comma prefix instead of inserting a symbol called “section-label” at that point. If you don’t know how that works: me neither. I just experiment until I don’t get runtime errors anymore even though I’ve read a great macro tutorial where you need that a lot.

You will notice that I use (magit-section-match 'status magit-root-section) in the beginning and then (magit-section-match '(magit-module-section ...)) later: when you omit the 2nd parameter, it applies to the current section at point automatically.

When I peek at the status line for this file via (magit-section-ident (magit-current-section)), I get this, for example:

((file . #("content/posts/2022/03/magit-context-menu.md" 0 43 (fontified nil)))
 (staged)
 (status))

So I’m in a status buffer where this file is already staged.

The raw output of (magit-current-section) is much more verbose. I leave interpreting that as an exercise to the reader.

Use the new context menu function

My context-menu-functions is set like this in my init.el:

(setq context-menu-functions
    '(context-menu-ffap
      magit-context-menu
      context-menu-highlight-symbol  ;; See link to Philip K. above
      occur-context-menu
      context-menu-region
      context-menu-undo
      context-menu-dictionary))

You can also use the customize interface or add-to-list or whatever to get the new magit-context-menu function in there. I’m just overwriting the whole variable globally.

Again, if you want the short version, check out the Gist.