;;; eshell-starship.el --- Starship-like (https://starship.rs) prompt for eshell ;;; Commentary: ;;; Code: (require 'vc) (require 'vc-git) (require 'eshell) (require 'cl-lib) (defun eshell-starship--git-process-lines (&rest flags) "Run `vc-git-program' and return an array of its output lines. FLAGS are passed to `vc-git-program' as its arguments." (with-temp-buffer (apply 'vc-git-command t 0 nil flags) (if (zerop (buffer-size)) '() (string-lines (buffer-substring-no-properties 1 (buffer-size)))))) (defun eshell-starship--replace-home-with-tilda (path) "If PATH beings with $HOME (the environment variable), replace it with ~." (let ((home (getenv "HOME"))) (if (equal home path) "~" (setq home (file-name-as-directory home)) (if (string-prefix-p home path) (concat "~/" (seq-subseq path (length home))) path)))) (defun eshell-starship--prompt-cut-path (num path) "Cut PATH down to NUM components. Example: /this/is/a/path 3-> is/a/path" (let ((parts (string-split path "/" t nil))) (concat (when (and (file-name-absolute-p path) (not (equal "~" (car parts))) (<= (length parts) num)) "/") (string-join (last parts num) "/")))) (defun eshell-starship--prompt-get-dir () "Get dir for `eshell-starship--prompt-function'." (eshell-starship--prompt-cut-path 3 (if-let ((worktree (vc-root-dir)) (parent (file-name-parent-directory worktree))) (file-relative-name default-directory parent) (eshell-starship--replace-home-with-tilda default-directory)))) (defun eshell-starship--prompt-current-branch-status (status-line) "Get the status char for the current branch and its remote. STATUS-LINE is the first line of output from \"git status --porcelain=v1 -b\"." (when (string-match "\\[\\(?:ahead \\([0-9]+\\)\\)?,? ?\\(?:behind \\([0-9]+\\)\\)?\\]$" status-line) (let ((ahead (match-string 1 status-line)) (behind (match-string 2 status-line))) (cond ((and ahead behind) ?󰹺) (ahead ?󰜷) (behind ?󰜮))))) (defun eshell-starship--prompt-git-has-stash () "Return t if the current git directory has a stash, nil otherwise." (zerop (process-file vc-git-program nil nil nil "rev-parse" "--verify" "refs/stash"))) (defun eshell-starship--prompt-git-state-chars () "Get chars, like + and ✘ for `eshell-starship--prompt-function'." (with-temp-buffer (when (zerop (vc-git-command t nil nil "status" "--porcelain=v1" "-b")) (goto-char (point-min)) (cl-loop with command-error-function = nil with status-arr = nil with first-line = (buffer-substring-no-properties (point) (pos-eol)) ;; account for newline at end with line-count = (car (buffer-line-statistics)) with cur-buf = (current-buffer) do (forward-line) for x_status = (char-after) for y_status = (char-after (1+ (point))) until (> (line-number-at-pos) line-count) do (cond ((or (= ?D x_status y_status) (= ?A x_status y_status) (= ?U x_status y_status) (and (= ?A x_status) (= ?U y_status)) (and (= ?U x_status) (= ?D y_status)) (and (= ?U x_status) (= ?A y_status)) (and (= ?D x_status) (= ?U y_status))) (push ?= status-arr)) ((or (= x_status ?D) (= y_status ?D)) (push ? status-arr)) ((or (= x_status ?R) (= y_status ?R)) (push ?» status-arr)) ((= y_status ?M) (push ?! status-arr)) ((or (= x_status ?A) (= x_status ?M)) (push ?+ status-arr)) ((= x_status y_status ??) (push ?? status-arr))) finally (sort status-arr #'(lambda (a b) (cond ((= a ?=) t) ((= b ?=) nil) (t (< a b))))) (when (eshell-starship--prompt-git-has-stash) (if (= (car status-arr) ?=) (setq status-arr (append '(?= ?$) (cdr status-arr))) (push ?$ status-arr))) (when-let (branch-status (eshell-starship--prompt-current-branch-status first-line)) (push branch-status status-arr)) finally return (apply 'string (seq-uniq status-arr)))))) (defun eshell-starship--prompt-git-get-operation () "Return the current git operation. For example, a revert." (let ((git-dir (expand-file-name ".git" (vc-git-root default-directory)))) (cond ((file-exists-p (expand-file-name "rebase-apply/applying" git-dir)) "AM") ((file-exists-p (expand-file-name "rebase-apply/rebasing" git-dir)) "REBASE") ((file-exists-p (expand-file-name "rebase-apply" git-dir)) "AM/REBASE") ((file-exists-p (expand-file-name "rebase-merge" git-dir)) "REBASING") ((file-exists-p (expand-file-name "CHERRY_PICK_HEAD" git-dir)) "CHERRY-PICKING") ((file-exists-p (expand-file-name "MERGE_HEAD" git-dir)) "MERGING") ((file-exists-p (expand-file-name "BISECT_LOG" git-dir)) "BISECTING") ((file-exists-p (expand-file-name "REVERT_HEAD" git-dir)) "REVERTING")))) (defun eshell-starship--prompt-git-status () "Get git status for `eshell-starship--prompt-function'." (let ((branch (car (vc-git-branches))) (state (eshell-starship--prompt-git-state-chars)) (operation (eshell-starship--prompt-git-get-operation))) (concat (propertize (concat " 󰊢 " branch) 'face '(:foreground "medium purple")) (unless (string-empty-p state) (propertize (concat " [" state "]") 'face '(:foreground "red"))) (when operation (concat " (" (propertize operation 'face '(:inherit 'bold :foreground "yellow")) ")"))))) (defun eshell-starship--prompt-vc-status () "Get vc status for `eshell-starship--prompt-function'." (if-let (backend (vc-responsible-backend default-directory t)) (if (eq backend 'Git) (eshell-starship--prompt-git-status) (propertize (concat "  " (downcase (symbol-name backend))) 'face '(:foreground "purple"))))) (defvar-local eshell-starship--prompt-last-start-time nil "Start time of last eshell command.") (defun eshell-starship--prompt-timer-pre-cmd () "Command run before each eshell program to record the time." (setq eshell-starship--prompt-last-start-time (current-time))) (add-hook 'eshell-pre-command-hook #'eshell-starship--prompt-timer-pre-cmd) (defun eshell-starship--prompt-format-span (span) "Format SPAN as \"XhXms\"." (let* ((hours (/ span 3600)) (mins (% (/ span 60) 60)) (secs (% span 60))) (concat (unless (= hours 0) (format "%dh" hours)) (unless (= mins 0) (format "%dm" mins)) (format "%ds" secs)))) (defun eshell-starship--prompt-last-command-time (end-time) "Return the prompt component for the time of the last command. END-TIME is the time when the command finished executing." (if-let ((eshell-starship--prompt-last-start-time) (len (time-subtract end-time eshell-starship--prompt-last-start-time)) (float-len (float-time len)) ((< 3 float-len)) (int-len (round float-len))) (concat " time " (propertize (eshell-starship--prompt-format-span int-len) 'face '(:foreground "gold1"))))) (defun eshell-starship--prompt-function () "Function for `eshell-prompt-function'." (let* ((end-time (current-time)) (dir (eshell-starship--prompt-get-dir)) (prompt (concat "\n" (propertize dir 'face '(:foreground "dark turquoise")) (unless (file-writable-p dir) " ") (eshell-starship--prompt-vc-status) (eshell-starship--prompt-last-command-time end-time) (propertize "\n" 'read-only t 'rear-nonsticky t) (propertize "❯ " 'face `(:foreground ,(if (= eshell-last-command-status 0) "lime green" "red")) 'rear-nonsticky t)))) (setq eshell-starship--prompt-last-start-time nil) prompt)) (defvar-local ehsell-starship--restore-state nil "State of various variables set by `eshell-starship-prompt-mode'.") ;;;###autoload (define-minor-mode eshell-starship-prompt-mode "Minor mode to make eshell prompts look like starship (https://starship.rs)." :global nil :init-value nil :interactive (eshell-mode) (if eshell-starship-prompt-mode (setq-local eshell-starship--restore-state (buffer-local-set-state eshell-prompt-function 'eshell-starship--prompt-function eshell-prompt-regexp "^❯ " eshell-highlight-prompt nil)) (buffer-local-restore-state eshell-starship--restore-state))) (provide 'eshell-starship) ;;; eshell-starship.el ends here