Context Menu in Magit Status Buffers
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:
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:
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-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-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.
Menu item producing function
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,
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.
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,
Bottom line here is: Without
define-key-after, the order would’ve been broken.
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
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
context-menu-functions is set like this in my
(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.