|
1 | 1 | (defpackage :lem-git-gutter |
2 | 2 | (:use :cl :lem) |
3 | | - (:local-nicknames (:diff-parser :lem-git-gutter/diff-parser)) |
| 3 | + (:local-nicknames (:diff-parser :lem-git-gutter/diff-parser) |
| 4 | + (:dir-mode :lem/directory-mode)) |
| 5 | + (:import-from :lem/directory-mode |
| 6 | + :*file-entry-inserters* |
| 7 | + :item-pathname |
| 8 | + :item-directory |
| 9 | + :update-buffer |
| 10 | + :directory-mode) |
4 | 11 | (:export :git-gutter-mode |
5 | 12 | :git-gutter-set-ref |
6 | 13 | :git-gutter-refresh |
7 | 14 | :git-gutter-toggle-line-highlight |
| 15 | + :git-gutter-refresh-directory-status |
8 | 16 | :*git-gutter-ref* |
9 | 17 | :*git-gutter-highlight-line* |
10 | | - :*git-gutter-update-delay*)) |
| 18 | + :*git-gutter-update-delay* |
| 19 | + :*directory-status-cache-ttl* |
| 20 | + ;; For testing |
| 21 | + :parse-git-status-porcelain |
| 22 | + :parse-git-diff-name-status |
| 23 | + :status-to-display)) |
11 | 24 | (in-package :lem-git-gutter) |
12 | 25 |
|
13 | 26 | ;;; Configuration |
|
21 | 34 | (defvar *git-gutter-update-delay* 300 |
22 | 35 | "Delay in milliseconds before updating gutter after edit.") |
23 | 36 |
|
| 37 | +(defvar *directory-status-cache-ttl* 5000 |
| 38 | + "Cache time-to-live in milliseconds for directory git status.") |
| 39 | + |
24 | 40 | ;;; Attributes |
25 | 41 |
|
26 | 42 | (define-attribute git-gutter-added-attribute |
|
224 | 240 | (when (buffer-filename buffer) |
225 | 241 | (update-git-gutter-for-buffer buffer))) |
226 | 242 |
|
| 243 | +(defun add-directory-mode-inserter () |
| 244 | + "Add git status inserter to directory-mode." |
| 245 | + (unless (member #'insert-git-status *file-entry-inserters*) |
| 246 | + (push #'insert-git-status *file-entry-inserters*))) |
| 247 | + |
| 248 | +(defun remove-directory-mode-inserter () |
| 249 | + "Remove git status inserter from directory-mode." |
| 250 | + (setf *file-entry-inserters* |
| 251 | + (remove #'insert-git-status *file-entry-inserters*))) |
| 252 | + |
| 253 | +(defun update-all-directory-buffers () |
| 254 | + "Update all directory-mode buffers to reflect git status." |
| 255 | + (dolist (buffer (buffer-list)) |
| 256 | + (when (eq (buffer-major-mode buffer) 'lem/directory-mode:directory-mode) |
| 257 | + (update-buffer buffer)))) |
| 258 | + |
| 259 | +(defun git-gutter-find-file (buffer) |
| 260 | + "Hook function called when a file is opened." |
| 261 | + (when (buffer-filename buffer) |
| 262 | + (update-git-gutter-for-buffer buffer))) |
| 263 | + |
227 | 264 | (defun enable-hook () |
228 | 265 | "Called when git-gutter-mode is enabled." |
229 | 266 | (update-all-buffers) |
| 267 | + (add-hook *find-file-hook* 'git-gutter-find-file) |
230 | 268 | (add-hook (variable-value 'after-save-hook :global t) |
231 | 269 | 'git-gutter-after-save) |
232 | 270 | (add-hook (variable-value 'after-change-functions :global t) |
233 | | - 'git-gutter-on-change)) |
| 271 | + 'git-gutter-on-change) |
| 272 | + ;; Add directory-mode support |
| 273 | + (add-directory-mode-inserter) |
| 274 | + (update-all-directory-buffers)) |
234 | 275 |
|
235 | 276 | (defun disable-hook () |
236 | 277 | "Called when git-gutter-mode is disabled." |
| 278 | + (remove-hook *find-file-hook* 'git-gutter-find-file) |
237 | 279 | (remove-hook (variable-value 'after-save-hook :global t) |
238 | 280 | 'git-gutter-after-save) |
239 | 281 | (remove-hook (variable-value 'after-change-functions :global t) |
240 | 282 | 'git-gutter-on-change) |
241 | 283 | (cancel-all-git-gutter-timers) |
242 | | - (clear-all-buffers)) |
| 284 | + (clear-all-buffers) |
| 285 | + ;; Remove directory-mode support and clear cache |
| 286 | + (remove-directory-mode-inserter) |
| 287 | + (clear-directory-status-cache)) |
243 | 288 |
|
244 | 289 | (define-minor-mode git-gutter-mode |
245 | 290 | (:name "GitGutter" |
|
295 | 340 | (setf *git-gutter-highlight-line* (not *git-gutter-highlight-line*)) |
296 | 341 | (update-all-buffers) |
297 | 342 | (message "Git gutter line highlight: ~A" (if *git-gutter-highlight-line* "on" "off"))) |
| 343 | + |
| 344 | +;;; Directory Mode Git Status Support |
| 345 | + |
| 346 | +(defvar *directory-git-status-cache* (make-hash-table :test #'equal) |
| 347 | + "Cache for directory git status. Maps directory path to (timestamp . status-hash).") |
| 348 | + |
| 349 | +(define-attribute git-status-modified-attribute |
| 350 | + (t :foreground "yellow")) |
| 351 | + |
| 352 | +(define-attribute git-status-added-attribute |
| 353 | + (t :foreground "green")) |
| 354 | + |
| 355 | +(define-attribute git-status-deleted-attribute |
| 356 | + (t :foreground "red")) |
| 357 | + |
| 358 | +(define-attribute git-status-untracked-attribute |
| 359 | + (t :foreground "cyan")) |
| 360 | + |
| 361 | +(define-attribute git-status-staged-attribute |
| 362 | + (t :foreground "green" :bold t)) |
| 363 | + |
| 364 | +(defun parse-git-status-porcelain (output) |
| 365 | + "Parse git status --porcelain=v1 output into a hash-table. |
| 366 | + Returns hash-table mapping relative-path -> status-keyword." |
| 367 | + (let ((status (make-hash-table :test #'equal))) |
| 368 | + (dolist (line (uiop:split-string output :separator '(#\Newline))) |
| 369 | + (when (>= (length line) 3) |
| 370 | + (let ((x (char line 0)) |
| 371 | + (y (char line 1)) |
| 372 | + (file (string-trim " " (subseq line 3)))) |
| 373 | + (when (plusp (length file)) |
| 374 | + ;; Handle renamed files: "R old -> new" |
| 375 | + (when (find #\> file) |
| 376 | + (setf file (string-trim " " (subseq file (1+ (position #\> file)))))) |
| 377 | + (cond |
| 378 | + ;; Untracked |
| 379 | + ((and (char= x #\?) (char= y #\?)) |
| 380 | + (setf (gethash file status) :untracked)) |
| 381 | + ;; Staged changes (index) |
| 382 | + ((char= x #\M) (setf (gethash file status) :staged-modified)) |
| 383 | + ((char= x #\A) (setf (gethash file status) :staged-added)) |
| 384 | + ((char= x #\D) (setf (gethash file status) :staged-deleted)) |
| 385 | + ((char= x #\R) (setf (gethash file status) :staged-added)) |
| 386 | + ;; Unstaged changes (work tree) |
| 387 | + ((char= y #\M) (setf (gethash file status) :modified)) |
| 388 | + ((char= y #\D) (setf (gethash file status) :deleted))))))) |
| 389 | + status)) |
| 390 | + |
| 391 | +(defun parse-git-diff-name-status (output) |
| 392 | + "Parse git diff --name-status output into a hash-table. |
| 393 | + Returns hash-table mapping relative-path -> status-keyword." |
| 394 | + (let ((status (make-hash-table :test #'equal))) |
| 395 | + (dolist (line (uiop:split-string output :separator '(#\Newline))) |
| 396 | + (when (>= (length line) 2) |
| 397 | + (let* ((status-char (char line 0)) |
| 398 | + (rest (string-trim '(#\Space #\Tab) (subseq line 1))) |
| 399 | + (file rest)) |
| 400 | + ;; Handle renamed files: "R100\told\tnew" format |
| 401 | + ;; After stripping status char, rest is "100\told\tnew" |
| 402 | + (when (char= status-char #\R) |
| 403 | + (let* ((parts (uiop:split-string rest :separator '(#\Tab))) |
| 404 | + (new-name (third parts))) |
| 405 | + (when new-name |
| 406 | + (setf file new-name)))) |
| 407 | + (when (plusp (length file)) |
| 408 | + (case status-char |
| 409 | + (#\M (setf (gethash file status) :modified)) |
| 410 | + (#\A (setf (gethash file status) :added)) |
| 411 | + (#\D (setf (gethash file status) :deleted)) |
| 412 | + (#\R (setf (gethash file status) :added))))))) |
| 413 | + status)) |
| 414 | + |
| 415 | +(defun get-directory-git-status (directory) |
| 416 | + "Get git status for all files in directory. Uses cache when available. |
| 417 | + Compares against *git-gutter-ref* (default: HEAD)." |
| 418 | + (let* ((cache-key (format nil "~A:~A" (namestring directory) *git-gutter-ref*)) |
| 419 | + (cached (gethash cache-key *directory-git-status-cache*)) |
| 420 | + (now (get-internal-real-time)) |
| 421 | + (ttl-ticks (* *directory-status-cache-ttl* |
| 422 | + (/ internal-time-units-per-second 1000)))) |
| 423 | + (if (and cached |
| 424 | + (< (- now (car cached)) ttl-ticks)) |
| 425 | + (cdr cached) |
| 426 | + ;; Refresh cache |
| 427 | + (alexandria:when-let ((git-root (find-git-root directory))) |
| 428 | + (uiop:with-current-directory (git-root) |
| 429 | + (let* ((output (run-git (list "diff" "--name-status" *git-gutter-ref*))) |
| 430 | + (status-table (parse-git-diff-name-status output))) |
| 431 | + (setf (gethash cache-key *directory-git-status-cache*) |
| 432 | + (cons now status-table)) |
| 433 | + status-table)))))) |
| 434 | + |
| 435 | +(defun find-status-in-directory (dir-prefix status-table) |
| 436 | + "Find if any file under dir-prefix has a git status. |
| 437 | + Returns the most significant status found (:modified > :added > :deleted > :untracked)." |
| 438 | + (let ((found-status nil)) |
| 439 | + (maphash (lambda (path status) |
| 440 | + (when (and (> (length path) (length dir-prefix)) |
| 441 | + (string= dir-prefix (subseq path 0 (length dir-prefix)))) |
| 442 | + ;; Found a file under this directory |
| 443 | + (case status |
| 444 | + ((:modified :staged-modified) |
| 445 | + (setf found-status :modified)) |
| 446 | + ((:added :staged-added) |
| 447 | + (unless (eq found-status :modified) |
| 448 | + (setf found-status :added))) |
| 449 | + ((:deleted :staged-deleted) |
| 450 | + (unless (member found-status '(:modified :added)) |
| 451 | + (setf found-status :deleted))) |
| 452 | + (:untracked |
| 453 | + (unless found-status |
| 454 | + (setf found-status :untracked)))))) |
| 455 | + status-table) |
| 456 | + found-status)) |
| 457 | + |
| 458 | +(defun get-file-git-status (pathname directory) |
| 459 | + "Get git status symbol for a specific file or directory. |
| 460 | + For directories, checks if any file underneath has changes." |
| 461 | + (alexandria:when-let ((status-table (get-directory-git-status directory))) |
| 462 | + (alexandria:when-let ((git-root (find-git-root directory))) |
| 463 | + (let ((relative-path (namestring (enough-namestring pathname git-root)))) |
| 464 | + (if (uiop:directory-pathname-p pathname) |
| 465 | + ;; For directories, check if any files underneath have changes |
| 466 | + (let ((dir-prefix (if (alexandria:ends-with #\/ relative-path) |
| 467 | + relative-path |
| 468 | + (concatenate 'string relative-path "/")))) |
| 469 | + (find-status-in-directory dir-prefix status-table)) |
| 470 | + ;; For files, direct lookup |
| 471 | + (gethash relative-path status-table)))))) |
| 472 | + |
| 473 | +(defun status-to-display (status) |
| 474 | + "Convert status keyword to display character and attribute." |
| 475 | + (case status |
| 476 | + (:modified (values "M" 'git-status-modified-attribute)) |
| 477 | + (:staged-modified (values "M" 'git-status-staged-attribute)) |
| 478 | + (:added (values "A" 'git-status-added-attribute)) |
| 479 | + (:staged-added (values "A" 'git-status-staged-attribute)) |
| 480 | + (:deleted (values "D" 'git-status-deleted-attribute)) |
| 481 | + (:staged-deleted (values "D" 'git-status-staged-attribute)) |
| 482 | + (:untracked (values "?" 'git-status-untracked-attribute)) |
| 483 | + (otherwise (values " " nil)))) |
| 484 | + |
| 485 | +(defun insert-git-status (point item) |
| 486 | + "Inserter function for directory-mode to show git status. |
| 487 | + Skips the '..' (parent directory) entry." |
| 488 | + (let* ((pathname (item-pathname item)) |
| 489 | + (directory (item-directory item))) |
| 490 | + ;; Skip ".." entry (when pathname is parent of directory) |
| 491 | + (if (and pathname directory |
| 492 | + (equal pathname (uiop:pathname-parent-directory-pathname directory))) |
| 493 | + (insert-string point " ") |
| 494 | + (let ((status (when (and pathname directory) |
| 495 | + (get-file-git-status pathname directory)))) |
| 496 | + (multiple-value-bind (char attr) |
| 497 | + (status-to-display status) |
| 498 | + (if attr |
| 499 | + (insert-string point (format nil "~A " char) :attribute attr) |
| 500 | + (insert-string point " "))))))) |
| 501 | + |
| 502 | +(defun clear-directory-status-cache () |
| 503 | + "Clear the directory git status cache." |
| 504 | + (clrhash *directory-git-status-cache*)) |
| 505 | + |
| 506 | +(define-command git-gutter-refresh-directory-status () () |
| 507 | + "Refresh git status cache for current directory buffer." |
| 508 | + (let ((directory (buffer-directory (current-buffer)))) |
| 509 | + (when directory |
| 510 | + (remhash (namestring directory) *directory-git-status-cache*) |
| 511 | + ;; Force redisplay of directory buffer if in directory-mode |
| 512 | + (when (eq (buffer-major-mode (current-buffer)) 'lem/directory-mode:directory-mode) |
| 513 | + (update-buffer (current-buffer))) |
| 514 | + (message "Git status refreshed")))) |
0 commit comments