aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 01:39:58 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 01:39:58 -0400
commit6f659aa3b424c513979ba83ea4f15856f9491c01 (patch)
tree474828fbfd2617cbf6abe03055b881697d7bd8f5
parent67e767466ba63e6e673e4157b68d58948a493462 (diff)
downloaddotemacs-6f659aa3b424c513979ba83ea4f15856f9491c01.tar.gz
dotemacs-6f659aa3b424c513979ba83ea4f15856f9491c01.zip
feat(mail): annotate the attachment picker with MIME type and size
cj/mu4e-save-attachment-here now completes through an annotated table (category mu4e-attachment), so marginalia shows each attachment's MIME type and decoded size beside the filename. Unknown candidates annotate as nil. The existing picker test queries the function table via all-completions instead of car-mapping the old alist.
-rw-r--r--modules/mu4e-attachments.el22
-rw-r--r--tests/test-mu4e-attachments.el51
2 files changed, 70 insertions, 3 deletions
diff --git a/modules/mu4e-attachments.el b/modules/mu4e-attachments.el
index 4acdfd6a..6c2be6fb 100644
--- a/modules/mu4e-attachments.el
+++ b/modules/mu4e-attachments.el
@@ -15,6 +15,7 @@
;;; Code:
(require 'seq)
+(require 'system-lib) ;; cj/completion-table-annotated
(defvar mu4e-uniquify-save-file-name-function)
(defvar-local cj/mu4e-attachment-selection-directory nil
@@ -67,6 +68,19 @@ The result is an alist of display labels to MIME part plists."
(cons (cj/mu4e--attachment-label part duplicates) part))
parts)))
+(defun cj/mu4e--attachment-annotator (candidates)
+ "Return an annotation function over attachment CANDIDATES.
+CANDIDATES is the label->part alist from `cj/mu4e--attachment-candidates'.
+The annotation shows the part's MIME type and human-readable decoded
+size; an unknown candidate annotates as nil so marginalia shows nothing."
+ (lambda (cand)
+ (when-let* ((part (cdr (assoc cand candidates))))
+ (let ((mime (or (plist-get part :mime-type) ""))
+ (size (if-let* ((bytes (plist-get part :decoded-size-approx)))
+ (file-size-human-readable bytes)
+ "")))
+ (format " %-24s %s" mime size)))))
+
(defun cj/mu4e--attachment-default-directory (parts)
"Return a sensible default save directory for attachment PARTS."
(file-name-as-directory
@@ -126,7 +140,13 @@ The result is an alist of display labels to MIME part plists."
(user-error "No attachments for this message"))
(let* ((directory (cj/mu4e--read-attachment-directory parts))
(candidates (cj/mu4e--attachment-candidates parts))
- (choice (completing-read "Save attachment: " candidates nil t))
+ (choice (completing-read
+ "Save attachment: "
+ (cj/completion-table-annotated
+ 'mu4e-attachment
+ (cj/mu4e--attachment-annotator candidates)
+ candidates)
+ nil t))
(part (cdr (assoc choice candidates)))
(path (cj/mu4e--save-attachment-part part directory)))
(message "Saved attachment to %s" path)
diff --git a/tests/test-mu4e-attachments.el b/tests/test-mu4e-attachments.el
index 21336f75..0a780977 100644
--- a/tests/test-mu4e-attachments.el
+++ b/tests/test-mu4e-attachments.el
@@ -108,8 +108,10 @@ so this fails the same way whether or not mu4e's MIME support is loadable."
((symbol-function 'read-directory-name)
(lambda (&rest _) "/downloads/"))
((symbol-function 'completing-read)
- (lambda (_prompt candidates &rest _)
- (should (equal (mapcar #'car candidates)
+ ;; the collection is an annotated function table now, so
+ ;; query it instead of car-mapping an alist
+ (lambda (_prompt collection &rest _)
+ (should (equal (all-completions "" collection)
'("a.pdf" "b.pdf")))
"b.pdf"))
((symbol-function 'cj/mu4e--save-attachment-part)
@@ -252,5 +254,50 @@ so this fails the same way whether or not mu4e's MIME support is loadable."
:type 'user-error))
(kill-buffer buffer))))
+;; ------------------------- Picker Annotations --------------------------------
+
+(ert-deftest test-mu4e-attachments-annotator-shows-mime-and-size ()
+ "Normal: the annotator yields MIME type and human-readable size."
+ (let* ((part (plist-put (test-mu4e-attachments--part "invoice.pdf" 1)
+ :decoded-size-approx 2048))
+ (candidates (list (cons "invoice.pdf" part)))
+ (annotate (cj/mu4e--attachment-annotator candidates))
+ (suffix (funcall annotate "invoice.pdf")))
+ (should (stringp suffix))
+ (should (string-match-p "application/pdf" suffix))
+ (should (string-match-p "2k" suffix))))
+
+(ert-deftest test-mu4e-attachments-annotator-no-size ()
+ "Boundary: a part without a decoded size still annotates the MIME type."
+ (let* ((candidates (list (cons "invoice.pdf"
+ (test-mu4e-attachments--part "invoice.pdf" 1))))
+ (annotate (cj/mu4e--attachment-annotator candidates))
+ (suffix (funcall annotate "invoice.pdf")))
+ (should (stringp suffix))
+ (should (string-match-p "application/pdf" suffix))))
+
+(ert-deftest test-mu4e-attachments-annotator-unknown-candidate-nil ()
+ "Error: an unknown candidate annotates as nil (marginalia shows nothing)."
+ (let ((annotate (cj/mu4e--attachment-annotator nil)))
+ (should-not (funcall annotate "nope.txt"))))
+
+(ert-deftest test-mu4e-attachments-picker-uses-annotated-category ()
+ "Normal: the save-here picker's collection carries category + annotator."
+ (let ((captured-collection nil))
+ (cl-letf (((symbol-function 'cj/mu4e--attachment-parts)
+ (lambda (&rest _) (list (test-mu4e-attachments--part "a.pdf" 1))))
+ ((symbol-function 'cj/mu4e--read-attachment-directory)
+ (lambda (&rest _) "/tmp/x/"))
+ ((symbol-function 'cj/mu4e--save-attachment-part)
+ (lambda (&rest _) "/tmp/x/a.pdf"))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt collection &rest _)
+ (setq captured-collection collection)
+ "a.pdf")))
+ (cj/mu4e-save-attachment-here)
+ (let ((md (funcall captured-collection "" nil 'metadata)))
+ (should (eq (alist-get 'category (cdr md)) 'mu4e-attachment))
+ (should (functionp (alist-get 'annotation-function (cdr md))))))))
+
(provide 'test-mu4e-attachments)
;;; test-mu4e-attachments.el ends here