Subject: 30.0.50; [PATCH] Add support for outline-minor-mode to Eshell

From: Jim Porter <[email-masked]>
Date: Thu, 30 May 2024 22:18:44 -0700

This patch adds support for outline-minor-mode to Eshell. When enabled, 
this will just add outline buttons to the prompt (as the top level), and 
one to the start of the output (as level 2).

The Eshell side is hopefully fairly straightforward, but the outline.el 
part probably warrants a close review. The changes in outline.el are all 
there to support outline headings that are just a newline and nothing 
else. This is important for both the output (you might call a command 
that prints a newline as the first character), and the prompt (the first 
character of your prompt could be a newline as a way of separating the 
prompt from the output just above it).[1]

Mainly, the changes in outline.el are about checking to see if the 
character *before* point is invisible (i.e. part of a collapsed node). 
This way, if point is at the beginning of the output, and you collapse 
that node, outline.el still considers you to be on a heading. (Without 
these changes, cycling a node doesn't work correctly.)

I'm open to adding regression tests here since the logic is pretty 
subtle, but I didn't see any existing ones for outline.el and it's not 
immediately obvious the best way to test its behavior. Let me know if 
anyone has ideas though.

[1] In the prompt case, a different way to solve this would be to treat 
the start of the heading as the first non-empty line. That doesn't work 
for output though since *all* lines could be empty.

From f10b887f945d057a50a06efccfc9d8c1e6762f0e Mon Sep 17 00:00:00 2001
From: Jim Porter <[email-masked]>
Date: Thu, 30 May 2024 14:31:55 -0700
Subject: [PATCH] Add support for outline-minor-mode in Eshell

* lisp/outline.el (outline-back-to-heading): Skip past invisible text.
(outline-on-heading-p): Check for invisibility of the character *before*
point; that way, this returns non-nil if at the end the first line of a
collapsed section.
(outline-flag-region): Set the outline button as needed.
(outline-end-of-subtree): Allow for stopping at empty lines.

* lisp/eshell/em-prompt.el (eshell-outline-search)
(eshell-outline-level): New functions...
(eshell-prompt-initialize): ... use them.

* doc/misc/eshell.texi (Bugs and ideas): Remove implemented idea.

* etc/NEWS: Announce this change.
---
 doc/misc/eshell.texi     |  6 ------
 etc/NEWS                 |  3 +++
 lisp/eshell/em-prompt.el | 45 +++++++++++++++++++++++++++++++++++++++-
 lisp/outline.el          | 29 ++++++++++++++++++--------
 4 files changed, 67 insertions(+), 16 deletions(-)

diff --git a/doc/misc/eshell.texi b/doc/misc/eshell.texi
index 873d14aff32..071a2e59191 100644
--- a/doc/misc/eshell.texi
+++ b/doc/misc/eshell.texi
@@ -2967,12 +2967,6 @@ Bugs and ideas
 
 @item Support zsh's ``Parameter Expansion'' syntax, i.e., @samp{$@{@var{name}:-@var{val}@}}
 
-@item Create a mode @code{eshell-browse}
-
-It would treat the Eshell buffer as an outline.  Collapsing the outline
-hides all of the output text.  Collapsing again would show only the
-first command run in each directory
-
 @item Allow other revisions of a file to be referenced using @samp{file@{rev@}}
 
 This would be expanded by @code{eshell-expand-file-name} (see above).
diff --git a/etc/NEWS b/etc/NEWS
index 3c672ffed8f..b5d4c9cfe6e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -877,6 +877,9 @@ can make it executable like other shell scripts:
 
     #!/usr/bin/env -S emacs --batch -f eshell-batch-file
 
+---
+*** Eshell now supports 'outline-minor-mode'.
+
 +++
 *** New builtin Eshell command 'compile'.
 This command runs another command, sending its output to a compilation
diff --git a/lisp/eshell/em-prompt.el b/lisp/eshell/em-prompt.el
index b6556d29544..2e637acc05f 100644
--- a/lisp/eshell/em-prompt.el
+++ b/lisp/eshell/em-prompt.el
@@ -107,6 +107,45 @@ eshell-prompt-repeat-map
 
 ;;; Functions:
 
+(defun eshell-outline-search (&optional bound move backward looking-at)
+  "Search for outline headings.  See `outline-search-function'."
+  (let ((value '(prompt command-output)))
+    (if looking-at
+        (when (and (memq (get-text-property (point) 'field) value)
+                   (= (field-beginning (point)) (point)))
+          (set-match-data (list (pos-bol) (pos-eol)))
+          t)
+      ;; Go to the end when in the middle of heading.
+      (when (and (not backward)
+                 (memq (get-text-property (point) 'field) value)
+                 (/= (point) (field-beginning (point))))
+        (goto-char (field-end (point))))
+      ;; Search for our wanted fields.
+      (if-let ((prop-match
+                (funcall (if backward #'text-property-search-backward
+                           #'text-property-search-forward)
+                         'field value (lambda (list elt) (memq elt list))))
+               ;; beg and end should only include the first line of the field.
+               (beg (prop-match-beginning prop-match))
+               (end (save-excursion (goto-char beg) (pos-eol)))
+               ((or (null bound) (if backward (>= beg bound) (<= end bound))))
+               ((save-excursion
+                  (goto-char beg)
+                  (= (field-beginning (point) (pos-bol))))))
+          (progn
+            (goto-char (if backward beg end))
+            (set-match-data (list beg end))
+            t)
+        (when move (goto-char (or bound (if backward (point-min) (point-max)))))
+        nil))))
+
+(defun eshell-outline-level ()
+  "Get the outline level at point."
+  (pcase (get-text-property (point) 'field)
+    ('prompt 1)
+    ('command-output 2)
+    (_ (error "Not at an outline heading"))))
+
 (define-minor-mode eshell-prompt-mode
   "Minor mode for eshell-prompt module.
 
@@ -117,7 +156,11 @@ eshell-prompt-initialize
   "Initialize the prompting code."
   (unless eshell-non-interactive-p
     (add-hook 'eshell-post-command-hook 'eshell-emit-prompt nil t)
-    (eshell-prompt-mode)))
+    (eshell-prompt-mode)
+
+    (setq-local outline-search-function #'eshell-outline-search
+                outline-level #'eshell-outline-level
+                outline-minor-mode-use-buttons 'in-margins)))
 
 (defun eshell-emit-prompt ()
   "Emit a prompt if eshell is being used interactively."
diff --git a/lisp/outline.el b/lisp/outline.el
index 40a75701cbf..b79984092e9 100644
--- a/lisp/outline.el
+++ b/lisp/outline.el
@@ -687,6 +687,11 @@ outline-back-to-heading
   "Move to previous heading line, or beg of this line if it's a heading.
 Only visible heading lines are considered, unless INVISIBLE-OK is non-nil."
   (forward-line 0)
+  (when (and (not invisible-ok)
+             (> (point) (point-min))
+             (get-char-property (1- (point)) 'invisible))
+    ;; Skip backwards past any invisible text.
+    (goto-char (previous-single-char-property-change (point) 'invisible)))
   (or (outline-on-heading-p invisible-ok)
       (let (found)
 	(save-excursion
@@ -706,7 +711,9 @@ outline-on-heading-p
 If INVISIBLE-OK is non-nil, an invisible heading line is ok too."
   (save-excursion
     (forward-line 0)
-    (and (bolp) (or invisible-ok (not (outline-invisible-p)))
+    (and (bolp) (or invisible-ok
+                    (bobp)
+                    (not (outline-invisible-p (1- (point)))))
 	 (if outline-search-function
              (funcall outline-search-function nil nil nil t)
            (looking-at outline-regexp)))))
@@ -1004,6 +1011,11 @@ outline-flag-region
     (let ((o (make-overlay from to nil 'front-advance)))
       (overlay-put o 'evaporate t)
       (overlay-put o 'invisible 'outline)
+      (unless (eq outline-minor-mode-use-buttons 'insert)
+        ;; Set up the button on this overlay too.  This ensures that the
+        ;; button is visible even if the first line of the region is
+        ;; empty.
+        (overlay-put o 'before-string (nth 1 outline--button-icons)))
       (overlay-put o 'isearch-open-invisible
 		   (or outline-isearch-open-invisible-function
 		       #'outline-isearch-open-invisible))))
@@ -1264,14 +1276,13 @@ outline-end-of-subtree
 		(or first (> (funcall outline-level) level)))
       (setq first nil)
       (outline-next-heading))
-    (if (and (bolp) (not (eolp)))
-	;; We stopped at a nonempty line (the next heading).
-	(progn
-	  ;; Go to end of line before heading
-	  (forward-char -1)
-          (if (and outline-blank-line (bolp))
- 	      ;; leave blank line before heading
- 	      (forward-char -1))))))
+    (when (bolp)
+      ;; We stopped at the next heading.  Go to end of line before
+      ;; heading.
+      (forward-char -1)
+      (when (and outline-blank-line (bolp))
+        ;; Leave blank line before heading.
+        (forward-char -1)))))
 
 (defun outline-show-branches ()
   "Show all subheadings of this heading, but not their bodies."
-- 
2.25.1