321 lines
14 KiB
EmacsLisp
321 lines
14 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:
|
|
|
|
;; File event watch and reaction implementation.
|
|
|
|
;; Open directories are put under watch and file changes event
|
|
;; collected even if filewatch-mode is disabled. This allows to
|
|
;; remove deleted files from all the caches they are in. Activating
|
|
;; filewatch-mode will therefore only enable automatic refresh of
|
|
;; treemacs buffers.
|
|
|
|
;;; Code:
|
|
|
|
(require 'dash)
|
|
(require 's)
|
|
(require 'ht)
|
|
(require 'filenotify)
|
|
(require 'treemacs-core-utils)
|
|
(require 'treemacs-async)
|
|
(require 'treemacs-dom)
|
|
(require 'treemacs-rendering)
|
|
(eval-when-compile
|
|
(require 'treemacs-macros)
|
|
(require 'inline))
|
|
|
|
(defvar treemacs--collapsed-filewatch-index (make-hash-table :size 100 :test #'equal)
|
|
"Keeps track of dirs under filewatch due to being collapsed into one.
|
|
|
|
Collapsed directories require special handling since all directories of a series
|
|
need to be put under watch so as to be notified when the collapsed structure
|
|
needs to change, but removing the file watch is not straightforward:
|
|
|
|
Assume a series of directories are collapsed into one as \"/c1/c2/c3/c4\" and a
|
|
new file is created in \"/c1/c2\". A refresh is started and only \"/c1/c2\" is
|
|
collapsed now, c3 and c4 are no longer part of the treemacs view and must be
|
|
removed from the filewatch list. However the event that triggered the refresh
|
|
was one of a file being created, so it is not possible to know that c3 and c4
|
|
need to stop being watched unless one also knows that they and c2 are under file
|
|
watch because they have been collapsed.
|
|
|
|
This is why this hash is used to keep track of collapsed directories under file
|
|
watch.")
|
|
|
|
(defvar treemacs--filewatch-index (make-hash-table :size 100 :test 'equal)
|
|
"Hash of all directories being watched for changes.
|
|
A file path is the key, the value is a cons, its car is a list of the treemacs
|
|
buffers watching that path, its cdr is the watch descriptor.")
|
|
|
|
(defvar treemacs--refresh-timer nil
|
|
"Timer that will run a refresh after `treemacs-file-event-delay' ms.
|
|
Stored here to allow it to be cancelled by a manual refresh.")
|
|
|
|
(define-inline treemacs--start-filewatch-timer ()
|
|
"Start the filewatch timer if it is not already running."
|
|
(inline-quote
|
|
(unless treemacs--refresh-timer
|
|
(setf treemacs--refresh-timer
|
|
(run-with-timer (/ treemacs-file-event-delay 1000) nil
|
|
#'treemacs--process-file-events)))))
|
|
|
|
(define-inline treemacs--cancel-refresh-timer ()
|
|
"Cancel a the running refresh timer if it is active."
|
|
(inline-quote
|
|
(when treemacs--refresh-timer
|
|
(cancel-timer treemacs--refresh-timer)
|
|
(setq treemacs--refresh-timer nil))))
|
|
|
|
(define-inline treemacs--start-watching (path &optional collapse)
|
|
"Watch PATH for file system events.
|
|
Assumes to be run in the treemacs buffer as it will set PATH to be watched by
|
|
`current-buffer'.
|
|
Also add PATH to `treemacs--collapsed-filewatch-index' when COLLAPSE is non-nil.
|
|
|
|
PATH: Filepath
|
|
COLLAPSE: Bool"
|
|
(inline-letevals (path collapse)
|
|
(inline-quote
|
|
(progn
|
|
(when ,collapse
|
|
(ht-set! treemacs--collapsed-filewatch-index ,path t))
|
|
(-if-let (watch-info (ht-get treemacs--filewatch-index ,path))
|
|
;; just add current buffer to watch list if path is watched already
|
|
(unless (memq (current-buffer) (car watch-info))
|
|
(setcar watch-info (cons (current-buffer) (car watch-info))))
|
|
;; if the Tramp connection does not support watches, don't show an error
|
|
;; every time a watch is started.
|
|
(treemacs-with-ignored-errors
|
|
((file-notify-error "No file notification program found"))
|
|
;; make new entry otherwise and set a new watcher
|
|
(ht-set! treemacs--filewatch-index
|
|
,path
|
|
(cons (list (current-buffer))
|
|
(file-notify-add-watch ,path '(change) #'treemacs--filewatch-callback)))))))))
|
|
|
|
(define-inline treemacs--stop-watching (path &optional all)
|
|
"Stop watching PATH for file events.
|
|
This also means stopping the watch over all dirs below path.
|
|
Must be called inside the treemacs buffer since it will remove `current-buffer'
|
|
from PATH's watch list. Does not apply if this is called in reaction to a file
|
|
being deleted. In this case ALL is t and all buffers watching PATH will be
|
|
removed from the filewatch hashes.
|
|
|
|
PATH: Filepath
|
|
ALL: Bool"
|
|
(inline-letevals (path all)
|
|
(inline-quote
|
|
(let (to-remove)
|
|
(treemacs--maphash treemacs--filewatch-index (watched-path watch-info)
|
|
(when (treemacs-is-path watched-path :in ,path)
|
|
(let ((watching-buffers (car watch-info))
|
|
(watch-descr (cdr watch-info)))
|
|
(if ,all
|
|
(progn
|
|
(file-notify-rm-watch watch-descr)
|
|
(ht-remove! treemacs--collapsed-filewatch-index watched-path)
|
|
(push watched-path to-remove))
|
|
(when (memq (current-buffer) watching-buffers)
|
|
(if (cdr watching-buffers)
|
|
(setcar watch-info (delq (current-buffer) watching-buffers))
|
|
(file-notify-rm-watch watch-descr)
|
|
(ht-remove! treemacs--collapsed-filewatch-index watched-path)
|
|
(push watched-path to-remove)))))))
|
|
(dolist (it to-remove)
|
|
(ht-remove! treemacs--filewatch-index it))))))
|
|
|
|
(define-inline treemacs--is-event-relevant? (event)
|
|
"Decide if EVENT is relevant to treemacs or should be ignored.
|
|
An event counts as relevant when
|
|
1) The event's action is not \"stopped\".
|
|
2) The event's action is not \"changed\" while `treemacs-git-mode' is disabled
|
|
3) The event's file will not return t when given to any of the functions which
|
|
are part of `treemacs-ignored-file-predicates'."
|
|
(declare (side-effect-free t))
|
|
(inline-letevals (event)
|
|
(inline-quote
|
|
(when (with-no-warnings treemacs-filewatch-mode)
|
|
(let ((action (cadr ,event)))
|
|
(not (or (eq action 'stopped)
|
|
(and (eq action 'changed)
|
|
(not treemacs-git-mode))
|
|
(and treemacs-hide-gitignored-files-mode
|
|
(let* ((file (caddr ,event))
|
|
(parent (treemacs--parent-dir file))
|
|
(cache (ht-get treemacs--git-cache parent)))
|
|
(and cache (eq 'treemacs-git-ignored-face (ht-get cache file)))))
|
|
(let* ((dir (caddr ,event))
|
|
(filename (treemacs--filename dir)))
|
|
(--any? (funcall it filename dir) treemacs-ignored-file-predicates)))))))))
|
|
|
|
(define-inline treemacs--set-refresh-flags (location type path)
|
|
"Set refresh flags at LOCATION for TYPE and PATH in the dom of every buffer.
|
|
Also start the refresh timer if it's not started already."
|
|
(inline-letevals (location type path)
|
|
(inline-quote
|
|
(progn
|
|
(when (ht-get treemacs--collapsed-filewatch-index ,path)
|
|
(ht-remove! treemacs--collapsed-filewatch-index ,path)
|
|
(treemacs--stop-watching ,path))
|
|
(treemacs-run-in-every-buffer
|
|
(--when-let (treemacs-find-in-dom ,location)
|
|
(let ((current-flag (assoc ,path (treemacs-dom-node->refresh-flag it))))
|
|
(pcase (cdr current-flag)
|
|
(`nil
|
|
(push (cons ,path ,type) (treemacs-dom-node->refresh-flag it)))
|
|
('created
|
|
(when (eq ,type 'deleted)
|
|
(setf (cdr current-flag) 'deleted)))
|
|
('deleted
|
|
(when (eq ,type 'created)
|
|
(setf (cdr current-flag) 'created)))
|
|
('changed
|
|
(when (eq ,type 'deleted)
|
|
(setf (cdr current-flag) 'deleted))))))
|
|
(treemacs--start-filewatch-timer))))))
|
|
|
|
|
|
(defun treemacs--filewatch-callback (event)
|
|
"Add EVENT to the list of file change events.
|
|
Do nothing if this event's file is irrelevant as per
|
|
`treemacs--is-event-relevant?'. Otherwise start a timer to process the
|
|
collected events if it has not been started already. Also immediately remove
|
|
the changed file from caches if it has been deleted instead of waiting for file
|
|
processing."
|
|
(when (treemacs--is-event-relevant? event)
|
|
(-let [(_ event-type path) event]
|
|
(when (eq 'deleted event-type)
|
|
(treemacs--on-file-deletion path :no-buffer-delete))
|
|
(if (eq 'renamed event-type)
|
|
(let ((old-name path)
|
|
(new-name (cadddr event)))
|
|
(treemacs-run-in-every-buffer
|
|
(treemacs--on-rename old-name new-name (with-no-warnings treemacs-filewatch-mode)))
|
|
(treemacs--set-refresh-flags (treemacs--parent old-name) 'deleted old-name)
|
|
(when (--none? (funcall it (treemacs--filename new-name) new-name) treemacs-ignored-file-predicates)
|
|
(treemacs--set-refresh-flags (treemacs--parent new-name) 'created new-name)))
|
|
(treemacs--set-refresh-flags (treemacs--parent path) event-type path)))))
|
|
|
|
(define-inline treemacs--do-process-file-events ()
|
|
"Dumb helper function.
|
|
Extracted only so `treemacs--process-file-events' can decide when to call
|
|
`save-excursion' without code duplication."
|
|
(inline-quote
|
|
(treemacs-run-in-every-buffer
|
|
(treemacs-save-position
|
|
(-let [treemacs--no-messages (or treemacs-silent-refresh treemacs-silent-filewatch)]
|
|
(dolist (project (treemacs-workspace->projects workspace))
|
|
(-when-let (root-node (-> project (treemacs-project->path) (treemacs-find-in-dom)))
|
|
(treemacs--recursive-refresh-descent root-node project)))))
|
|
(hl-line-highlight))))
|
|
|
|
(defun treemacs--process-file-events ()
|
|
"Process the file events that have been collected.
|
|
Stop watching deleted dirs and refresh all the buffers that need updating."
|
|
(setf treemacs--refresh-timer nil)
|
|
(treemacs-without-following
|
|
(if (eq treemacs--in-this-buffer t)
|
|
(treemacs--do-process-file-events)
|
|
;; need to save excursion here because an update when the treemacs window is not visible
|
|
;; will actually move point in the current buffer
|
|
;; TODO(2019/07/18): check if this is still necessary after granular filewatch is done
|
|
(save-excursion
|
|
(treemacs--do-process-file-events)))))
|
|
|
|
(defun treemacs--stop-filewatch-for-current-buffer ()
|
|
"Called when a treemacs buffer is torn down/killed.
|
|
Will stop file watch on every path watched by this buffer."
|
|
(let ((buffer (treemacs-get-local-buffer))
|
|
(to-remove))
|
|
(treemacs--maphash treemacs--filewatch-index (watched-path watch-info)
|
|
(-let [(watching-buffers . watch-descr) watch-info]
|
|
(when (memq buffer watching-buffers)
|
|
(if (= 1 (length watching-buffers))
|
|
(progn
|
|
(file-notify-rm-watch watch-descr)
|
|
(ht-remove! treemacs--collapsed-filewatch-index watched-path)
|
|
(push watched-path to-remove))
|
|
(setcar watch-info (delq buffer watching-buffers))))))
|
|
(dolist (it to-remove)
|
|
(ht-remove! treemacs--filewatch-index it))))
|
|
|
|
(defun treemacs--stop-watching-all ()
|
|
"Cancel any and all running file watch processes.
|
|
Clear the filewatch and collapsed filewatch indices.
|
|
Reset the refresh flags of every buffer.
|
|
|
|
Called when filewatch mode is disabled."
|
|
(treemacs-run-in-every-buffer
|
|
(treemacs--maphash treemacs-dom (_ node)
|
|
(setf (treemacs-dom-node->refresh-flag node) nil)))
|
|
(treemacs--maphash treemacs--filewatch-index (_ watch-info)
|
|
(file-notify-rm-watch (cdr watch-info)))
|
|
(ht-clear! treemacs--filewatch-index)
|
|
(ht-clear! treemacs--collapsed-filewatch-index))
|
|
|
|
(define-inline treemacs--tear-down-filewatch-mode ()
|
|
"Stop watch processes, throw away file events, stop the timer."
|
|
(inline-quote
|
|
(progn
|
|
(treemacs--stop-watching-all)
|
|
(treemacs--cancel-refresh-timer))))
|
|
|
|
(define-minor-mode treemacs-filewatch-mode
|
|
"Minor mode to let treemacs auto-refresh itself on file system changes.
|
|
Activating this mode enables treemacs to watch the files it is displaying (and
|
|
only those) for changes and automatically refresh its view when it detects a
|
|
change that it decides is relevant.
|
|
|
|
A file change event is relevant for treemacs if a new file has been created or
|
|
deleted or a file has been changed and `treemacs-git-mode' is enabled. Events
|
|
caused by files that are ignored as per `treemacs-ignored-file-predicates' are
|
|
counted as not relevant.
|
|
|
|
The refresh is not called immediately after an event was received, treemacs
|
|
instead waits `treemacs-file-event-delay' ms to see if any more files have
|
|
changed to avoid having to refresh multiple times over a short period of time.
|
|
|
|
Due to limitations in the underlying kqueue library this mode may not be able to
|
|
track file modifications on MacOS, making it miss potentially useful updates
|
|
when used in combination with `treemacs-git-mode.'
|
|
|
|
The watch mechanism only applies to directories opened *after* this mode has
|
|
been activated. This means that to enable file watching in an already existing
|
|
treemacs buffer it needs to be torn down and rebuilt by calling `treemacs' or
|
|
`treemacs-projectile'.
|
|
|
|
Turning off this mode is, on the other hand, instantaneous - it will immediately
|
|
turn off all existing file watch processes and outstanding refresh actions."
|
|
:init-value nil
|
|
:global t
|
|
:lighter nil
|
|
:group 'treemacs
|
|
(unless treemacs-filewatch-mode
|
|
(treemacs--tear-down-filewatch-mode)))
|
|
|
|
;; in case we don't have a file notification library (like on travis CI)
|
|
(unless file-notify--library
|
|
(fset 'treemacs--start-watching (lambda (_x &optional _y) (ignore)))
|
|
(fset 'treemacs--stop-watching (lambda (_x &optional _y) (ignore))))
|
|
|
|
(treemacs-only-during-init (treemacs-filewatch-mode))
|
|
|
|
(provide 'treemacs-filewatch-mode)
|
|
|
|
;;; treemacs-filewatch-mode.el ends here
|