Commit ce98e164e822

Vincent Demeester <vincent@sbr.pm>
2025-03-20 19:41:12
tools/emacs: update some lisp code from upstream
Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 199bc73
Changed files (3)
tools/emacs/lisp/org-menu.el
@@ -1,6 +1,6 @@
-;;; org-menu.el --- A discoverable menu for org-mode using transient -*- lexical-binding: t; coding: utf-8 -*-
+;;; org-menu.el --- A discoverable menu for org-mode using transient -*- lexical-binding: t -*-
 ;;
-;; Copyright 2021 Jan Rehders
+;; Copyright 2021  Jan Rehders
 ;;
 ;; Author: Jan Rehders <nospam@sheijk.net>
 ;; Version: 0.1alpha
@@ -30,19 +30,22 @@
 ;;
 ;; (with-eval-after-load 'org
 ;;   (require 'org-menu) ;; not needed if installing by package manager
-;;   (define-key org-mode-map (kbd "C-c m") 'org-menu))
+;;   (define-key org-mode-map (kbd "C-c m") #'org-menu))
 ;;
 ;; The menu should be pretty self-explanatory.  It is context dependent and
 ;; offers different commands for headlines, tables, timestamps, etc.
 ;; The task menu provides entry points for task that work from anywhere.
-;;
+
 ;;; Code:
 
 (require 'org)
 (require 'transient)
+(require 'org-capture)
+(require 'org-timer)
+(require 'cl-lib)
 
 (defgroup org-menu nil
-  "Options for org-menu"
+  "Options for `org-menu'."
   :group 'org)
 
 (defcustom org-menu-use-q-for-quit t
@@ -50,13 +53,75 @@
 
 Use this if you prefer to be consistent with magit.  It will also
 change some other bindings to use Q instead of q."
-  :group 'org-menu
   :type 'boolean)
 
 (defcustom org-menu-global-toc-depth 10
   "The number of heading levels to show when displaying the global content."
-  :group 'org-menu
-  :type 'integer)
+  :type 'natnum)
+
+(defcustom org-menu-expand-snippet-function #'org-menu-expand-snippet-default
+  "The function used to expand a snippet.
+
+See `org-menu-expand-snippet-default' for a list of snippet ids
+which need to be supported.  `org-menu-expand-snippet-yasnippet'
+shows how to invoke snippets."
+  :type 'function)
+
+(defun org-menu-show-columns-view-options-p ()
+  "Return whether `org-columns' mode is active."
+  (bound-and-true-p org-columns-overlays))
+
+(defun org-menu-show-heading-options-p ()
+  "Whether to show commands operating on headings."
+  (unless (org-menu-show-columns-view-options-p)
+    (org-at-heading-p)))
+
+(defun org-menu-show-table-options-p ()
+  "Whether to show commands operating on tables."
+  (unless (org-menu-show-columns-view-options-p)
+    (org-at-table-p)))
+
+(defun org-menu-show-list-options-p ()
+  "Whether to show commands operating on lists."
+  (unless (org-menu-show-columns-view-options-p)
+    (org-at-item-p)))
+
+(defun org-menu-show-text-options-p ()
+  "Whether to show commands operating on text."
+  (not (or (org-menu-show-columns-view-options-p)
+           (org-at-heading-p)
+           (org-at-table-p)
+           (org-in-item-p)
+           (org-in-src-block-p))))
+
+(defun org-menu-show-src-options-p ()
+  "Whether to show commands operating on src blocks."
+  (unless (org-menu-show-columns-view-options-p)
+    (org-in-src-block-p)))
+
+(defun org-menu-show-link-options-p ()
+  "Whether to show commands operating on links.
+
+Conditions have been adapted from `org-insert-link'"
+  (unless (org-menu-show-columns-view-options-p)
+    (or
+     ;; Use variable from org-compat to support Emacs 26
+     (org-in-regexp (symbol-value 'org-bracket-link-regexp) 1)
+     (when (boundp 'org-link-angle-re)
+       (org-in-regexp org-link-angle-re))
+     (when (boundp 'org-link-plain-re)
+       (org-in-regexp org-link-plain-re)))))
+
+(defun org-menu-show-timestamp-options-p ()
+  "Whether to show commands operating on timestamps."
+  (unless (org-menu-show-columns-view-options-p)
+    (org-at-timestamp-p 'lax)))
+
+(defun org-menu-show-footnote-options-p ()
+  "Whether to show commands operating on footnotes."
+  (unless (org-menu-show-columns-view-options-p)
+    (or (org-footnote-at-definition-p)
+        (org-footnote-at-reference-p))))
 
 (defun org-menu-heading-navigate-items (check-for-heading &optional cycle-function)
   "Items to navigate headings.
@@ -66,7 +131,8 @@ true the items will only be added if on a heading.  `CYCLE-FUNCTION' is the
 function to be used to cycle visibility of current element."
   (setq cycle-function (or cycle-function #'org-cycle))
   `(["Navigate"
-     ,@(when check-for-heading '(:if org-at-heading-p))
+     :pad-keys t
+     ,@(and check-for-heading '(:if org-menu-show-heading-options-p))
      ("p" "prev" org-previous-visible-heading :transient t)
      ("n" "next" org-next-visible-heading :transient t)
      ("c" "cycle" ,cycle-function :transient t)
@@ -76,45 +142,126 @@ function to be used to cycle visibility of current element."
      ("M-w" "store link" org-store-link :transient t :if-not region-active-p)
      ("C-_" "undo" undo :transient t)]))
 
+(defun org-menu-expand-snippet-default (snippet-id)
+  "Insert a fixed text for each `SNIPPET-ID'."
+  (pcase snippet-id
+    ('block (insert "#+BEGIN:\n#+END:\n"))
+    ('option (insert "#+"))
+    ('subscript (insert "a_b"))
+    ('superscript (insert "a^b"))
+    ('plot
+     (insert
+      "#+plot: type:2d file:\"plot.svg\"
+| A |  B |
+|---+----|
+| 1 | 10 |
+| 2 |  8 |
+| 3 |  9 |
+
+#+attr_org: :width 400px
+[[file:plot.svg]]
+"))
+    (_ (error "Unknown snippet type %s" snippet-id))))
+
+(autoload 'yas-expand-snippet "yasnippet")
+(autoload 'yas-expand-from-trigger-key "yasnippet")
+
+(defun org-menu-expand-snippet-yasnippet (snippet-id)
+  "Expand a yasnippet for each `SNIPPET-ID'."
+  (unless (require 'yasnippet nil 'noerror)
+    (error "Yasnippet not installed, could not expand %s" snippet-id))
+  (pcase snippet-id
+    ('block
+     (insert "beg")
+     (yas-expand-from-trigger-key))
+    ('option
+     (insert "opt")
+     (yas-expand-from-trigger-key))
+    ('subscript
+     (yas-expand-snippet "${1:text}_{${2:sub}}"))
+    ('superscript
+     (yas-expand-snippet "${1:text}^{${2:super}}"))
+    ('plot
+     (yas-expand-snippet
+      "#+plot: type:${1:2d} file:\"${2:plot.svg}\"
+| A |  B |
+|---+----|
+| 1 | 10 |
+| 2 |  8 |
+| 3 |  9 |
+
+#+attr_org: :width ${3:400px}
+[[file:$2]]
+"))
+    (_
+     (error "Unknown snippet type %s" snippet-id))))
+
+;; If yasnippet gets loaded it will be used automatically
+(with-eval-after-load 'yasnippet
+  (unless (equal org-menu-expand-snippet-function #'org-menu-expand-snippet-default)
+    (setq org-menu-expand-snippet-function #'org-menu-expand-snippet-yasnippet)))
+
+(defun org-menu-expand-snippet (snippet-id)
+  "Will expand the given snippet named `SNIPPET-ID' with `ARGS'."
+  (funcall org-menu-expand-snippet-function snippet-id))
+
 (defun org-menu-show-headline-content ()
   "Will show the complete content of the current headline and it's children."
   (interactive)
   (save-excursion
     (outline-hide-subtree)
-    (org-show-children 4)
+    (with-no-warnings
+      (org-show-children 4))
     (org-goto-first-child)
     (org-reveal '(4))))
 
 ;;;###autoload (autoload 'org-menu-visibility "org-menu" nil t)
 (transient-define-prefix org-menu-visibility ()
-  "A menu to control visibility of org-mode items"
-  ["dummy"])
+  "A menu to control visibility of `org-mode' items."
+  ["Visibility"
+   ["Heading"
+    ("a" "all" org-show-subtree :if-not org-at-block-p :transient t)
+    ("a" "all" org-hide-block-toggle :if org-at-block-p :transient t)
+    ("c" "cycle" org-cycle :transient t)
+    ("t" "content" org-menu-show-headline-content :if-not org-at-block-p :transient t)
+    ("h" "hide" outline-hide-subtree :if-not org-at-block-p :transient t)
+    ("h" "hide" org-hide-block-toggle :if org-at-block-p :transient t)
+    ("r" "reveal" (lambda () (interactive) (org-reveal t)) :if-not org-at-block-p :transient t)]
+   ["Global"
+    :pad-keys t
+    ("C" "cycle global" org-global-cycle :transient t)
+    ("go" "overview" org-overview)
+    ("gt" "content" (lambda () (interactive) (org-content org-menu-global-toc-depth)))
+    ("ga" "all" org-show-all)
+    ("gd" "default" (lambda () (interactive) (org-set-startup-visibility)))]
+   ["Narrow"
+    :pad-keys t
+    ("nn" "toggle" org-toggle-narrow-to-subtree)
+    ("nb" "to block" org-narrow-to-block :if org-at-block-p)
+    ("ns" "to sub tree" org-narrow-to-subtree)
+    ("ne" "to element" org-narrow-to-element)
+    ("w" "widen" widen)]
+   ["Quit"
+    :if-non-nil org-menu-use-q-for-quit
+    ("q" "quit" transient-quit-all)]])
 
-(transient-insert-suffix 'org-menu-visibility (list 0)
-  `["Visibility"
-    ,@(org-menu-heading-navigate-items nil)
-    ["Visibility"
-     ("a" "all" org-show-subtree :if-not org-at-block-p :transient t)
-     ("a" "all" org-hide-block-toggle :if org-at-block-p :transient t)
-     ("t" "content" org-menu-show-headline-content :if-not org-at-block-p :transient t)
-     ("h" "hide" outline-hide-subtree :if-not org-at-block-p :transient t)
-     ("h" "hide" org-hide-block-toggle :if org-at-block-p :transient t)
-     ("r" "reveal" (lambda () (interactive) (org-reveal t)) :if-not org-at-block-p :transient t)]
-    ["Global"
-     ("C" "cycle global" org-global-cycle :transient t)
-     ("go" "overview" org-overview)
-     ("gt" "content" (lambda () (interactive) (org-content org-menu-global-toc-depth)))
-     ("ga" "all" org-show-all)
-     ("gd" "default" (lambda () (interactive) (org-set-startup-visibility)))]
-    ["Quit"
-     :if-non-nil org-menu-use-q-for-quit
-     ("q" "quit" transient-quit-all)]])
+(transient-define-prefix org-menu-visibility-columns ()
+  "A menu to control visibility of `org-mode' items in `org-columns' mode."
+  ["Visibility"
+   ["Columns view"
+    :if org-menu-show-columns-view-options-p
+    ("t" "content" org-columns-content :transient t)
+    ("o" "overview" org-overview :transient t)
+    ("g" "refresh" org-columns-redo :transient t)]
+   ["Quit"
+    :if-non-nil org-menu-use-q-for-quit
+    ("q" "quit" transient-quit-all)]])
 
 (defun org-menu-eval-src-items ()
   "Return the items to evaluate a source block."
   (list
    ["Source"
-    :if org-in-src-block-p
+    :if org-menu-show-src-options-p
     ("e" "run block" org-babel-execute-src-block)
     ("c" "check headers" org-babel-check-src-block)
     ("k" "clear results" org-babel-remove-result-one-or-many)
@@ -122,7 +269,7 @@ function to be used to cycle visibility of current element."
 
 ;;;###autoload (autoload 'org-menu-eval "org-menu" nil t)
 (transient-define-prefix org-menu-eval ()
-  "A menu to evaluate buffers, tables, etc. in org-mode"
+  "A menu to evaluate buffers, tables, etc. in `org-mode'."
   ["dummy"])
 
 (defun org-menu-run-gnuplot ()
@@ -146,6 +293,9 @@ function to be used to cycle visibility of current element."
      ("c" "update checkbox count" org-update-checkbox-count)]
     ["Plot"
      ("p" "gnuplot" org-menu-run-gnuplot)]
+    ["Export"
+     ("t" "tangle source files" org-babel-tangle)
+     ("x" "export" org-export-dispatch)]
     ["Quit"
      :if-non-nil org-menu-use-q-for-quit
      ("q" "quit" transient-quit-all)]])
@@ -155,18 +305,14 @@ function to be used to cycle visibility of current element."
   (interactive)
   (insert (format "#+begin_%s\n#+end_%s\n" str str)))
 
-(defun org-menu-expand-snippet (snippet)
-  "Will expand the given snippet named `SNIPPET'."
+(defun org-menu-insert-horizontal-rule ()
+  "Insert a horizontal rule."
   (interactive)
-  (if (require 'yasnippet nil 'noerror)
-      (progn
-        (insert snippet)
-        (yas-expand))
-    (message "error: yasnippet not installed, could not expand %s" snippet)))
+  (insert "-----"))
 
 ;;;###autoload (autoload 'org-menu-insert-blocks "org-menu" nil t)
 (transient-define-prefix org-menu-insert-blocks ()
-  "A menu to insert new blocks in org-mode"
+  "A menu to insert new blocks in `org-mode'."
   [["Insert block"
     ("s" "source" (lambda () (interactive) (org-menu-insert-block "src")))
     ("e" "example" (lambda () (interactive) (org-menu-insert-block "example")))
@@ -181,7 +327,7 @@ function to be used to cycle visibility of current element."
 
 ;;;###autoload (autoload 'org-menu-insert-heading "org-menu" nil t)
 (transient-define-prefix org-menu-insert-heading ()
-  "A menu to insert new headings in org-mode"
+  "A menu to insert new headings in `org-mode'."
   [["Heading"
     ("h" "heading" org-insert-heading)
     ("H" "heading (after)" org-insert-heading-after-current)
@@ -194,27 +340,26 @@ function to be used to cycle visibility of current element."
 
 ;;;###autoload (autoload 'org-menu-insert-template "org-menu" nil t)
 (transient-define-prefix org-menu-insert-template ()
-  "A menu to insert new templates in org-mode"
+  "A menu to insert new templates in `org-mode'."
   [["Templates"
     ("S" "structure template" org-insert-structure-template)
-    ("B" "yas blocks" (lambda () (interactive) (org-menu-expand-snippet "beg")))
-    ("O" "yas options" (lambda () (interactive) (org-menu-expand-snippet "opt")))]
+    ("B" "blocks" (lambda () (interactive) (org-menu-expand-snippet 'block)))
+    ("O" "options" (lambda () (interactive) (org-menu-expand-snippet 'option)))]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
 
 ;;;###autoload (autoload 'org-menu-insert-timestamp "org-menu" nil t)
 (transient-define-prefix org-menu-insert-timestamp ()
-  "A menu to insert timestamps in org-mode"
-  [["Timestamp"
-    ("." "active" org-time-stamp)
-    ("!" "inactive" org-time-stamp-inactive)]
-   ["Now"
-    ("n" "active" (lambda () (interactive) (org-insert-time-stamp (current-time) t)))
-    ("N" "inactive" (lambda () (interactive) (org-insert-time-stamp (current-time) t t)))]
-   ["Today"
-    ("t" "active" (lambda () (interactive) (org-insert-time-stamp (current-time) nil)))
-    ("T" "inactive" (lambda () (interactive) (org-insert-time-stamp (current-time) nil t)))]
+  "A menu to insert timestamps in Org Mode."
+  [["Active"
+    ("." "Time stamp" org-time-stamp)
+    ("t" "Today" (lambda () (interactive) (org-insert-time-stamp (current-time) nil nil)))
+    ("n" "Today + time" (lambda () (interactive) (org-insert-time-stamp (current-time) t nil)))]
+   ["Inactive"
+    ("!" "Time stamp (i)" org-time-stamp-inactive)
+    ("T" "Today (i)" (lambda () (interactive) (org-insert-time-stamp (current-time) nil t)))
+    ("N" "Today + time (i)" (lambda () (interactive) (org-insert-time-stamp (current-time) t t)))]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
@@ -232,7 +377,7 @@ function to be used to cycle visibility of current element."
 
 ;;;###autoload (autoload 'org-menu-insert-table "org-menu" nil t)
 (transient-define-prefix org-menu-insert-table ()
-  "A menu to insert table items in org-mode"
+  "A menu to insert table items in `org-mode'."
   [["Table"
     ("t" "table" org-table-create-or-convert-from-region :if-not org-at-table-p)
     ("i" "import" org-table-import :if-not org-at-table-p)]
@@ -240,8 +385,8 @@ function to be used to cycle visibility of current element."
     :if org-at-table-p
     ("r" "row above" org-table-insert-row :transient t)
     ("R" "row below" org-menu-table-insert-row-below :transient t)
-    ("c" "column right" org-table-insert-column :transient t)
-    ("C" "column left" org-menu-table-insert-column-left :transient t)
+    ("c" "column left" org-table-insert-column :transient t)
+    ("C" "column right" org-menu-table-insert-column-left :transient t)
     ("-" "horiz. line" org-table-insert-hline :transient t)]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
@@ -250,16 +395,12 @@ function to be used to cycle visibility of current element."
 (defun org-menu-insert-superscript ()
   "Insert a text with superscript."
   (interactive)
-  (if (require 'yasnippet nil 'noerror)
-      (yas-expand-snippet "${1:text}^{${2:super}}")
-    (insert "a^b")))
+  (org-menu-expand-snippet 'superscript))
 
 (defun org-menu-insert-subscript ()
   "Insert a text with subscript."
   (interactive)
-  (if (require 'yasnippet nil 'noerror)
-      (yas-expand-snippet "${1:text}_{${2:sub}}")
-    (insert "a_b")))
+  (org-menu-expand-snippet 'subscript))
 
 (defun org-menu-parse-formatting (format-char)
   "Will return the bounds of the format markup `FORMAT-CHAR'."
@@ -278,19 +419,19 @@ function to be used to cycle visibility of current element."
             (cons start end)))))))
 
 (defun org-menu-toggle-format (format-char)
-  "Will add/remove the given format wrapped in `FORMAT-CHAR' form the region (or point)."
+  "Will either remove `FORMAT-CHAR' or add it around region/point."
   (let ((range (org-menu-parse-formatting format-char))
         (format-string (format "%c" format-char)))
     (if (null range)
         (org-menu-insert-text format-string format-string t)
       (goto-char (cdr range))
-      (delete-backward-char 1)
+      (delete-char -1)
       (goto-char (car range))
       (delete-char 1))))
 
 ;;;###autoload (autoload 'org-menu-insert-list "org-menu" nil t)
 (transient-define-prefix org-menu-insert-list ()
-  "A menu to insert lists"
+  "A menu to insert lists."
   [["List"
     ("-" "item" (lambda () (interactive) (insert "- ")))
     ("+" "+" (lambda () (interactive) (insert "+ ")))
@@ -309,46 +450,76 @@ function to be used to cycle visibility of current element."
   "Insert a small example plot for `gnu-plot'."
   (interactive)
   (beginning-of-line 1)
-  (if (require 'yasnippet nil 'noerror)
-      (yas-expand-snippet
-       "#+plot: type:${1:2d} file:\"${2:plot.svg}\"
-| A |  B |
-|---+----|
-| 1 | 10 |
-| 2 |  8 |
-| 3 |  9 |
+  (org-menu-expand-snippet 'plot))
 
-#+attr_org: :width ${3:400px}
-[[file:$2]]
-")
-    (insert
-     "#+plot: type:2d file:\"plot.svg\"
-| A |  B |
-|---+----|
-| 1 | 10 |
-| 2 |  8 |
-| 3 |  9 |
+(defun org-menu-insert-option-line-smart (line)
+  "Insert `LINE'.  If inside a block move to right before it."
+  (beginning-of-line 1)
+  (insert line "\n"))
 
-#+attr_org: :width 400px
-[[file:plot.svg]]
-")))
+(defun org-menu-insert-name (name)
+  "Insert a #+NAME for the next element."
+  (interactive "MName? ")
+  (org-menu-insert-option-line-smart (format "#+NAME: %s" name)))
+
+(defun org-menu-insert-caption (caption)
+  "Insert a #+CAPTION for the next element."
+  (interactive "MCaption? ")
+  (org-menu-insert-option-line-smart (format "#+CAPTION: %s" caption)))
+
+(defun org-menu-insert-startup-setting (setting)
+  "Insert a buffer `SETTING'."
+  (interactive (list (completing-read "Startup setting? "
+                                (mapcar 'car org-startup-options))))
+  (org-menu-insert-option-line-smart (format "#+STARTUP: %s" setting)))
+
+(defun org-menu-insert-buffer-setting (setting)
+  "Insert a buffer `SETTING'."
+  (interactive (list (completing-read "Buffer setting? " org-options-keywords)))
+  (insert (format "#+%s " setting)))
+
+(defun org-menu-insert-footnote-definition (name definition)
+  "Insert a definition for a footnote.
+
+Named `NAME' using `DEFINITION'."
+  (interactive "MName? \nMDefinition? ")
+  (org-menu-insert-option-line-smart (format "[fn:%s] %s" name definition)))
+
+(defun org-menu-insert-footnote-inline (name definition)
+  "Insert a definition for an inline footnote.
+
+Named `NAME' with `DEFINITION'."
+  (interactive "MName? \nMDefinition? ")
+  (insert (format "[fn:%s: %s]" name definition)))
 
 ;;;###autoload (autoload 'org-menu-insert "org-menu" nil t)
 (transient-define-prefix org-menu-insert ()
-  "A menu to insert new items in org-mode"
-  [["Insert"
+  "A menu to insert new items in `org-mode'."
+  ["Insert"
+   ["Element"
     ("." "time" org-menu-insert-timestamp)
-    ("t" "table" org-menu-insert-table)
-    ("h" "heading" org-menu-insert-heading)
-    ("b" "block" org-menu-insert-blocks)
-    ("T" "templates" org-menu-insert-template)
     ("l" "link (new)" org-insert-link)
     ("L" "link (stored)" org-insert-last-stored-link :transient t)
+    ("T" "templates" org-menu-insert-template)]
+   ["Structure"
+    ("h" "heading" org-menu-insert-heading)
     ("-" "list" org-menu-insert-list)
+    ("H" "hor. rule" org-menu-insert-horizontal-rule)]
+   ["Block/table"
+    ("b" "block" org-menu-insert-blocks)
+    ("t" "table" org-menu-insert-table)
     ("p" "plot" org-menu-insert-plot)]
    ["Format"
     ("^" "superscript" org-menu-insert-superscript)
     ("_" "subscript" org-menu-insert-subscript)]
+   ["Footnotes"
+    ("fd" "define" org-menu-insert-footnote-definition)
+    ("fi" "inline" org-menu-insert-footnote-inline)]
+   ["Options"
+    ("n" "name" org-menu-insert-name)
+    ("c" "caption" org-menu-insert-caption)
+    ("s" "startup option" org-menu-insert-startup-setting)
+    ("o" "buffer option" org-menu-insert-buffer-setting)]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
@@ -358,13 +529,17 @@ function to be used to cycle visibility of current element."
   (interactive)
   (save-excursion (comment-line 1)))
 
+(defun org-menu-fix-timestamp ()
+  "Fix the timestamp at `(point)'."
+  (interactive)
+  (org-timestamp-change 0 'day))
+
 (defun org-menu-insert-text (left right &optional surround-whitespace)
   "Will insert left|right and put the curser at |.
 
 If region is active it will be surrounded by `LEFT' and `RIGHT' and
 the point will be at end of region.  Will add spaces before/after text if
 `SURROUND-WHITESPACE' is true and it's needed."
-
   (let ((start (point))
         (end (point)))
     (when (region-active-p)
@@ -372,13 +547,13 @@ the point will be at end of region.  Will add spaces before/after text if
             end (region-end))
       (deactivate-mark))
     (when (> start end)
-      ;; swap variables w/o importing cl-lib
-      (setq start (prog1 end (setq end start))))
+      (cl-psetq start end
+                end start))
 
     (goto-char start)
     (when (and surround-whitespace
                (not (bolp))
-               (not (looking-back " +")))
+               (not (looking-back " +" nil)))
       (insert " "))
     (insert left)
 
@@ -391,39 +566,37 @@ the point will be at end of region.  Will add spaces before/after text if
                  (not (looking-at " +")))
         (insert " ")))))
 
-(defun org-menu-in-time-p ()
-  "Return whether we're at a time stamp or similar.
-
-Adapted from `org-goto-calendar'"
-  (or (org-at-timestamp-p 'lax)
-      (org-match-line (concat ".*" org-ts-regexp))))
-
 ;;;###autoload (autoload 'org-menu-goto "org-menu" nil t)
 (transient-define-prefix org-menu-goto ()
-  "Menu to go to different places by name"
+  "Menu to go to different places by name."
   [["Go to"
     ("h" "heading" imenu)
     ("s" "source block" org-babel-goto-named-src-block)
     ("r" "result block" org-babel-goto-named-result)
-    ("." "calendar" org-goto-calendar :if org-menu-in-time-p)]
+    ("." "calendar" org-goto-calendar :if org-menu-show-timestamp-options-p)]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
 
-(defun org-menu-at-text-p ()
-  "Return whether point is at text."
-  (not (or (org-at-heading-p)
-           (org-at-table-p)
-           (org-in-item-p)
-           (org-in-src-block-p))))
+(defun org-menu-toggle-zwspace ()
+  "Will remove zero-width space before/after point or insert it if none found."
+  (interactive)
+  (let ((zww (string ?\N{ZERO WIDTH SPACE})))
+    (save-excursion
+      (skip-chars-backward zww)
+      (if (looking-at (rx (+ (literal zww))))
+      (replace-match "")
+    (insert zww)))))
 
 (defun org-menu-text-format-items (check-for-table)
   "Items to format text.
 
-Will add an ':if org-menu-at-text-p' criteria if `CHECK-FOR-TABLE' is true."
+Will add an ':if org-menu-show-text-options-p' criteria if
+`CHECK-FOR-TABLE' is true."
   (list
    `["Navigate"
-     ,@(when check-for-table '(:if org-menu-at-text-p))
+     ,@(when check-for-table '(:if org-menu-show-text-options-p))
+     :pad-keys t
      ("p" "up" previous-line :transient t)
      ("n" "down" next-line :transient t)
      ("b" "left" backward-word :transient t)
@@ -434,19 +607,21 @@ Will add an ':if org-menu-at-text-p' criteria if `CHECK-FOR-TABLE' is true."
      ("SPC" "mark" set-mark-command :transient t)
      ("C-x C-x" "exchange" exchange-point-and-mark :transient t)]
    `["Formatting"
-     ,@(when check-for-table '(:if org-menu-at-text-p))
+     ,@(when check-for-table '(:if org-menu-show-text-options-p))
+     :pad-keys t
      ("*" "Bold" (lambda nil (interactive) (org-menu-toggle-format ?*)) :transient t)
      ("/" "italic" (lambda nil (interactive) (org-menu-toggle-format ?/)) :transient t)
      ("_" "underline" (lambda nil (interactive) (org-menu-toggle-format ?_)) :transient t)
-     ("+" "strikethrough" (lambda nil (interactive) (org-menu-toggle-format ?+)) :transient t)]
+     ("+" "strikethrough" (lambda nil (interactive) (org-menu-toggle-format ?+)) :transient t)
+     ("S-SPC" "zero-width space" org-menu-toggle-zwspace :transient t)]
    `["Source"
-     ,@(when check-for-table '(:if org-menu-at-text-p))
+     ,@(when check-for-table '(:if org-menu-show-text-options-p))
      ("~" "code" (lambda nil (interactive) (org-menu-toggle-format ?~)) :transient t)
      ("=" "verbatim" (lambda nil (interactive) (org-menu-toggle-format ?=)) :transient t)]))
 
 ;;;###autoload (autoload 'org-menu-text-in-element "org-menu" nil t)
 (transient-define-prefix org-menu-text-in-element ()
-  "Add formatting for text inside other elements like lists and tables"
+  "Add formatting for text inside other elements like lists and tables."
   ["dummy"])
 
 (transient-insert-suffix 'org-menu-text-in-element (list 0)
@@ -457,7 +632,7 @@ Will add an ':if org-menu-at-text-p' criteria if `CHECK-FOR-TABLE' is true."
 
 ;;;###autoload (autoload 'org-menu-options "org-menu" nil t)
 (transient-define-prefix org-menu-options ()
-  "A menu to toggle options"
+  "A menu to toggle options."
   [["Display"
     ("l" "show links" org-toggle-link-display)
     ("i" "inline images" org-toggle-inline-images)
@@ -469,18 +644,6 @@ Will add an ':if org-menu-at-text-p' criteria if `CHECK-FOR-TABLE' is true."
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
 
-(defun org-menu-in-link ()
-  "Return whether we are inside a link.
-
-Conditions have been adapted from `org-insert-link'"
-  (or
-   ;; Use variable from org-compat to support Emacs 26
-   (org-in-regexp org-bracket-link-regexp 1)
-   (when (boundp 'org-link-angle-re)
-     (org-in-regexp org-link-angle-re))
-   (when (boundp 'org-link-plain-re)
-     (org-in-regexp org-link-plain-re))))
-
 (defun org-menu-toggle-has-checkbox ()
   "Toggle whether the current list item has a checkbox."
   (interactive)
@@ -505,8 +668,9 @@ Conditions have been adapted from `org-insert-link'"
 
 ;;;###autoload (autoload 'org-menu-clock "org-menu" nil t)
 (transient-define-prefix org-menu-clock ()
-  "Time management using org-modes clock"
+  "Time management using org-modes clock."
   [["Clock"
+    :pad-keys t
     ("<tab>" "in" org-clock-in :if-not org-clock-is-active)
     ("TAB" "in" org-clock-in :if-not org-clock-is-active)
     ("o" "out" org-clock-out :if org-clock-is-active)
@@ -528,12 +692,20 @@ Conditions have been adapted from `org-insert-link'"
     ("," "pause" org-timer-pause-or-continue :if org-menu-is-timer-running)
     ("," "continue" org-timer-pause-or-continue :if org-menu-is-timer-paused)
     (";" "countdown" org-timer-set-timer :if-nil org-timer-start-time)]
+   ["Effort"
+    ("e" "set effort" org-set-effort)
+    ("E" "increase" org-inc-effort)]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
 
+(defun org-menu-columns-globally ()
+  "Turn on `org-columns' globally."
+  (interactive)
+  (org-columns t))
+
 (transient-define-prefix org-menu-search-and-filter ()
-  "A menu to search and filter org-mode documents"
+  "A menu to search and filter `org-mode' documents."
   ["Search and filter"
    ["Filter"
     ("/" "only matching" org-sparse-tree)
@@ -541,17 +713,22 @@ Conditions have been adapted from `org-insert-link'"
     ("Q" "tags" org-tags-sparse-tree :if-non-nil org-menu-use-q-for-quit)
     ("t" "todos" org-show-todo-tree)
     ("d" "deadlines" org-check-deadlines)
-    ("b" "before date" org-check-before-date)
-    ("a" "after date" org-check-after-date)
-    ("D" "dates range" org-check-dates-range)]
-   ["Agenda"
-    ("A" "open" org-agenda)]
+    ("r" "remove highlights" org-remove-occur-highlights :if-non-nil org-occur-highlights)]
+   ["Dates"
+    ("b" "before" org-check-before-date)
+    ("a" "after" org-check-after-date)
+    ("D" "range" org-check-dates-range)]
+   ["Views"
+    ("A" "agenda" org-agenda)
+    ("c" "columns" org-columns :if-nil org-columns-current-fmt)
+    ("c" "columns off" org-columns-quit :if-non-nil org-columns-current-fmt)
+    ("gc" "whole buffer" org-menu-columns-globally :if-nil org-columns-current-fmt)]
    ["Quit"
     :if-non-nil org-menu-use-q-for-quit
     ("q" "quit" transient-quit-all)]])
 
 (transient-define-prefix org-menu-attachments ()
-  "A menu to manage attachments"
+  "A menu to manage attachments."
   ["Attachments"
    ["Add"
     ("a" "file" org-attach-attach)
@@ -573,15 +750,24 @@ Conditions have been adapted from `org-insert-link'"
    ["More"
     ("s" "set directory" org-attach-set-directory)
     ("S" "unset" org-attach-unset-directory)
-    ("z" "synchronize" org-attach-sync)]])
+    ("z" "synchronize" org-attach-sync)]]
+  (interactive)
+  (require 'org-attach)
+  (transient-setup 'org-menu-attachments))
 
 (transient-define-prefix org-menu-archive ()
-  "A menu to archive items"
+  "A menu to archive items."
   ["dummy"])
 
+(defun org-menu-force-cycle-archived ()
+  "Wrapper around deprecated `org-force-cycle-archived' to fix warning."
+  (interactive)
+  (with-no-warnings
+    (org-force-cycle-archived)))
+
 (transient-insert-suffix 'org-menu-archive (list 0)
   `["Archive"
-    ,@(org-menu-heading-navigate-items nil #'org-force-cycle-archived)
+    ,@(org-menu-heading-navigate-items nil #'org-menu-force-cycle-archived)
     ["Archive to"
      ("t" "tree" org-archive-subtree :transient t)
      ("s" "sibling" org-archive-to-archive-sibling :transient t)
@@ -594,7 +780,7 @@ Conditions have been adapted from `org-insert-link'"
 
 ;;;###autoload (autoload 'org-menu "org-menu" nil t)
 (transient-define-prefix org-menu ()
-  "A discoverable menu to edit and view org-mode documents"
+  "A discoverable menu to edit and view `org-mode' documents."
   ["dummy"])
 
 (transient-insert-suffix 'org-menu (list 0)
@@ -603,7 +789,7 @@ Conditions have been adapted from `org-insert-link'"
     ,@(org-menu-heading-navigate-items t)
 
     ["Move heading"
-     :if org-at-heading-p
+     :if org-menu-show-heading-options-p
      ("P" "up" org-metaup :transient t)
      ("N" "down" org-metadown :transient t)
      ("B" "left" org-shiftmetaleft :transient t)
@@ -612,7 +798,7 @@ Conditions have been adapted from `org-insert-link'"
      ("f" "right (line)" org-metaright :transient t)
      ("r" "refile" org-refile :transient t)]
     ["Change heading"
-     :if org-at-heading-p
+     :if org-menu-show-heading-options-p
      ("*" "toggle" org-ctrl-c-star :if-not org-at-table-p :transient t)
      ("t" "todo" org-todo :transient t)
      ("q" "tags" org-set-tags-command :transient t :if-nil org-menu-use-q-for-quit)
@@ -623,21 +809,25 @@ Conditions have been adapted from `org-insert-link'"
      ("D" "deadline" org-deadline :transient t)
      ("S" "schedule" org-schedule :transient t)
      ("/" "comment" org-toggle-comment :transient t)
-     ("C-w" "cut tree" org-cut-special :transient t)
-     ("C-y" "yank tree" org-paste-special :transient t)]
+     ("mn" "add note" org-add-note)]
     ["Make new/delete"
-     :if org-at-heading-p
+     :if org-menu-show-heading-options-p
+     :pad-keys t
      ("mh" "make heading (before)" org-insert-heading)
      ("mH" "make heading (after)" org-insert-heading-after-current)
      ("mt" "make todo (before)" org-insert-todo-heading)
      ("mT" "make todo (after)" org-menu-insert-todo-heading-after-current)
+     ("mc" "clone with time shift" org-clone-subtree-with-time-shift)
      ("dh" "delete heading" org-cut-subtree :transient t)
      ("dy" "delete property" org-delete-property :transient t)
-     ("a" "attachments" org-menu-attachments :transient t)]
+     ("a" "attachments" org-menu-attachments)
+     ("C-w" "cut tree" org-cut-special :transient t)
+     ("C-y" "yank tree" org-paste-special :transient t)]
 
     ;; Items for tables
     ["Navigate"
-     :if org-at-table-p
+     :if org-menu-show-table-options-p
+     :pad-keys t
      ("p" "up" previous-line :transient t)
      ("n" "down" next-line :transient t)
      ("b" "left" org-table-previous-field :transient t)
@@ -646,19 +836,20 @@ Conditions have been adapted from `org-insert-link'"
      ("M-w" "store link" org-store-link :transient t :if-not region-active-p)
      ("C-_" "undo" undo :transient t)]
     ["Move r/c"
-     :if org-at-table-p
+     :if org-menu-show-table-options-p
      ("P" "up" org-table-move-row-up :transient t)
      ("N" "down" org-table-move-row-down :transient t)
      ("B" "left" org-table-move-column-left :transient t)
      ("F" "right" org-table-move-column-right :transient t)]
     ["Field"
-     :if org-at-table-p
+     :if org-menu-show-table-options-p
+     :pad-keys t
      ("'" "edit" org-table-edit-field)
      ("SPC" "blank" org-table-blank-field :transient t)
      ("RET" "from above" org-table-copy-down :transient t)
      ("t" "text formatting" org-menu-text-in-element)]
     ["Formulas"
-     :if org-at-table-p
+     :if org-menu-show-table-options-p
      ("E" "edit all" org-table-edit-formulas :transient t)
      ("=" "field" (lambda () (interactive) (org-table-eval-formula '(4))) :transient t)
      ("+" "in place" (lambda () (interactive) (org-table-eval-formula '(16))))
@@ -666,11 +857,13 @@ Conditions have been adapted from `org-insert-link'"
      ("h" "coordinates" org-table-toggle-coordinate-overlays :transient t)
      ("D" "debug" org-table-toggle-formula-debugger :transient t)]
     ["Table"
-     :if org-at-table-p
+     :if org-menu-show-table-options-p
+     :pad-keys t
      ("dr" "delete row" org-shiftmetaup :transient t)
      ("dc" "delete column" org-shiftmetaleft :transient t)
      ("m" "make" org-menu-insert-table)
      ,@(when (fboundp (function org-table-toggle-column-width))
+         ;; This will emit a warning during byte compilation. We can ignore it
          (list '("S" "shrink column" org-table-toggle-column-width :transient t)))
      ("r" "sort" org-table-sort-lines :transient t)
      ("M-w" "copy rect" org-table-copy-region :transient t :if region-active-p)
@@ -679,7 +872,8 @@ Conditions have been adapted from `org-insert-link'"
 
     ;; Items for lists
     ["Navigate"
-     :if org-in-item-p
+     :if org-menu-show-list-options-p
+     :pad-keys t
      ("p" "prev" previous-line :transient t)
      ("n" "next" next-line :transient t)
      ("c" "cycle" org-cycle :transient t)
@@ -689,7 +883,7 @@ Conditions have been adapted from `org-insert-link'"
      ("M-w" "store link" org-store-link :transient t :if-not region-active-p)
      ("C-_" "undo" undo :transient t)]
     ["Move list"
-     :if org-in-item-p
+     :if org-menu-show-list-options-p
      ("P" "up" org-metaup :transient t)
      ("N" "down" org-metadown :transient t)
      ("B" "left" org-shiftmetaleft :transient t)
@@ -697,22 +891,26 @@ Conditions have been adapted from `org-insert-link'"
      ("b" "left (line)" org-metaleft :transient t)
      ("f" "right (line)" org-metaright :transient t)]
     ["List"
-     :if org-in-item-p
+     :if org-menu-show-list-options-p
      ("R" "repair" org-list-repair)
      ("*" "turn into tree" org-list-make-subtree)
      ("S" "sort" org-sort-list :transient t)
      ("t" "text formatting" org-menu-text-in-element)]
     ["Toggle"
-     :if org-in-item-p
+     :if org-menu-show-list-options-p
      ("-" "list item" org-toggle-item :if-not org-at-table-p :transient t)
      ("+" "list style" org-cycle-list-bullet :if-not org-at-table-p :transient t)
      ("d" "done" org-toggle-checkbox :transient t)
+     ("h" "half-done"
+      (lambda () (interactive) (org-toggle-checkbox '(16)))
+      :transient t)
      ("m" "checkbox" org-menu-toggle-has-checkbox :transient t)]
 
     ;; Items for text
     ,@(org-menu-text-format-items t)
     ["Line"
-     :if org-menu-at-text-p
+     :if org-menu-show-text-options-p
+     :pad-keys t
      (":" "fixed width" org-toggle-fixed-width :transient t)
      (";" "comment" org-menu-comment-line :transient t)
      ("--" "list" org-toggle-item :transient t)
@@ -722,16 +920,57 @@ Conditions have been adapted from `org-insert-link'"
     ,@(org-menu-eval-src-items)
 
     ["Link"
-     :if org-menu-in-link
+     :if org-menu-show-link-options-p
      ("e" "edit" org-insert-link :transient t)]
 
     ["Timestamp"
-     :if org-menu-in-time-p
+     :if org-menu-show-timestamp-options-p
      ("." "type" org-toggle-timestamp-type :transient t)
-     ("e" "edit" org-time-stamp :transient t)]
+     ("e" "edit" org-time-stamp :transient t)
+     ("R" "repair" org-menu-fix-timestamp :transient t)]
+
+    ["Footnote"
+     :if org-menu-show-footnote-options-p
+     ("ed" "delete" (lambda () (interactive) (org-footnote-delete)))
+     ("es" "sort" (lambda () (interactive) (org-footnote-sort)))
+     ("er" "renumber" (lambda () (interactive) (org-footnote-renumber-fn:N)))
+     ("eS" "sort+renumber" (lambda () (interactive)
+                (org-footnote-renumber-fn:N)
+                (org-footnote-sort)))
+     ("en" "normalize" (lambda () (interactive) (org-footnote-normalize)))]
+
+    ;; Items for column view
+    ["Navigate"
+     :if org-menu-show-columns-view-options-p
+     :pad-keys t
+     ("p" "prev" org-columns-move-up :transient t)
+     ("n" "next" org-columns-move-down :transient t)
+     ("f" "forward" forward-char :transient t)
+     ("b" "backward" backward-char :transient t)
+     ("M-w" "store link" org-store-link :transient t :if-not region-active-p)
+     ("C-_" "undo" undo :transient t)]
+    ["Value"
+     :if org-menu-show-columns-view-options-p
+     :pad-keys t
+     ("e" "edit" org-columns-edit-value :transient t)
+     ("V" "show" org-columns-show-value :transient t)
+     ("M-n" "next" org-columns-next-allowed-value :transient t)
+     ("M-p" "previous" org-columns-previous-allowed-value :transient t)
+     ("a" "edit allowed" org-columns-edit-allowed :transient t)]
+    ["Column"
+     :if org-menu-show-columns-view-options-p
+     :pad-keys t
+     ("E" "edit column" org-columns-edit-attributes :transient t)
+     ("{" "narrow" org-columns-narrow :transient t)
+     ("}" "widen" org-columns-widen :transient t)
+     ("M-<right>" "move right" org-columns-move-right :transient t)
+     ("M-<left>" "move left" org-columns-move-left :transient t)
+     ("M-S-<right>" "new" org-columns-new :transient t)
+     ("M-S-<left>" "delete" org-columns-delete :transient t)]
 
     ["Tasks"
-     ("v" "visibility" org-menu-visibility)
+     ("v" "visibility" org-menu-visibility :if-not org-menu-show-columns-view-options-p)
+     ("v" "visibility" org-menu-visibility-columns :if org-menu-show-columns-view-options-p)
      ("x" "evaluation" org-menu-eval)
      ("i" "insert" org-menu-insert)
      ("g" "go to" org-menu-goto)
@@ -743,9 +982,8 @@ Conditions have been adapted from `org-insert-link'"
          (list '("C-c C-c" "confirm capture" org-capture-finalize :if-non-nil org-capture-mode)))
      ,@(when (fboundp #'org-capture-kill)
          (list '("C-c C-k" "abort capture" org-capture-kill :if-non-nil org-capture-mode)))
-     ("" "" transient-noop)
-     ("q" "quit" transient-quit-all :if-non-nil org-menu-use-q-for-quit)
-     ]])
+     ""
+     ("q" "quit" transient-quit-all :if-non-nil org-menu-use-q-for-quit)]])
 
 (provide 'org-menu)
 ;;; org-menu.el ends here
tools/emacs/lisp/portal.el
@@ -1,3 +1,22 @@
+;;; portal.el --- Run processes in portals
+;;
+;; Copyright (C) 2024 Chris Done
+;;
+;; This file is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 2, or (at your option)
+;; any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs; see the file COPYING.  If not, write to
+;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
+;; Boston, MA 02111-1307, USA.
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; Customizations
 
@@ -6,7 +25,7 @@
   :group 'convenience)
 
 (defcustom portal-outputs-directory
-  "~/.portals/"
+  "~/.local/share/portals/"
   "Directory where to create output artifacts."
   :type 'string :group 'portal)
 
@@ -33,6 +52,11 @@
   "Portal exited stdout face."
   :group 'portal)
 
+(defface portal-timestamp-face
+  '((t :foreground "#888888"))
+  "Portal exited stdout face."
+  :group 'portal)
+
 (defface portal-exited-stderr-face
   '((t :foreground "#aa7070"))
   "Portal exited stderr face."
@@ -77,12 +101,20 @@ buffer."
 (defun portal-open-stdout ()
   "Open the stdout of the file at point."
   (interactive)
-  (find-file (portal-file-name (portal-at-point) "stdout")))
+  (with-current-buffer (find-file-other-window (portal-file-name (portal-at-point) "stdout"))
+    (portal-ansi-colors-minor-mode)
+    (auto-revert-tail-mode)
+    (goto-char (point-max))
+    (push-mark (point-max))))
 
 (defun portal-open-stderr ()
   "Open the stderr of the file at point."
   (interactive)
-  (find-file (portal-file-name (portal-at-point) "stderr")))
+  (with-current-buffer (find-file-other-window (portal-file-name (portal-at-point) "stderr"))
+    (portal-ansi-colors-minor-mode)
+    (auto-revert-tail-mode)
+    (goto-char (point-max))
+    (push-mark (point-max))))
 
 (defun portal-interrupt ()
   "Interrupt the process at point."
@@ -116,7 +148,7 @@ buffer."
            shell-file-name
            shell-command-switch
            (read-from-minibuffer
-            "Command: "
+            "Edit command: "
             (portal-as-shell-command (portal-read-json-file portal "command")))))
          (env (portal-read-json-file portal "env"))
          (default-directory (portal-read-json-file portal "directory")))
@@ -297,6 +329,15 @@ location."
          (nanoid (string-trim-right (substring base64-encoded 0 21))))
     (concat "portal_" nanoid)))
 
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; A minor mode for applying ansi-term colors to a buffer
+
+(define-minor-mode portal-ansi-colors-minor-mode
+  "Apply ANSI colors for terminal outputs."
+  :init-value nil
+  :lighter "ANSI"
+  (ansi-color-apply-on-region (point-min) (point-max)))
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; A small minor mode that just sets up a timer that runs a thing in a
 ;; given buffer every N seconds
@@ -372,7 +413,11 @@ later."
                      (portal-last-n-lines
                       5
                       (process-get (process-get process :stderr-process) :buffer))
-                   (portal-tail-file portal 5 "stderr"))))
+                   (portal-tail-file portal 5 "stderr")))
+         (started-time
+          (file-attribute-modification-time (file-attributes (portal-file-name portal "command"))))
+         (exited-time
+          (file-attribute-modification-time (file-attributes (portal-file-name portal "status")))))
     (with-temp-buffer
       (insert (propertize
                (concat "# (" (if (string= status "run") "๐ŸŒ€" status) ") " (portal-as-shell-command command))
@@ -382,6 +427,17 @@ later."
                  (if (string= status "0")
                      'portal-exit-success-face
                    'portal-exit-failure-face))))
+      (insert "\n"
+              (concat
+               (propertize (format-time-string "# Started: %Y-%m-%d %T" started-time)
+                           'face 'portal-timestamp-face)
+               (if (string= status "run")
+                   ""
+                 (propertize (concat
+                              (format-time-string ", exited: %Y-%m-%d %T" exited-time)
+                              " => "
+                              (portal-display-time-difference started-time exited-time))
+                             'face 'portal-timestamp-face))))
       ;; Only show if it's different to the current directory,
       ;; otherwise it's noise.
       (unless (string= default-directory directory) (insert "\n# " directory))
@@ -401,6 +457,29 @@ later."
       (propertize (buffer-string)
                   'portal portal))))
 
+(defun portal-display-time-difference (start-time end-time)
+  "Display the time difference between START-TIME and END-TIME in human-readable format.
+START-TIME and END-TIME should be Emacs Lisp time values as returned by `current-time'.
+The function will display the time in the most appropriate unit (from ns to days)."
+  (let* ((diff (float-time (time-subtract end-time start-time))))
+    (apply #'format
+           (cons "%.3f %s"
+                 (cond
+                  ((< diff 1e-6)
+                   (list (* diff 1e9) "ns"))
+                  ((< diff 1e-3)
+                   (list (* diff 1e6) "us"))
+                  ((< diff 1)
+                   (list (* diff 1e3) "ms"))
+                  ((< diff 60)
+                   (list diff "s"))
+                  ((< diff 3600)
+                   (list (/ diff 60) "mins"))
+                  ((< diff 86400)
+                   (list (/ diff 3600) "hours"))
+                  (t
+                   (list (/ diff 86400) "days")))))))
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; String generation
 
@@ -441,7 +520,12 @@ later."
 
 (defun portal-no-empty-lines (string)
   "Drop empty lines from a string."
-  (replace-regexp-in-string "\n$" "" string))
+  (replace-regexp-in-string
+   ;; Drop ANSI codes from terminal output
+   ;; <https://superuser.com/questions/380772/removing-ansi-color-codes-from-text-stream>
+   "\\(\x1B\\[[0-9;]*[A-Za-z]\\|[\x00-\x09\x0B-\x1F\x7F]\\|\n$\\|^\n\\)"
+   ""
+   string))
 
 (defun portal-last-n-lines (n string)
   "Take last N lines from STRING."
@@ -499,14 +583,16 @@ the same paragraph."
 ;; Major mode
 
 (defvar-keymap portal-mode-map
-  "M-!" 'portal-shell-command
+  "M-!" 'portal-dwim-execute
   "C-c C-c" 'portal-interrupt
   "RET" 'portal-jump-to-thing-at-point
+  "M-p" 'portal-rerun
   )
 
 (define-derived-mode portal-mode
   fundamental-mode "Portals"
   "Major mode for portals."
+  (setq buffer-save-without-query t)
   (portal-alpha-minor-mode))
 
 (defun portal-insert-command (command)
@@ -523,6 +609,15 @@ buffer."
      (cdr command))
     (insert portal)))
 
+(defun portal-dwim-execute ()
+  (interactive)
+  (call-interactively
+   (if (condition-case nil
+           (portal-at-point)
+         (error nil))
+       'portal-edit
+     'portal-shell-command)))
+
 (defun portal-shell-command (command)
   "Run a shell command and insert it at point."
   (interactive "sCommand: ")
@@ -544,3 +639,6 @@ the file."
      ((eq face 'portal-exited-stdout-face)
       (portal-open-stdout))
      (t (call-interactively 'newline)))))
+
+(provide 'portal)
+
tools/emacs/lisp/project-headerline.el
@@ -6,6 +6,9 @@
 ;; Author: Victor Gaydov <victor@enise.org>
 ;; Created: 03 Feb 2025
 ;; URL: https://github.com/gavv/project-headerline
+;; Version: 0.4
+;; Package-Requires: ((emacs "28.2") (f "0.21.0") (s "1.13.0") (all-the-icons "5.0.0"))
+;; Keywords: convenience
 
 ;;; License:
 
@@ -44,12 +47,9 @@
 (require 'seq)
 (require 'vc)
 
-(when (featurep 'projectile)
-  (require 'projectile))
-(when (featurep 'magit)
-  (require 'magit))
-(when (featurep 'all-the-icons)
-  (require 'all-the-icons))
+(require 'projectile nil 'noerror)
+(require 'magit nil 'noerror)
+(require 'all-the-icons nil 'noerror)
 
 (require 'f)
 (require 's)
@@ -62,19 +62,19 @@
 
 (defface project-headerline-project-name
   '((t :inherit font-lock-string-face :weight bold))
-  "Face used for 'project-name segment."
+  "Face used for \\='project-name segment."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline)
 
 (defface project-headerline-path-in-project
   '((t :inherit font-lock-keyword-face))
-  "Face used for 'path-in-project segment."
+  "Face used for \\='path-in-project segment."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline)
 
 (defface project-headerline-buffer-name
   '((t :inherit font-lock-builtin-face))
-  "Face used for 'buffer-name segment."
+  "Face used for \\='buffer-name segment."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline)
 
@@ -86,22 +86,35 @@
 
 (defface project-headerline-path-separator
   '((t :inherit shadow :height 0.8))
-  "Face used for between path components inside 'path-in-project' segment."
+  "Face used for between path components inside `path-in-project' segment."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline)
 
-(defcustom project-headerline-display-segments '(project-name path-in-project buffer-name)
+(defface project-headerline-space
+  '((t :height 0.5))
+  "Face used for spaces around segment and path separators."
+  :package-version '(project-headerline . "0.2")
+  :group 'project-headerline)
+
+(defcustom project-headerline-display-segments
+  '(
+    ;; list of pre-defined symbols, each symbol corresponds to a segment
+    project-name
+    path-in-project
+    buffer-name
+    ;;
+    )
   "Which segments to show and in what order.
 
 Must be a list of symbols, where each symbol represents a segment:
 
-  - 'project-name' - name of project where current file belongs
-  - 'path-in-project' - relative path from project root up to the current file
-  - 'buffer-name' - file name or buffer name
+  - `project-name' - name of project where current file belongs
+  - `path-in-project' - relative path from project root up to the current file
+  - `buffer-name' - file name or buffer name
 
-'path-in-project' segment is present only if buffer is file or directory.
-'buffer-name' segment displays file or directory name if buffer is visiting one,
-and uses (buffer-name) otherwise."
+`path-in-project' segment is present only if buffer is file or directory.
+`buffer-name' segment displays file or directory name if buffer is visiting one,
+and uses function (buffer-name) otherwise."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline
   :type '(repeat
@@ -127,7 +140,7 @@ to create it with default icon name."
   :set 'project-headerline--set-variable)
 
 (defcustom project-headerline-path-separator nil
-  "String or icon to separate path components inside 'path-in-project' segment.
+  "String or icon to separate path components inside \\='path-in-project segment.
 
 Icon is actually also a string, but with special properties.
 For example, you can create one using `all-the-icons-material'.
@@ -142,7 +155,7 @@ to create it with default icon name."
   :set 'project-headerline--set-variable)
 
 (defcustom project-headerline-path-ellipsis "..."
-  "String or icon used when 'path-in-project' segment is truncated.
+  "String or icon used when \\='path-in-project' segment is truncated.
 
 If the segment is too long, a few leading path components are
 replaced with the value of this variable."
@@ -164,22 +177,23 @@ replaced with the value of this variable."
     ;; detect using builtin project.el package
     (project :allow-remote nil
              :describe ,(lambda ()
-                          (when-let ((project (project-current)))
-                            (list :name (project-name project)
+                          (when-let* ((project (project-current)))
+                            (list :name (f-base (project-root project))
                                   :path (project-root project)))))
     ;; detect using magit, if installed
     (magit :allow-remote nil
            :describe ,(lambda ()
                         (when (featurep 'magit)
-                          (when-let ((magit-root (magit-toplevel)))
+                          (when-let* ((magit-root (magit-toplevel)))
                             (list :name (f-filename magit-root)
                                   :path (f-full magit-root))))))
     ;; detect using builtin vc package
     (vc :allow-remote nil
         :describe ,(lambda ()
-                     (when-let ((vc-root (vc-root-dir)))
+                     (when-let* ((vc-root (vc-root-dir)))
                        (list :name (f-filename vc-root)
                              :path (f-full vc-root)))))
+    ;;
     )
   "Assoc list of project detection methods.
 
@@ -210,8 +224,11 @@ Used by default implementation of
 
 (defcustom project-headerline-fallback-alist
   '(
+    ;; pseudo-project "~" for all orphan files under $HOME
     ("~" . "~/")
+    ;; pseudo-project "/" for all other orphan files
     ("/" . "/")
+    ;;
     )
   "Assoc list of fallback projects when normal detection fails.
 
@@ -219,16 +236,16 @@ Assoc list key is project name.
 Assoc list value is project path.
 
 If no project was detected using `project-headerline-detect-alist',
-then `project-headerline-fallback-alist' is scanned. A fallback
+then `project-headerline-fallback-alist' is scanned.  A fallback
 project is selected if it's path is the parent of buffer's path.
 
 You can use it both for real projects with hard-coded paths
-(e.g. if they're not identified by common methods), and for
+\(e.g. if they're not identified by common methods), and for
 fallbacks for buffers that don't really belong to a project.
 
 By default, two `pseudo projects` are registered: `~' for any
 file inside home directory, and `/' for any file elsewhere
-on filesystem. You can disable this by removing corresponding
+on filesystem.  You can disable this by removing corresponding
 elements from the assoc list."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline
@@ -239,10 +256,13 @@ elements from the assoc list."
 
 (defcustom project-headerline-rename-alist
   '(
+    ;; magit
     ("^\\(magit\\):.*" . "\\1")
     ("^\\(magit-[a-z]+\\):.*" . "\\1")
+    ;; compilation
     ("^\\*compilation\\*<.*>" . "compilation")
     ("^\\*compilation<.*>\\*" . "compilation")
+    ;;
     )
   "Assoc list of buffer rename rules.
 
@@ -250,7 +270,7 @@ Assoc list key is a regular expression.
 Assoc list value is a replacement string that can use capture groups.
 
 Keys and values are passed to `replace-regexp-in-string' and FROM and
-TO arguments. If any of the rule matches buffer, buffer name displayed
+TO arguments.  If any of the rule matches buffer, buffer name displayed
 in headerline is changed according to the replacement."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline
@@ -291,9 +311,9 @@ For `dir' buffers, `:dir' is path to directory itself.
 For `other' buffers, `:dir' is path to a directory associated with
 the buffer, typically `default-directory' inside that buffer.
 
-Default implementation reports `dir' for dired buffers, `file' for
-buffers with non-empty `buffer-file-name', and `other' for the rest.
-It also applies buffer renaming rules according to variable
+Default implementation reports `dir' for Dired buffers, `file' for
+buffers with non-empty variable `buffer-file-name', and `other' for
+the rest.  It also applies buffer renaming rules according to variable
 `project-headerline-rename-alist'."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline
@@ -326,11 +346,11 @@ and applies corresponding faces."
   "Function to create icon from name.
 
 Takes two arguments:
-  - 'icon-name' - string name of the icon
-  - 'icon-face' - face to apply to the icon
+  - `icon-name' - string name of the icon
+  - `icon-face' - face to apply to the icon
 
 Returns propertized string with the icon.
-If icon is not available, returns nil. In this case fallback
+If icon is not available, returns nil.  In this case fallback
 character will be used instead of the icon.
 
 Default implementation uses `all-the-icons-material' when it's
@@ -356,7 +376,11 @@ Takes no arguments and returns number of characters."
     conf-mode
     text-mode
     dired-mode)
-  "Modes in which `global-project-headerline-mode' enables `project-headerline-mode'.
+  "Modes in which to enable `project-headerline-mode' automatically.
+
+When `global-project-headerline-mode' is enabled, it enables headerline
+in buffer if its major mode is derived from one of these modes.
+
 Note that minibuffer and hidden buffers are always excluded."
   :package-version '(project-headerline . "0.1")
   :group 'project-headerline
@@ -370,7 +394,7 @@ and `project-headerline-fallback-alist' and defines project name and path.
 
 It can be either a string or a list:
 
- - If it's a string, it should be a path to project directory. Project name
+ - If it's a string, it should be a path to project directory.  Project name
    is set to the directory name.
 
  - If it's a list, it should be a plist with project properties, in the same
@@ -379,6 +403,9 @@ It can be either a string or a list:
 It's convenient to set this from local variables, e.g. in `.dir-locals.el'
 in the project root.")
 
+;; Forward-declate mode variable.
+(defvar project-headerline-mode)
+
 (defun project-headerline--set-variable (symbol value)
   "Setter for defcustom.
 Assigns value to variable and invokes `project-headerline-reset'."
@@ -398,15 +425,21 @@ Otherwise, evaluate FORM, store in cache, and return it."
      (or (gethash ,key cache)
          (puthash ,key ,form cache))))
 
-(defmacro project-headerline--call (func &rest args)
+(defmacro project-headerline--call (func-or-cons &rest args)
   "Call user function.
 On error, display warning and return nil."
-  `(condition-case err
-       (funcall ,func ,@args)
-     (error
-      (warn "Caught error from %s: %s" ,(symbol-name func)
-            (error-message-string err))
-      nil)))
+  (let ((func (if (consp func-or-cons)
+                  (car func-or-cons)
+                func-or-cons))
+        (name (if (consp func-or-cons)
+                  (cdr func-or-cons)
+                (symbol-name func-or-cons))))
+    `(condition-case err
+         (funcall ,func ,@args)
+       (error
+        (warn "Caught error from %s: %s" ,name
+              (error-message-string err))
+        nil))))
 
 (defun project-headerline-describe-project ()
   "Get current project properties.
@@ -436,7 +469,8 @@ see its docstring for details."
                 (when (and (or allow-remote
                                (not (file-remote-p default-directory)))
                            describe-fn)
-                  (project-headerline--call describe-fn))))
+                  (project-headerline--call
+                   (describe-fn . "project-headerline-detect-alist :describe")))))
             project-headerline-detect-alist))
 
 (defun project-headerline--project-from-fallback-alist ()
@@ -524,22 +558,27 @@ Otherwise returns buffer name."
    (t
     (buffer-name))))
 
+;; Forward-declare to ensure they are not byte-compiled as lexical.
+(defvar all-the-icons-scale-factor)
+(defvar all-the-icons-default-adjust)
+
 (defun project-headerline-icon (icon-name icon-face)
   "Format propertized icon string from icon name and face.
 Default implementation of `project-headerline-icon-function',
 see its docstring for details."
   (when (functionp 'all-the-icons-material)
     (let ((all-the-icons-scale-factor 1.0)
-          (all-the-icons-default-adjust -0.18))
+          (all-the-icons-default-adjust -0.15))
       (when-let* ((icon (all-the-icons-material icon-name :face icon-face))
-                  (sep (s-concat " " icon " ")))
-        sep))))
+                  (space (propertize " " 'font-lock-face 'project-headerline-space)))
+        (s-concat
+         space icon space)))))
 
 (defun project-headerline-width ()
   "Return maximum number of characters in headerline.
 Default implementation of `project-headerline-width-function',
 see its docstring for details."
-  (/ (window-width) 1.5))
+  (window-width))
 
 (defun project-headerline--separator (key default-icon default-char)
   "Make propertized icon string."
@@ -554,8 +593,9 @@ see its docstring for details."
       (project-headerline--call project-headerline-icon-function
                                 default-icon face-name)
       ;; default char
-      (propertize (s-concat " " default-char " ")
-                  'face face-name)))))
+      (let ((char (propertize default-char 'font-lock-face face-name))
+            (space (propertize " " 'font-lock-face 'project-headerline-space)))
+        (s-concat space char space))))))
 
 (defun project-headerline--path-components (root-path path)
   "Split path from ROOT-PATH to CURR-PATH into components."
@@ -616,22 +656,23 @@ see its docstring for details."
       project buffer))))
 
 (defun project-headerline--format-project-name (project buffer)
-  "Build 'project segment."
+  "Build \\='project segment."
+  (ignore buffer)
   (let ((project-name (plist-get project :name)))
     (when (s-present-p project-name)
       (propertize project-name
                   'font-lock-face 'project-headerline-project-name))))
 
 (defun project-headerline--format-path-in-project (project buffer max-path)
-  "Build 'path-in-project segment."
+  "Build \\='path-in-project segment."
   (let* ((project-path (plist-get project :path))
          (buffer-type (plist-get buffer :type))
          (buffer-dir (plist-get buffer :dir))
          (path-in-project (cond
                            ;; directory
                            ((eq buffer-type 'dir)
-                            (if (and (seq-contains project-headerline-display-segments
-                                                   'buffer-name)
+                            (if (and (seq-contains-p project-headerline-display-segments
+                                                     'buffer-name)
                                      (not (f-same-p project-path
                                                     buffer-dir)))
                                 (f-parent buffer-dir)
@@ -663,7 +704,7 @@ see its docstring for details."
         result))))
 
 (defun project-headerline--format-buffer-name (project buffer)
-  "Build 'buffer segment."
+  "Build \\='buffer segment."
   (let* ((project-path (plist-get project :path))
          (buffer-type (plist-get buffer :type))
          (buffer-dir (plist-get buffer :dir))
@@ -695,7 +736,7 @@ see its docstring for details."
 
 (defun project-headerline--composer-match (elem func)
   "Match `header-line-format' element by composer function."
-  (when-let ((form (car-safe (cdr-safe elem))))
+  (when-let* ((form (car-safe (cdr-safe elem))))
     (and (eq (car form) :eval)
          (eq (caadr form) func))))
 
@@ -735,8 +776,8 @@ see its docstring for details."
 
 (defun project-headerline--magit-compose (text)
   "Build magit headerline.
-If project-headerline-mode is off, produces same result as original
-`magit-set-header-line-format'. Otherwise, produces right-aligned
+If `project-headerline-mode' is off, produces same result as original
+`magit-set-header-line-format'.  Otherwise, produces right-aligned
 headerline that can be use together with `project-headerline'."
   (project-headerline--cached
    'magit-headerline
@@ -751,9 +792,8 @@ headerline that can be use together with `project-headerline'."
     text)))
 
 (defun project-headerline--magit-advice (orig-fn &rest args)
-  "Wraps magit headrline builder to support using `project-headerline'
-in magit buffers. If you don't use project-headerline with magit,
-no visible changes are made."
+  "Wraps magit headrline builder to support `project-headerline' in magit buffers.
+If you don't use project-headerline with magit, no visible changes are made."
   ;; safety check: don't follow advice if signature doesn't
   ;; match what it used to be
   (if (and (eq 1 (length args))
@@ -763,27 +803,32 @@ no visible changes are made."
     (apply orig-fn args)))
 
 (defun project-headerline--rename-file-advice (orig-fn &rest args)
-  "Wraps rename-file to update headerline on name change."
+  "Wraps `rename-file' to update headerline on name change."
   (unwind-protect
       (apply orig-fn args)
     (let ((from (car args))
           (to (cadr args)))
-      (dolist (buffer (buffer-list))
-        (when-let ((buffer-path (buffer-file-name buffer)))
-          (when (or (and from (f-same-p buffer-path from))
-                    (and to (f-same-p buffer-path to)))
-            (project-headerline-reset-buffer buffer)))))))
+      (project-headerline--reset-paths from to))))
 
-(defun project-headerline--rename-buffer-advice (orig-fn &rest args)
-  "Wraps rename-buffer to update headerline on name change."
+(defun project-headerline--add-name-to-file-advice (orig-fn &rest args)
+  "Wraps `add-name-to-file' to update headerline on name change."
   (unwind-protect
       (apply orig-fn args)
-    (project-headerline-reset-buffer)))
+    (let ((from (car args))
+          (to (cadr args)))
+      (project-headerline--reset-paths from to))))
+
+(defun project-headerline--rename-buffer-advice (orig-fn &rest args)
+  "Wraps `rename-buffer' to update headerline on name change."
+  (unwind-protect
+      (apply orig-fn args)
+    (project-headerline--reset-buffer)))
 
 (defun project-headerline--enable-maybe ()
-  "Enable `project-headerline-mode' in current buffer if its major mode is
-derived from one of the modes in `project-headerline-mode-list'.
-Never enable in minibuffer and hidden buffers."
+  "Enable `project-headerline-mode' in current buffer, if needed.
+Headerline is enabled if buffer major mode is derived from one of the modes
+in `project-headerline-mode-list'.
+Never enables in minibuffer and hidden buffers."
   (when (and (not (minibufferp))
              (not (string-match "^ " (buffer-name)))
              (seq-some #'derived-mode-p project-headerline-mode-list)
@@ -797,40 +842,54 @@ Never enable in minibuffer and hidden buffers."
                 :around #'project-headerline--magit-advice))
   (advice-add 'rename-file
               :around #'project-headerline--rename-file-advice)
+  (advice-add 'add-name-to-file
+              :around #'project-headerline--add-name-to-file-advice)
   (advice-add 'rename-buffer
               :around #'project-headerline--rename-buffer-advice))
 
 (defun project-headerline--register-hooks ()
   "Register all hooks."
   (add-hook 'window-configuration-change-hook
-            #'project-headerline-reset-buffer nil :local)
+            #'project-headerline--reset-buffer nil :local)
   (add-hook 'after-revert-hook
-            #'project-headerline-reset-buffer nil :local))
+            #'project-headerline--reset-buffer nil :local)
+  (add-hook 'after-set-visited-file-name-hook
+            #'project-headerline--reset-buffer nil :local))
 
 (defun project-headerline--unregister-hooks ()
   "Unregister all hooks."
   (remove-hook 'window-configuration-change-hook
-               #'project-headerline-reset-buffer :local)
+               #'project-headerline--reset-buffer :local)
   (remove-hook 'after-revert-hook
-               #'project-headerline-reset-buffer :local))
+               #'project-headerline--reset-buffer :local)
+  (remove-hook 'after-set-visited-file-name-hook
+               #'project-headerline--reset-buffer :local))
 
-(defun project-headerline-reset-buffer (&optional buffer)
-  "Invalidate headerline caches and refresh"
+(defun project-headerline--reset-buffer (&optional buffer)
+  "Refresh headerline in given BUFFER (or current)."
   (with-current-buffer (or buffer (current-buffer))
     (when (bound-and-true-p project-headerline--cache)
       (setq-local project-headerline--cache nil))
     (when project-headerline-mode
       (force-mode-line-update))))
 
+(defun project-headerline--reset-paths (&rest paths)
+  "Refresh headerline in buffers visiting any of PATHS."
+  (dolist (buffer (buffer-list))
+    (when-let* ((buffer-path (buffer-file-name buffer)))
+      (dolist (path paths)
+        (when (and path (f-same-p buffer-path path))
+          (project-headerline--reset-buffer buffer))))))
+
 ;;;###autoload
 (defun project-headerline-reset (&optional buffer)
   "Forcibly refresh headerline in all buffers.
 If BUFFER is given, refresh only that buffer."
   (interactive)
   (if buffer
-      (project-headerline-reset-buffer buffer)
+      (project-headerline--reset-buffer buffer)
     (dolist (buffer (buffer-list))
-      (project-headerline-reset-buffer buffer))))
+      (project-headerline--reset-buffer buffer))))
 
 ;;;###autoload
 (define-minor-mode project-headerline-mode
@@ -848,11 +907,11 @@ If BUFFER is given, refresh only that buffer."
     ;; disable mode
     (project-headerline--unregister-hooks)
     (project-headerline--composer-remove 'project-headerline--compose)
-    (project-headerline-reset-buffer)
+    (project-headerline--reset-buffer)
     (force-mode-line-update)))
 
 ;;;###autoload
-(define-global-minor-mode global-project-headerline-mode
+(define-globalized-minor-mode global-project-headerline-mode
   project-headerline-mode
   project-headerline--enable-maybe
   :group 'project-headerline)