diff --git a/elisp/eshell-starship.el b/elisp/eshell-starship.el new file mode 100644 index 0000000..7443f32 --- /dev/null +++ b/elisp/eshell-starship.el @@ -0,0 +1,214 @@ +;;; eshell-starship.el --- Starship-like (https://starship.rs) prompt for eshell +;;; Commentary: +;;; Code: + +(require 'vc) +(require 'vc-git) +(require 'eshell) + +(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-status-char-for-branch (branch remote) + "Get the status char representing the relation between BRANCH and REMOTE." + (let ((lines (eshell-starship--git-process-lines + "rev-list" + "--left-right" + (concat branch "..." remote))) + (to-remote nil) + (to-local nil)) + (dolist (line lines) + (if-let (((not (string-empty-p line))) + (dir-char (aref line 0))) + (if (= dir-char ?<) + (setq to-remote t) + (setq to-local t)))) + (cond + ((and to-remote to-local) ?󰹺) + (to-remote ?󰜷) + (to-local ?󰜮)))) + + +(defun eshell-starship--prompt-current-branch-status () + "Get the status char for the current branch and its remote." + (let ((refs (eshell-starship--git-process-lines + "for-each-ref" + "--format=%(HEAD)%00%(refname:short)%00%(upstream:short)" + "refs/heads"))) + (catch 'break + (dolist (ref refs) + (if-let ((split-ref (split-string ref "\0" nil nil)) + ((equal (car split-ref) "*"))) + (throw 'break (eshell-starship--prompt-status-char-for-branch + (cadr split-ref) + (caddr split-ref)))))))) + +(defun eshell-starship--prompt-git-state-chars () + "Get chars, like + and ✘ for `eshell-starship--prompt-function'." + (let ((lines (eshell-starship--git-process-lines "status" "--porcelain=v1")) + (branch-status (eshell-starship--prompt-current-branch-status)) + (status-arr)) + (dolist (line lines) + (cl-loop with fields = (string-split line " " t " *") + with status-str = (car-safe fields) + for status-char across status-str + do + (cond ((or (= status-char ?M) (= status-char ?T)) + (add-to-list 'status-arr ?!)) + ((= status-char ??) + (add-to-list 'status-arr ??)) + ((or (= status-char ?A) (= status-char ?C)) + (add-to-list 'status-arr ?+)) + ((= status-char ?D) + (add-to-list 'status-arr ?)) + ((= status-char ?R) + (add-to-list 'status-arr ?»)) + ((= status-char ?U) + (add-to-list 'status-arr ?=))))) + (sort status-arr #'<) + (when branch-status + (push branch-status status-arr)) + (apply 'string 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 "REVERT_HEAD" git-dir)) + "REVERTING") + ((file-exists-p (expand-file-name "rebase-merge" git-dir)) + "REBASING") + ((file-exists-p (expand-file-name "MERGE_HEAD" git-dir)) + "MERGING")))) + +(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 diff --git a/init.el b/init.el index 3eac8b0..bd87f6d 100644 --- a/init.el +++ b/init.el @@ -923,175 +923,7 @@ If no name is given, list all bookmarks instead." (eshell/cd (bookmark-get-filename name)) (when my/eshell-bm-auto-ls (eshell/ls))) - (eshell-print (string-join (bookmark-all-names) " ")))) - (defun my/-git-process-lines (&rest flags) - "Run `vc-git-program' and return an array of its output lines." - (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 my/-replace-home-with-tilda (path) - (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 my/-eshell-prompt-cut-path (num path) - "Cut PATH down to NUM components." - (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 my/-eshell-prompt-get-dir () - "Get dir for `my/-eshell-prompt-function'" - (my/-eshell-prompt-cut-path 3 - (if-let ((worktree (vc-root-dir)) - (parent (file-name-parent-directory worktree))) - (file-relative-name default-directory parent) - (my/-replace-home-with-tilda default-directory)))) - (defun my/-eshell-prompt-status-char-for-branch (branch remote) - "Get the status char representing the relation between BRANCH and REMOTE." - (let ((lines (my/-git-process-lines - "rev-list" - "--left-right" - (concat branch "..." remote))) - (to-remote nil) - (to-local nil)) - (dolist (line lines) - (if-let (((not (string-empty-p line))) - (dir-char (aref line 0))) - (if (= dir-char ?<) - (setq to-remote t) - (setq to-local t)))) - (cond - ((and to-remote to-local) ?󰹺) - (to-remote ?󰜷) - (to-local ?󰜮)))) - (defun my/-eshell-prompt-current-branch-status () - "Get the status char for the current branch and its remote." - (let ((refs (my/-git-process-lines - "for-each-ref" - "--format=%(HEAD)%00%(refname:short)%00%(upstream:short)" - "refs/heads"))) - (catch 'break - (dolist (ref refs) - (if-let ((split-ref (split-string ref "\0" nil nil)) - ((equal (car split-ref) "*"))) - (throw 'break (my/-eshell-prompt-status-char-for-branch - (cadr split-ref) - (caddr split-ref)))))))) - (defun my/-eshell-prompt-git-state-chars () - "Get chars, like + and ✘ for `my/-eshell-prompt-function'." - (let ((lines (my/-git-process-lines "status" "--porcelain=v1")) - (branch-status (my/-eshell-prompt-current-branch-status)) - (status-arr)) - (dolist (line lines) - (cl-loop with fields = (string-split line " " t " *") - with status-str = (car-safe fields) - for status-char across status-str - do - (cond ((or (= status-char ?M) (= status-char ?T)) - (add-to-list 'status-arr ?!)) - ((= status-char ??) - (add-to-list 'status-arr ??)) - ((or (= status-char ?A) (= status-char ?C)) - (add-to-list 'status-arr ?+)) - ((= status-char ?D) - (add-to-list 'status-arr ?)) - ((= status-char ?R) - (add-to-list 'status-arr ?»)) - ((= status-char ?U) - (add-to-list 'status-arr ?=))))) - (sort status-arr #'<) - (when branch-status - (push branch-status status-arr)) - (apply 'string status-arr))) - (defun my/-eshell-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 "REVERT_HEAD" git-dir)) - "REVERTING") - ((file-exists-p (expand-file-name "rebase-merge" git-dir)) - "REBASING") - ((file-exists-p (expand-file-name "MERGE_HEAD" git-dir)) - "MERGING")))) - (defun my/-eshell-prompt-git-status () - "Get git status for `my/-eshell-prompt-function'" - (let ((branch (car (vc-git-branches))) - (state (my/-eshell-prompt-git-state-chars)) - (operation (my/-eshell-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 my/-eshell-prompt-vc-status () - "Get vc status for `my/-eshell-prompt-function'." - (if-let (backend (vc-responsible-backend default-directory t)) - (if (eq backend 'Git) - (my/-eshell-prompt-git-status) - (my/-eshell-prompt-set-face-color - (concat "  " (downcase (symbol-name backend))) - "purple")))) - (defvar-local my/-eshell-prompt-last-start-time nil - "Start time of last eshell command.") - (defun my/-eshell-prompt-timer-pre-cmd () - "Command run before each eshell program to record the time." - (setq my/-eshell-prompt-last-start-time (current-time))) - (add-hook 'eshell-pre-command-hook #'my/-eshell-prompt-timer-pre-cmd) - (defun my/-eshell-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 my/-eshell-prompt-last-command-time (end-time) - "Return the prompt component for the time of the last command." - (if-let ((my/-eshell-prompt-last-start-time) - (len (time-subtract end-time - my/-eshell-prompt-last-start-time)) - (float-len (float-time len)) - ((< 3 float-len)) - (int-len (round float-len))) - (concat " time " - (propertize (my/-eshell-prompt-format-span int-len) - 'face '(:foreground "gold1"))))) - (defun my/-eshell-prompt-function () - "Function for `eshell-prompt-function'" - (let* ((end-time (current-time)) - (dir (my/-eshell-prompt-get-dir)) - (prompt (concat - "\n" - (propertize dir 'face '(:foreground "dark turquoise")) - (unless (file-writable-p dir) - " ") - (my/-eshell-prompt-vc-status) - (my/-eshell-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 my/-eshell-prompt-last-start-time nil) - prompt)) - (setq eshell-prompt-function #'my/-eshell-prompt-function - eshell-prompt-regexp "^❯ " - eshell-highlight-prompt nil)) + (eshell-print (string-join (bookmark-all-names) " "))))) (use-package esh-help :hook (eshell-mode . my/-setup-eshell-help-func) :init @@ -1102,6 +934,10 @@ If no name is given, list all bookmarks instead." (use-package eshell-syntax-highlighting :init (eshell-syntax-highlighting-global-mode 1)) +(use-package eshell-starship + :ensure nil + :demand t + :hook (eshell-prompt-mode . eshell-starship-prompt-mode)) ;; proced (use-package proced