341 lines
15 KiB
EmacsLisp
341 lines
15 KiB
EmacsLisp
;;; treemacs.el --- A tree style file viewer package -*- lexical-binding: t -*-
|
|
|
|
;; Copyright (C) 2024 Alexander Miller
|
|
|
|
;; This program 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 3 of the License, or
|
|
;; (at your option) any later version.
|
|
|
|
;; This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
;;; Commentary:
|
|
|
|
;; Minor mode to follow the tag at point in the treemacs view on an idle timer
|
|
;; Finding the current tag is a fairly involved process:
|
|
;; * Grab current buffer's imenu output
|
|
;; * Flatten the list to create full tag paths
|
|
;; * Sort according to tag position
|
|
;; * Beware of edge cases: org-mode headlines are containers, but also hold a position, hidden as a text property and
|
|
;; semantic-mode parsed buffers use overlays instead of markers
|
|
;; * Find the last tag whose position begins before point
|
|
;; * Jump to that tag path
|
|
;; * No jump when there's no buffer file, or no imenu, or buffer file is not seen in treemacs etc.
|
|
|
|
;; NOTE: This module is lazy-loaded.
|
|
|
|
;;; Code:
|
|
|
|
(require 'imenu)
|
|
(require 'hl-line)
|
|
(require 'treemacs-customization)
|
|
(require 'treemacs-core-utils)
|
|
(require 'treemacs-tags)
|
|
(require 'treemacs-scope)
|
|
(require 'treemacs-follow-mode)
|
|
(require 'treemacs-logging)
|
|
|
|
(eval-when-compile
|
|
(require 'inline)
|
|
(require 'cl-lib)
|
|
(require 'treemacs-macros))
|
|
|
|
(defvar treemacs--tag-follow-timer nil
|
|
"The idle timer object for `treemacs-tag-follow-mode'.
|
|
Active while tag follow mode is enabled and nil/cancelled otherwise.")
|
|
|
|
(defvar-local treemacs--previously-followed-tag-position nil
|
|
"Records the last node and path whose tags were expanded by tag follow mode.
|
|
Is made up of a cons of the last expanded node and its path. Both are kept to
|
|
make sure that the position has not become invalidated in the meantime.
|
|
When `treemacs-tag-follow-cleanup' it t this button's tags will be closed up
|
|
again when tag follow mode moves to another button.")
|
|
|
|
(defvar-local treemacs--imenu-cache nil
|
|
"Cache for the current buffer's flattened and sorted imenu index.
|
|
Is reset in `first-change-hook' will only be set again after the buffer has been
|
|
saved.")
|
|
|
|
(define-inline treemacs--reset-imenu-cache ()
|
|
"Reset `treemacs--imenu-cache'."
|
|
(inline-quote (setq-local treemacs--imenu-cache nil)))
|
|
|
|
(define-inline treemacs--forget-previously-follow-tag-btn ()
|
|
"Forget the previously followed button when treemacs is killed or rebuilt."
|
|
(inline-quote (setq treemacs--previously-followed-tag-position nil)))
|
|
|
|
;;;###autoload
|
|
(defun treemacs--flatten&sort-imenu-index ()
|
|
"Flatten current file's imenu index and sort it by tag position.
|
|
The tags are sorted into the order in which they appear, regardless of section
|
|
or nesting depth."
|
|
(if (eq major-mode 'pdf-view-mode)
|
|
'unsupported
|
|
(let* ((imenu-auto-rescan t)
|
|
(org? (eq major-mode 'org-mode))
|
|
(index (-> (buffer-file-name) (treemacs--get-imenu-index)))
|
|
(flat-index (if org?
|
|
(treemacs--flatten-org-mode-imenu-index index)
|
|
(treemacs--flatten-imenu-index index)))
|
|
(first (caar flat-index))
|
|
;; in org mode buffers the first item may not be a cons since its position
|
|
;; is still stored as a text property
|
|
(semantic? (and (consp first) (overlayp (cdr first))))
|
|
(compare-func (if (memq major-mode '(markdown-mode adoc-mode))
|
|
#'treemacs--compare-markdown-tag-paths
|
|
#'treemacs--compare-tag-paths)))
|
|
(cond
|
|
(semantic?
|
|
;; go ahead and just transform semantic overlays into markers so we dont
|
|
;; have trouble with comparisons when searching a position
|
|
(dolist (tag-path flat-index)
|
|
(let ((leaf (car tag-path))
|
|
(marker (make-marker)))
|
|
(setcdr leaf (move-marker marker (overlay-start (cdr leaf)))))))
|
|
;; same goes for an org index, since headlines with children store their
|
|
;; positions as text properties
|
|
(org?
|
|
(dolist (tag-path flat-index)
|
|
(let ((leaf (car tag-path)))
|
|
(when (stringp leaf)
|
|
(setcar tag-path (cons leaf (get-text-property 0 'org-imenu-marker leaf))))))))
|
|
(sort flat-index compare-func))))
|
|
|
|
(defun treemacs--flatten-imenu-index (index &optional path)
|
|
"Flatten a nested imenu INDEX to a flat list of tag paths.
|
|
The function works recursively with PATH being the already collected tag path in
|
|
each iteration.
|
|
|
|
INDEX: Imenu Tag Index
|
|
PATH: String List"
|
|
(declare (pure t) (side-effect-free t))
|
|
(let (result)
|
|
(--each index
|
|
(cond
|
|
((imenu--subalist-p it)
|
|
(setq result
|
|
(append result (treemacs--flatten-imenu-index (cdr it) (cons (car it) path)))))
|
|
;; make sure our leaf elements have a cdr where a location should be stored, it looks like there are cases,
|
|
;; at least on emacs 25, where we only get what amounts to an empty section
|
|
;; https://github.com/Alexander-Miller/treemacs/issues/283#issuecomment-427281977
|
|
((and (consp it) (cdr it))
|
|
(setq result (cons (cons it (nreverse (copy-sequence path))) result)))))
|
|
result))
|
|
|
|
(defun treemacs--flatten-org-mode-imenu-index (index &optional path)
|
|
"Specialisation of `treemacs--flatten-imenu-index' for org mode.
|
|
An index produced in an `org-mode' buffer is special in that tag sections act
|
|
not just as a means of grouping tags (being bags of functions, classes etc).
|
|
Each tag section is instead also a headline which can be moved to. The
|
|
flattening algorithm must therefore be slightly adjusted.
|
|
|
|
INDEX: Org Imenu Tag Index
|
|
PATH: String List"
|
|
(declare (pure t) (side-effect-free t))
|
|
(let (result)
|
|
(--each index
|
|
(let ((is-subalist? (imenu--subalist-p it)))
|
|
(setq result (cons (cons (if is-subalist? (car it) it) (nreverse (copy-sequence path))) result))
|
|
(when is-subalist?
|
|
(setq result (append result (treemacs--flatten-org-mode-imenu-index (cdr it) (cons (car it) path)))))))
|
|
result))
|
|
|
|
(define-inline treemacs--compare-tag-paths (p1 p2)
|
|
"Compare two tag paths P1 & P2 by the position of the tags they lead to.
|
|
Used to sort tag paths according to the order their tags appear in.
|
|
|
|
P1: Tag-Path
|
|
P2: Tag-Path"
|
|
(declare (pure t) (side-effect-free t))
|
|
(inline-letevals (p1 p2)
|
|
(inline-quote
|
|
(< (-> ,p1 (cdar) (marker-position))
|
|
(-> ,p2 (cdar) (marker-position))))))
|
|
|
|
(define-inline treemacs--compare-markdown-tag-paths (p1 p2)
|
|
"Specialised version of `treemacs--compare-tag-paths' for markdown and adoc.
|
|
P1: Tag-Path
|
|
P2: Tag-Path"
|
|
(declare (pure t) (side-effect-free t))
|
|
(inline-letevals (p1 p2)
|
|
(inline-quote
|
|
(< (cdar ,p1) (cdar ,p2)))))
|
|
|
|
(defun treemacs--find-index-pos (point list)
|
|
"Find the tag at POINT within a flat tag-path LIST.
|
|
Returns the tag-path whose tag is the last to be situated before POINT (meaning
|
|
that the next tag is after POINT and thus too far). Accounts for POINT being
|
|
located either before the first or after the last tag.
|
|
|
|
POINT: Int
|
|
LIST: Sorted Tag Path List"
|
|
(declare (pure t) (side-effect-free t))
|
|
(when list
|
|
(let ((first (car list))
|
|
(last (nth (1- (length list)) list)))
|
|
(cond
|
|
((<= point (-> first car cdr))
|
|
first)
|
|
((>= point (-> last car cdr))
|
|
last)
|
|
(t (treemacs--binary-index-search point list))))))
|
|
|
|
(cl-defun treemacs--binary-index-search (point list &optional (start 0) (end (1- (length list))))
|
|
"Find the position of POINT in LIST using a binary search.
|
|
Continuation of `treemacs--find-index-pos'. Search LIST between START & END.
|
|
|
|
POINT: Integer
|
|
LIST: Sorted Tag Path List
|
|
START: Integer
|
|
END: Integer"
|
|
(declare (pure t) (side-effect-free t))
|
|
(let* ((index (+ start (/ (- end start) 2)))
|
|
(elem1 (nth index list))
|
|
(elem2 (nth (1+ index) list))
|
|
(pos1 (-> elem1 car cdr))
|
|
(pos2 (-> elem2 car cdr)))
|
|
(cond
|
|
((and (> point pos1)
|
|
(<= point pos2))
|
|
elem1)
|
|
((> pos2 point)
|
|
(treemacs--binary-index-search point list 0 index))
|
|
((< pos2 point)
|
|
(treemacs--binary-index-search point list index end)))))
|
|
|
|
(defun treemacs--do-follow-tag (flat-index treemacs-window buffer-file project)
|
|
"Actual tag-follow implementation, run once the necessary data is gathered.
|
|
|
|
FLAT-INDEX: Sorted list of tag paths
|
|
TREEMACS-WINDOW: Window
|
|
BUFFER-FILE: Filepath
|
|
PROJECT: Project Struct"
|
|
(let* ((tag-path (treemacs--find-index-pos (point) flat-index))
|
|
(file-states '(file-node-open file-node-closed root-node-open root-node-closed))
|
|
(btn))
|
|
(when tag-path
|
|
(treemacs-without-following
|
|
(with-selected-window treemacs-window
|
|
(setq btn (treemacs-current-button))
|
|
(if btn
|
|
;; first move to the nearest file when we're on a tag
|
|
(if (memq (treemacs-button-get btn :state) '(tag-node-open tag-node-closed tag-node))
|
|
(while (not (memq (treemacs-button-get btn :state) file-states))
|
|
(setq btn (treemacs-button-get btn :parent)))
|
|
;; when that doesnt work move manually to the correct file
|
|
(-let [btn-path (treemacs-button-get btn :path)]
|
|
(unless (and (stringp btn-path) (treemacs-is-path buffer-file :same-as btn-path))
|
|
(treemacs-goto-file-node buffer-file project)
|
|
(setq btn (treemacs-current-button)))))
|
|
;; also move manually when there is no button at point
|
|
(treemacs-goto-file-node buffer-file project)
|
|
(setq btn (treemacs-current-button)))
|
|
;; close the button that was opened on the previous follow
|
|
(goto-char (treemacs-button-start btn))
|
|
;; imenu already rescanned when fetching the tag path
|
|
(let ((imenu-auto-rescan nil)
|
|
(new-file-btn))
|
|
;; make a copy since this tag-path will be saved as cache, and the two modifications made here
|
|
;; make it impossible to find the current position in `treemacs--find-index-pos'
|
|
(let* ((tag-path (copy-sequence tag-path))
|
|
(target-tag (list (car (car tag-path)))))
|
|
;; remove position marker from target tag and move it
|
|
;; to the end of the tag path
|
|
(setf tag-path (nconc (cdr tag-path) target-tag))
|
|
;; the tag path also needs its file
|
|
(setf tag-path (cons buffer-file tag-path))
|
|
;; workaround: goto routines assume that at least the very first element of the followed
|
|
;; path has a dom entry with a valid position, but this is not the case when moving to tags
|
|
;; in a previously never-expanded file node, so we first find the file to make sure its
|
|
;; position is known
|
|
(setf new-file-btn (treemacs-find-file-node buffer-file))
|
|
(treemacs-goto-node tag-path)
|
|
(when (and treemacs--previously-followed-tag-position
|
|
(not (equal (car treemacs--previously-followed-tag-position) new-file-btn)))
|
|
(-let [(prev-followed-pos . _) treemacs--previously-followed-tag-position]
|
|
(save-excursion
|
|
(when (eq 'file-node-open (treemacs-button-get prev-followed-pos :state))
|
|
(goto-char prev-followed-pos)
|
|
(treemacs--collapse-file-node prev-followed-pos)))))
|
|
(setf treemacs--previously-followed-tag-position
|
|
(cons new-file-btn (treemacs-button-get new-file-btn :path)))))
|
|
(hl-line-highlight)
|
|
(treemacs--evade-image)
|
|
(when treemacs-recenter-after-tag-follow
|
|
(treemacs--maybe-recenter treemacs-recenter-after-tag-follow)))))))
|
|
|
|
(defun treemacs--follow-tag-at-point ()
|
|
"Follow the tag at point in the treemacs view."
|
|
(interactive)
|
|
(let* ((treemacs-window (treemacs-get-local-window))
|
|
(buffer (current-buffer))
|
|
(buffer-file (when buffer (buffer-file-name)))
|
|
(project (treemacs--find-project-for-buffer)))
|
|
(when (and treemacs-window buffer-file project)
|
|
(condition-case e
|
|
(-when-let (index (or treemacs--imenu-cache
|
|
(treemacs--flatten&sort-imenu-index)))
|
|
(unless (eq index 'unsupported)
|
|
(unless (buffer-modified-p)
|
|
(setq-local treemacs--imenu-cache (copy-sequence index)))
|
|
(treemacs--do-follow-tag index treemacs-window buffer-file project)))
|
|
(imenu-unavailable (ignore e))
|
|
(error (treemacs-log-err "Encountered error while following tag at point: %s" e))))))
|
|
|
|
(defun treemacs--setup-tag-follow-mode ()
|
|
"Setup tag follow mode."
|
|
(treemacs-follow-mode -1)
|
|
(--each (buffer-list)
|
|
(with-current-buffer it
|
|
(treemacs--reset-imenu-cache)))
|
|
(add-hook 'first-change-hook #'treemacs--reset-imenu-cache)
|
|
(setq treemacs--tag-follow-timer
|
|
(run-with-idle-timer treemacs-tag-follow-delay t #'treemacs--follow-tag-at-point)))
|
|
|
|
(defun treemacs--tear-down-tag-follow-mode ()
|
|
"Tear down tag follow mode."
|
|
(remove-hook 'first-change-hook #'treemacs--reset-imenu-cache)
|
|
(when treemacs--tag-follow-timer
|
|
(cancel-timer treemacs--tag-follow-timer)))
|
|
|
|
;;;###autoload
|
|
(define-minor-mode treemacs-tag-follow-mode
|
|
"Toggle `treemacs-tag-follow-mode'.
|
|
|
|
This acts as more fine-grained alternative to `treemacs-follow-mode' and will
|
|
thus disable `treemacs-follow-mode' on activation. When enabled treemacs will
|
|
focus not only the file of the current buffer, but also the tag at point.
|
|
|
|
The follow action is attached to Emacs' idle timer and will run
|
|
`treemacs-tag-follow-delay' seconds of idle time. The delay value is not an
|
|
integer, meaning it accepts floating point values like 1.5.
|
|
|
|
Every time a tag is followed a re--scan of the imenu index is forced by
|
|
temporarily setting `imenu-auto-rescan' to t (though a cache is applied as long
|
|
as the buffer is unmodified). This is necessary to assure that creation or
|
|
deletion of tags does not lead to errors and guarantees an always up-to-date tag
|
|
view.
|
|
|
|
Note that in order to move to a tag in treemacs the treemacs buffer's window
|
|
needs to be temporarily selected, which will reset blink-cursor-mode's timer if
|
|
it is enabled. This will result in the cursor blinking seemingly pausing for a
|
|
short time and giving the appearance of the tag follow action lasting much
|
|
longer than it really does."
|
|
:init-value nil
|
|
:global t
|
|
:lighter nil
|
|
:group 'treemacs
|
|
(if treemacs-tag-follow-mode
|
|
(treemacs--setup-tag-follow-mode)
|
|
(treemacs--tear-down-tag-follow-mode)))
|
|
|
|
(provide 'treemacs-tag-follow-mode)
|
|
|
|
;;; treemacs-tag-follow-mode.el ends here
|