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:
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:
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.
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, 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.