branch: elpa/javelin
commit affe1300fb8dc42ff55db70e948867b21a86cc62
Author: Damian Barabonkov <[email protected]>
Commit: Damian Barabonkov <[email protected]>

    feat: Replace file-based cache with Emacs bookmarks
---
 javelin.el | 427 ++++++++++++++++++++++---------------------------------------
 1 file changed, 154 insertions(+), 273 deletions(-)

diff --git a/javelin.el b/javelin.el
index 0c177aa4e02..11ba2309d52 100644
--- a/javelin.el
+++ b/javelin.el
@@ -34,10 +34,9 @@
 (require 'subr-x)
 (require 'cl-lib)
 (require 'project)
+(require 'bookmark)
+(require 'vc-git)
 
-;; Since these are optional, simply declare the external functions
-;; that may or may not be available
-(declare-function magit-get-current-branch "magit")
 
 ;;; Code:
 
@@ -47,19 +46,14 @@
   "Organize bookmarks by project and branch."
   :group 'tools)
 
-(defcustom javelin-cache-dir (concat user-emacs-directory ".local/javelin/")
-  "Where the cache will be saved."
-  :type 'string)
-
 (defcustom javelin-separate-by-branch t
   "Javelin separated by branch."
   :type 'boolean)
 
 (defcustom javelin-default-positions-namespace "javelin"
   "Namespace for javelin positions when not in a project.
-Used as a fallback identifier for the cache file when javelin cannot
-detect a project root. The namespace helps isolate javelin positions
-for different contexts."
+Used as a fallback identifier when javelin cannot detect a project root.
+The namespace helps isolate javelin positions for different contexts."
   :type 'string)
 
 (defcustom javelin-disable-confirmation nil
@@ -67,15 +61,24 @@ for different contexts."
   :type 'boolean)
 ;;; --- Customizable variables ---
 
-(defvar javelin--markers (make-hash-table :test 'equal)
-  "Hash table mapping (filepath . javelin-position) to markers.
-Markers provide live position tracking within an Emacs session,
-automatically adjusting when text is inserted or deleted above them.")
+(defconst javelin--bookmark-prefix "javelin:"
+  "Prefix for javelin bookmark names.")
+
+(defvar javelin--bookmarks-loaded nil
+  "Non-nil when javelin has loaded bookmark data.")
 
-(defconst javelin--cache-version 1
-  "Current version of the javelin cache file schema.
-Increment this when making incompatible changes to the cache format.
-Cache files with a different version will be deleted and recreated.")
+(defun javelin--ensure-bookmarks-loaded ()
+  "Load the default bookmark file once if it exists."
+  (unless javelin--bookmarks-loaded
+    (setq javelin--bookmarks-loaded t)
+    (when (and (boundp 'bookmark-default-file)
+               (file-exists-p bookmark-default-file))
+      (bookmark-load bookmark-default-file t))))
+
+(defun javelin--bookmark-save-silent ()
+  "Save bookmarks without emitting messages."
+  (let ((inhibit-message t))
+    (bookmark-save)))
 
 (defun javelin--get-project-root ()
   "Get the project root.
@@ -98,207 +101,87 @@ Returns the project name as a string, or nil if there is 
no project."
 
 (defun javelin--get-branch-name ()
   "Get the branch name for javelin.
-Uses magit if available, otherwise falls back to git CLI.
 Returns nil if not in a git repository."
-  (if (featurep 'magit)
-      (magit-get-current-branch)
-    (let ((default-directory (javelin--get-project-root)))
-      (with-temp-buffer
-        (when (zerop (call-process "git" nil t nil "rev-parse" "--abbrev-ref" 
"HEAD"))
-          (string-trim (buffer-string)))))))
-
-(defun javelin--cache-key ()
-  "Key to save current file on cache.
-Returns `javelin-default-positions-namespace' if there is no project."
-  ;; Use `url-hexify-string' to percent-encode the cache key, making it
-  ;; filename-friendly by escaping "/" and other forbidden characters.
+  (car (vc-git-branches)))
+
+(defun javelin--bookmark-namespace ()
+  "Namespace for javelin bookmark names."
   (let ((project-name (or (javelin--get-project-name) 
javelin-default-positions-namespace)))
-    (url-hexify-string
-     (if-let ((branch (and javelin-separate-by-branch 
(javelin--get-branch-name))))
-         (concat project-name "#" branch)
-       project-name))))
-
-(defun javelin--cache-file-name ()
-  "File name for javelin on current project."
-  (concat javelin-cache-dir (javelin--cache-key) ".json"))
-
-(defun javelin--ensure-cache-file ()
-  "Create javelin cache dir and file if they don't exist."
-  (unless (file-directory-p javelin-cache-dir)
-    (make-directory javelin-cache-dir t))
-  (unless (file-exists-p (javelin--cache-file-name))
-    (write-region (json-serialize '()) nil (javelin--cache-file-name))))
-
-(defun javelin--sort-positions (data)
-  "Sort DATA by `javelin_position' in increasing order."
-  (seq-sort (lambda (a b)
-              (< (alist-get 'javelin_position a)
-                 (alist-get 'javelin_position b)))
-            data))
-
-(defun javelin--cache-compatible-p (data)
-  "Check if DATA from cache file is compatible with the current schema.
-Returns non-nil if compatible, nil otherwise."
-  (and data
-       (listp data)
-       (alist-get 'version data)
-       (= (alist-get 'version data) javelin--cache-version)))
-
-(defun javelin--read-javelin-positions ()
-  "Read and parse the javelin positions cache JSON file.
-Returns a list of alists with `javelin_position', `filepath', and `point' keys.
-If the cache file is incompatible, it is deleted and an empty list is 
returned."
-  (let ((data (if (file-exists-p (javelin--cache-file-name))
-                  (condition-case nil
-                     (with-temp-buffer
-                       (insert-file-contents (javelin--cache-file-name))
-                       (goto-char (point-min))
-                       (json-parse-buffer :object-type 'alist
-                                           :array-type 'list))
-                    ;; JSON file got corrupted, delete it and return empty list
-                    (error
-                     (delete-file (javelin--cache-file-name))
-                     nil))
-                nil)))
-    (if (javelin--cache-compatible-p data)
-        (javelin--sort-positions (alist-get 'positions data))
-      (when (file-exists-p (javelin--cache-file-name))
-        (delete-file (javelin--cache-file-name)))
-      '())))
-
-(defun javelin--write-javelin-positions (data)
-  "Write DATA to the javelin JSON file.
-DATA should be a list of alists with `javelin_position', `filepath',
-and `point' keys.  Entries are sorted by `javelin_position' in increasing
-order before writing.  Creates the cache file if it does not exist.
-If DATA is empty, deletes the cache file instead."
-  (if (null data)
-      (when (file-exists-p (javelin--cache-file-name))
-        (delete-file (javelin--cache-file-name)))
-    (javelin--ensure-cache-file)
-    (let ((cache-data `((version . ,javelin--cache-version)
-                        (positions . ,(vconcat (javelin--sort-positions 
data))))))
-      (write-region (json-serialize cache-data) nil 
(javelin--cache-file-name)))))
-
-(defun javelin--get-entry-by-position (javelin-position)
-  "Get the full entry alist for a given JAVELIN-POSITION.
-Returns nil if not found.
-Entry contains `javelin_position', `filepath', and `point' keys."
-  (let ((data (javelin--read-javelin-positions)))
-    (seq-find (lambda (item)
-                (= (alist-get 'javelin_position item) javelin-position))
-              data)))
-
-(defun javelin--get-filepath-by-position (javelin-position)
-  "Get the filepath for a given JAVELIN-POSITION.
-Returns nil if not found."
-  (alist-get 'filepath (javelin--get-entry-by-position javelin-position)))
-
-(defun javelin--set-entry-by-position (javelin-position filepath pos)
-  "Set FILEPATH and POS for a given JAVELIN-POSITION.
-Updates existing entry or adds a new one.
-Also creates a marker at POS in the current buffer and caches it."
-  (let* ((data (javelin--read-javelin-positions))
-         (existing (seq-find (lambda (item)
-                               (= (alist-get 'javelin_position item) 
javelin-position))
-                             data)))
-    (if existing
-        ;; Update existing entry
-        (progn
-          (setf (alist-get 'filepath existing) filepath)
-          (setf (alist-get 'point existing) pos))
-      ;; Add new entry
-      (push `((javelin_position . ,javelin-position) (filepath . ,filepath) 
(point . ,pos)) data))
-    (javelin--write-javelin-positions data)
-    ;; Create and cache a marker for this position
-    (let ((marker (make-marker)))
-      (set-marker marker pos (current-buffer))
-      (puthash (cons filepath javelin-position) marker javelin--markers))))
-
-(defun javelin--remove-filepath-by-position (javelin-position)
-  "Remove entry with JAVELIN-POSITION from the javelin list."
-  (let ((data (javelin--read-javelin-positions)))
-    (javelin--write-javelin-positions
-     (seq-remove (lambda (item)
-                   (= (alist-get 'javelin_position item) javelin-position))
-                 data))))
+    (if-let ((branch (and javelin-separate-by-branch 
(javelin--get-branch-name))))
+        (format "%s#%s" project-name branch)
+      project-name)))
+
+(defun javelin--bookmark-name (javelin-position)
+  "Bookmark name for JAVELIN-POSITION."
+  (format "%s%s:%d" javelin--bookmark-prefix (javelin--bookmark-namespace) 
javelin-position))
+
+(defun javelin--bookmark-positions ()
+  "Return list of (position . bookmark-name) in the current namespace."
+  (javelin--ensure-bookmarks-loaded)
+  (let* ((namespace (javelin--bookmark-namespace))
+         (pattern (format "^%s%s:\\([1-9]\\)$"
+                          (regexp-quote javelin--bookmark-prefix)
+                          (regexp-quote namespace)))
+         (positions nil))
+    (dolist (bm bookmark-alist)
+      (let ((name (car bm)))
+        (when (string-match pattern name)
+          (push (cons (string-to-number (match-string 1 name)) name) 
positions))))
+    (seq-sort-by #'car #'< positions)))
+
+(defun javelin--bookmark-record (javelin-position)
+  "Return the bookmark record for JAVELIN-POSITION, or nil."
+  (bookmark-get-bookmark (javelin--bookmark-name javelin-position) 'noerror))
+
+(defun javelin--position-for-current-buffer ()
+  "Return the javelin position for the current buffer, or nil." 
+  (let ((current-file (buffer-file-name))
+        (current-buffer-name (buffer-name))
+        (positions (javelin--bookmark-positions))
+        found)
+    (dolist (pair positions)
+      (let* ((record (bookmark-get-bookmark (cdr pair) 'noerror))
+             (file (and record (bookmark-get-filename record)))
+             (buffer-name (and record (bookmark-prop-get record 
'buffer-name))))
+        (when (or (and file current-file
+                       (string= (expand-file-name file) (expand-file-name 
current-file)))
+                  (and (null file) buffer-name (string= buffer-name 
current-buffer-name)))
+          (setq found (car pair)))))
+    found))
+
+(defun javelin--bookmark-display-target (record)
+  "Return a display-friendly target for RECORD."
+  (let* ((file (bookmark-get-filename record))
+         (buffer-name (bookmark-prop-get record 'buffer-name))
+         (project-root (javelin--get-project-root)))
+    (cond
+     (file
+      (if project-root
+          (file-relative-name file project-root)
+        file))
+     (buffer-name buffer-name)
+     (t "<unknown>"))))
 
 (defun javelin--next-available-position ()
   "Get the next available javelin position.
 Scans positions 1-9 and returns the first gap found.
 Returns nil if all positions 1-9 are taken."
-  (let ((data (javelin--read-javelin-positions)))
+  (let ((taken (mapcar #'car (javelin--bookmark-positions))))
     (cl-loop for pos from 1 to 9
-             unless (seq-find (lambda (item)
-                                (= (alist-get 'javelin_position item) pos))
-                              data)
+             unless (member pos taken)
              return pos)))
 
-(defun javelin--buffer-filepath-relative-to-root ()
-  "Get buffer file name relative to project root.
-Returns the relative path if in a project, otherwise the absolute path.
-For non-file buffers, returns the buffer name."
-  (if-let ((filepath (buffer-file-name)))
-      (let ((project-root (javelin--get-project-root)))
-        (if project-root
-            (file-relative-name filepath project-root)
-          filepath))
-    (buffer-name)))
-
 ;;; --- Go-to functions ---
 
 ;;;###autoload
 (defun javelin-go-to (javelin-number)
-  "Go to specific file or buffer of the javelin by JAVELIN-NUMBER.
-If already in the target buffer, just navigate to the saved position.
-Uses markers for live position tracking within a session."
-  (let* ((entry (javelin--get-entry-by-position javelin-number))
-         (name (alist-get 'filepath entry))
-         (saved-point (alist-get 'point entry))
-         (project-root (javelin--get-project-root))
-         (full-file-name (when name
-                           (if project-root
-                               (concat project-root name)
-                             name)))
-         (marker-key (when name (cons name javelin-number)))
-         (cached-marker (when marker-key (gethash marker-key 
javelin--markers)))
-         (target-pos (cond
-                      ;; Use cached marker if it's valid (buffer still exists)
-                      ((and cached-marker (marker-buffer cached-marker))
-                       cached-marker)
-                      ;; Fall back to saved point
-                      (saved-point saved-point)
-                      ;; Default to beginning of buffer
-                      (t 1))))
-    (cond
-     ((null name)
-      (message "No file javelined to position %d" javelin-number))
-     ;; Already in the target buffer - just go to position
-     ((and full-file-name
-           (buffer-file-name)
-           (string= (expand-file-name full-file-name)
-                    (expand-file-name (buffer-file-name))))
-      (goto-char (if (markerp target-pos) (marker-position target-pos) 
target-pos)))
-     ;; File exists - open it and go to position
-     ((and full-file-name (file-exists-p full-file-name))
-      (find-file full-file-name)
-      (goto-char (if (markerp target-pos) (marker-position target-pos) 
target-pos))
-      ;; Create a marker for future jumps in this session if we used a raw 
point
-      (unless (markerp target-pos)
-        (let ((marker (make-marker)))
-          (set-marker marker (point) (current-buffer))
-          (puthash marker-key marker javelin--markers))))
-     ;; Buffer exists (non-file buffer)
-     ((get-buffer name)
-      (switch-to-buffer name)
-      (goto-char (if (markerp target-pos) (marker-position target-pos) 
target-pos))
-      ;; Create a marker for future jumps in this session if we used a raw 
point
-      (unless (markerp target-pos)
-        (let ((marker (make-marker)))
-          (set-marker marker (point) (current-buffer))
-          (puthash marker-key marker javelin--markers))))
-     (t
-      (message "%s not found." name)))))
+  "Go to specific file or buffer of the javelin by JAVELIN-NUMBER."
+  (interactive "nJavelin position: ")
+  (javelin--ensure-bookmarks-loaded)
+  (let ((name (javelin--bookmark-name javelin-number)))
+    (if (bookmark-get-bookmark name 'noerror)
+        (bookmark-jump name)
+      (message "No file javelined to position %d" javelin-number))))
 
 ;;;###autoload
 (defun javelin-go-to-1 ()
@@ -359,8 +242,15 @@ Uses markers for live position tracking within a session."
 ;;;###autoload
 (defun javelin-delete (javelin-number)
   "Delete an item of the javelin. JAVELIN-NUMBER: Position to delete."
-  (javelin--remove-filepath-by-position javelin-number)
-  (message "Deleted javelin position %d" javelin-number))
+  (interactive "nJavelin position: ")
+  (javelin--ensure-bookmarks-loaded)
+  (let ((name (javelin--bookmark-name javelin-number)))
+    (if (bookmark-get-bookmark name 'noerror)
+        (progn
+          (bookmark-delete name)
+          (javelin--bookmark-save-silent)
+          (message "Deleted javelin position %d" javelin-number))
+      (message "No javelin position %d to delete" javelin-number))))
 
 ;;;###autoload
 (defun javelin-delete-1 ()
@@ -421,13 +311,12 @@ Uses markers for live position tracking within a session."
 ;;;###autoload
 (defun javelin-assign-to (javelin-number)
   "Assign the current buffer to a specific position in javelin.
-JAVELIN-NUMBER: The position (1-9) to assign the current buffer to.
-Also records the current point position for later navigation."
-  (let ((file-to-add (javelin--buffer-filepath-relative-to-root))
-        (current-pos (point)))
-    (javelin--set-entry-by-position javelin-number file-to-add current-pos)
-    (message "Assigned %s:%d to javelin position %d"
-             file-to-add (line-number-at-pos current-pos) javelin-number)))
+JAVELIN-NUMBER: The position (1-9) to assign the current buffer to."
+  (interactive "nJavelin position: ")
+  (javelin--ensure-bookmarks-loaded)
+  (bookmark-set (javelin--bookmark-name javelin-number))
+  (javelin--bookmark-save-silent)
+  (message "Assigned current buffer to javelin position %d" javelin-number))
 
 ;;;###autoload
 (defun javelin-assign-to-1 ()
@@ -488,7 +377,7 @@ Also records the current point position for later 
navigation."
   "Go to javelin position if occupied, otherwise assign current buffer to it.
 JAVELIN-NUMBER: The position (1-9) to go to or assign.
 With FORCE (or prefix arg \\[universal-argument]), always assign even if 
position is occupied."
-  (if (and (not force) (javelin--get-filepath-by-position javelin-number))
+  (if (and (not force) (javelin--bookmark-record javelin-number))
       (javelin-go-to javelin-number)
     (javelin-assign-to javelin-number)))
 
@@ -555,74 +444,56 @@ With FORCE (or prefix arg \\[universal-argument]), always 
assign."
   (interactive "P")
   (javelin-go-or-assign-to 9 force))
 
+(defun javelin--cycle-position (direction)
+  "Cycle to the next or previous javelin position.
+DIRECTION should be 1 for next, -1 for previous."
+  (let ((positions (mapcar #'car (javelin--bookmark-positions))))
+    (if (null positions)
+        (message "No javelin positions set.")
+      (let* ((current (javelin--position-for-current-buffer))
+             (current-index (or (cl-position current positions) -1))
+             (new-index (mod (+ current-index direction) (length positions)))
+             (new-pos (nth new-index positions)))
+        (when new-pos
+          (javelin-go-to new-pos))))))
+
 ;;;###autoload
 (defun javelin-go-to-next ()
   "Go to the next buffer in javelin."
   (interactive)
-  (let* ((data (javelin--read-javelin-positions))
-         (files (mapcar (lambda (item) (alist-get 'filepath item)) data))
-         (current-file (javelin--buffer-filepath-relative-to-root))
-         (current-index (or (cl-position current-file files :test 'string=) 
-1))
-         (next-index (mod (+ current-index 1) (length files)))
-         (next-item (nth next-index data)))
-    (when next-item
-      (javelin-go-to (alist-get 'javelin_position next-item)))))
+  (javelin--cycle-position 1))
 
 ;;;###autoload
 (defun javelin-go-to-prev ()
   "Go to the previous buffer in javelin."
   (interactive)
-  (let* ((data (javelin--read-javelin-positions))
-         (files (mapcar (lambda (item) (alist-get 'filepath item)) data))
-         (current-file (javelin--buffer-filepath-relative-to-root))
-         (current-index (or (cl-position current-file files :test 'string=) 
-1))
-         (prev-index (mod (+ current-index (length files) -1) (length files)))
-         (prev-item (nth prev-index data)))
-    (when prev-item
-      (javelin-go-to (alist-get 'javelin_position prev-item)))))
+  (javelin--cycle-position -1))
 
 ;;;###autoload
 (defun javelin-add-file ()
-  "Add current buffer to javelin at the next available position.
-Also records the current point position for later navigation."
-  (interactive)
-  (let* ((file-to-add (javelin--buffer-filepath-relative-to-root))
-         (current-pos (point))
-         (data (javelin--read-javelin-positions))
-         (existing (seq-find (lambda (item)
-                               (string= (alist-get 'filepath item) 
file-to-add))
-                             data)))
-    (if existing
-        (message "This buffer is already marked in javelin.")
-      (let ((next-num (javelin--next-available-position)))
-        (javelin--set-entry-by-position next-num file-to-add current-pos)
-        (message "Buffer added to javelin at position %d." next-num)))))
+  "Add current buffer to javelin at the next available position."
+  (interactive)
+  (if (javelin--position-for-current-buffer)
+      (message "This buffer is already marked in javelin.")
+    (let ((next-num (javelin--next-available-position)))
+      (if (null next-num)
+          (message "All javelin positions are already assigned.")
+        (javelin-assign-to next-num)))))
 
 ;;;###autoload
 (defun javelin-toggle-quick-menu ()
   "Open quick menu to select a javelined buffer."
   (interactive)
-  (let* ((data (javelin--read-javelin-positions))
-         (project-root (javelin--get-project-root))
-         (candidates (mapcar (lambda (item)
-                               (let* ((filepath (alist-get 'filepath item))
-                                      (pos (alist-get 'point item))
-                                      (full-path (if project-root
-                                                     (concat project-root 
filepath)
-                                                   filepath))
-                                      ;; Calculate line number from point
-                                      (line-num (if (and pos (file-exists-p 
full-path))
-                                                    (with-temp-buffer
-                                                      (insert-file-contents 
full-path)
-                                                      (goto-char (min pos 
(point-max)))
-                                                      (line-number-at-pos))
-                                                  1)))
-                                 (cons (format "%d: %s:%d"
-                                               (alist-get 'javelin_position 
item)
-                                               filepath
-                                               line-num)
-                                       item)))
-                             data))
+  (let* ((pairs (javelin--bookmark-positions))
+         (candidates (mapcar (lambda (pair)
+                               (let* ((pos (car pair))
+                                      (bm-name (cdr pair))
+                                      (record (bookmark-get-bookmark bm-name 
'noerror))
+                                      (display (if record
+                                                   
(javelin--bookmark-display-target record)
+                                                 bm-name)))
+                                 (cons (format "%d: %s" pos display) pos)))
+                             pairs))
          (candidate-strings (mapcar #'car candidates))
          ;; Completion table that preserves order (disables sorting)
          (collection (lambda (string pred action)
@@ -630,8 +501,11 @@ Also records the current point position for later 
navigation."
                            '(metadata (display-sort-function . identity)
                              (cycle-sort-function . identity))
                          (complete-with-action action candidate-strings string 
pred)))))
-    (when-let ((selection (completing-read "Javelin to file: " collection)))
-      (javelin-go-to (alist-get 'javelin_position (cdr (assoc selection 
candidates)))))))
+    (if (null candidates)
+        (message "No javelin positions set.")
+      (when-let ((selection (completing-read "Javelin to file: " collection)))
+        (let ((pos (cdr (assoc selection candidates))))
+          (javelin-go-to pos))))))
 
 ;;;###autoload
 (defun javelin-clear ()
@@ -639,7 +513,9 @@ Also records the current point position for later 
navigation."
   (interactive)
   (when (or javelin-disable-confirmation
             (yes-or-no-p "Do you really want to clear all javelin positions?"))
-    (javelin--write-javelin-positions '())
+    (dolist (pair (javelin--bookmark-positions))
+      (bookmark-delete (cdr pair)))
+    (javelin--bookmark-save-silent)
     (message "Javelin positions cleaned.")))
 
 ;;;###autoload
@@ -648,10 +524,15 @@ Also records the current point position for later 
navigation."
   (interactive)
   (when (or javelin-disable-confirmation
             (yes-or-no-p "Do you really want to clear all javelin positions 
across all javelin projects? "))
-    (let ((files (directory-files javelin-cache-dir t "\\.json$")))
-      (dolist (file files)
-        (delete-file file))
-      (message "Deleted all javelin positions across %d project(s)." (length 
files)))))
+    (javelin--ensure-bookmarks-loaded)
+    (let ((deleted 0))
+      (dolist (bm bookmark-alist)
+        (let ((name (car bm)))
+          (when (string-prefix-p javelin--bookmark-prefix name)
+            (bookmark-delete name)
+            (setq deleted (1+ deleted)))))
+      (javelin--bookmark-save-silent)
+      (message "Deleted all javelin positions across %d project(s)." 
deleted))))
 
 ;;; --- Minor mode ---
 

Reply via email to