From 35c95e201005579d58a8a59b42fb2c227eeff189 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sun, 7 Jun 2026 00:32:24 -0400 Subject: [PATCH] feat: rename and add inital feature rewrite --- impure.plugin.zsh | 1 + impure.zsh | 1407 +++++++++++++++++++++++++++++++++++++++++ prompt_impure_setup | 1 + prompt_pure_setup | 1 - pure.plugin.zsh | 1 - pure.zsh | 1243 ------------------------------------ tests/test-prompt.zsh | 266 ++++++++ 7 files changed, 1675 insertions(+), 1245 deletions(-) create mode 120000 impure.plugin.zsh create mode 100644 impure.zsh create mode 120000 prompt_impure_setup delete mode 120000 prompt_pure_setup delete mode 120000 pure.plugin.zsh delete mode 100644 pure.zsh create mode 100644 tests/test-prompt.zsh diff --git a/impure.plugin.zsh b/impure.plugin.zsh new file mode 120000 index 0000000..8dc4c34 --- /dev/null +++ b/impure.plugin.zsh @@ -0,0 +1 @@ +impure.zsh \ No newline at end of file diff --git a/impure.zsh b/impure.zsh new file mode 100644 index 0000000..0c212bd --- /dev/null +++ b/impure.zsh @@ -0,0 +1,1407 @@ +# Impure +# Forked from Pure by Sindre Sorhus +# https://github.com/sindresorhus/pure +# MIT License + +# For my own and others sanity +# git: +# %b => current branch +# %a => current action (rebase/merge) +# prompt: +# %F => color dict +# %f => reset color +# %~ => current path +# %* => time +# %n => username +# %m => shortname host +# %(?..) => prompt conditional - %(condition.true.false) +# terminal codes: +# \e7 => save cursor position +# \e[2A => move cursor 2 lines up +# \e[1G => go to position 1 in terminal +# \e8 => restore cursor position +# \e[K => clears everything after the cursor on the current line +# \e[2K => clear everything on the current line + + +# Turns seconds into human readable time. +# 165392 => 1d 21h 56m 32s +# https://github.com/sindresorhus/pretty-time-zsh +prompt_impure_human_time_to_var() { + local total_seconds=$1 var=$2 + local formatted + if (( total_seconds < 60 )); then + formatted=$(printf '%.3fs' "$total_seconds") + else + local minutes=$(( total_seconds / 60 )) + local seconds=$(( total_seconds % 60 )) + formatted="${minutes}m ${seconds}s" + fi + typeset -g "${var}"="${formatted}" +} + +# Stores (into prompt_impure_cmd_exec_time) the execution +# time of the last command if set threshold was exceeded. +prompt_impure_check_cmd_exec_time() { + integer elapsed + (( elapsed = EPOCHSECONDS - ${prompt_impure_cmd_timestamp:-$EPOCHSECONDS} )) + typeset -g prompt_impure_cmd_exec_time= + (( elapsed >= ${IMPURE_CMD_MAX_EXEC_TIME:-3} )) && { + prompt_impure_human_time_to_var $elapsed "prompt_impure_cmd_exec_time" + } +} + +prompt_impure_set_title() { + setopt localoptions noshwordsplit + + # Allow disabling title management. + zstyle -T ":prompt:impure:title" show || return + + # Emacs terminal does not support settings the title. + (( ${+EMACS} || ${+INSIDE_EMACS} )) && return + + case $TTY in + # Don't set title over serial console. + /dev/ttyS[0-9]*) return;; + esac + + # Show hostname if connected via SSH and host display is enabled. + local hostname= + if (( psvar[13] )) && (( ${prompt_impure_state[show_host]:-1} )); then + # Expand in-place in case ignore-escape is used. + hostname="${(%):-(%m) }" + fi + + local -a opts + case $1 in + expand-prompt) opts=(-P);; + ignore-escape) opts=(-r);; + esac + + # Set title atomically in one print statement so that it works when XTRACE is enabled. + print -n $opts $'\e]0;'${hostname}${2}$'\a' +} + +prompt_impure_preexec() { + if [[ -n $prompt_impure_git_fetch_pattern ]]; then + # Detect when Git is performing pull/fetch, including Git aliases. + local -H MATCH MBEGIN MEND match mbegin mend + if [[ $2 =~ (git|hub)\ (.*\ )?($prompt_impure_git_fetch_pattern)(\ .*)?$ ]]; then + # We must flush the async jobs to cancel our git fetch in order + # to avoid conflicts with the user issued pull / fetch. + async_flush_jobs 'prompt_impure' + fi + fi + + typeset -g prompt_impure_cmd_timestamp=$EPOCHSECONDS + + # Shows the current directory and executed command in the title while a process is active. + prompt_impure_set_title 'ignore-escape' "$PWD:t: $2" + + # Disallow Python virtualenv from updating the prompt. Set it to 20 if + # untouched by the user to indicate that Impure modified it. Here we use + # the magic number 20, same as in `psvar`. + export VIRTUAL_ENV_DISABLE_PROMPT=${VIRTUAL_ENV_DISABLE_PROMPT:-20} +} + +# Change the colors if their value are different from the current ones. +prompt_impure_set_colors() { + local color_temp key value + for key value in ${(kv)prompt_impure_colors}; do + zstyle -t ":prompt:impure:$key" color "$value" && continue + case $? in + 1) # The current style is different from the one from zstyle. + zstyle -s ":prompt:impure:$key" color color_temp + prompt_impure_colors[${key}]=$color_temp ;; + 2) # No style is defined. + prompt_impure_colors[${key}]=${prompt_impure_colors_default[${key}]} ;; + esac + done + + prompt_impure_set_path_separator + + return 0 +} + +prompt_impure_set_path_separator() { + local path_color=$prompt_impure_colors[path] + + # Ephemeral shell: use red path to signal non-persistent environment. + if [[ -n ${ATELIER_EPHEMERAL:-} ]]; then + path_color=$prompt_impure_colors[path:ephemeral] + fi + + typeset -g prompt_impure_path_segment="%F{${path_color}}%~%f" + + if zstyle -t ':prompt:impure:path:separator' dim; then + typeset -g prompt_impure_path_separator_dimmed=1 + else + typeset -g prompt_impure_path_separator_dimmed= + fi +} + +prompt_impure_render_dimmed_path() { + setopt localoptions noshwordsplit + + # This runs from PROMPT_SUBST so directory changes followed by reset-prompt redraw correctly without precmd. + local current_path=${1:-${(%):-%~}} + current_path=${current_path//\%/%%} + + local separator=$'%{\e[2m%}/%{\e[22m%}' + # Keep the leading / on absolute paths at full brightness. + local prefix= + if [[ $current_path == /* ]]; then + prefix=/ + current_path=${current_path:1} + fi + + local path_color=$prompt_impure_colors[path] + if [[ -n ${ATELIER_EPHEMERAL:-} ]]; then + path_color=$prompt_impure_colors[path:ephemeral] + fi + + print -n -r -- "%F{${path_color}}${prefix}${current_path//\//$separator}%f" +} + +prompt_impure_preprompt_render() { + setopt localoptions noshwordsplit + + unset prompt_impure_async_render_requested + + # Update git branch color based on cache state. + typeset -g prompt_impure_git_branch_color=$prompt_impure_colors[git:branch] + [[ -n ${prompt_impure_git_last_dirty_check_timestamp+x} ]] && prompt_impure_git_branch_color=$prompt_impure_colors[git:branch:cached] + + # Update psvar values. PROMPT uses %(NV.true.false) to conditionally + # render each part. See prompt_impure_setup for the PROMPT template. + # + # psvar[12]: Suspended jobs symbol. + psvar[12]= + ((${(M)#jobstates:#suspended:*} != 0)) && psvar[12]=${IMPURE_SUSPENDED_JOBS_SYMBOL-✦} + + # psvar[13]: Username flag (set once in prompt_impure_state_setup). + + # psvar[14]: Git branch name. + psvar[14]=${prompt_impure_vcs_info[branch]} + + # psvar[15]: Git staging summary (e.g. "+2 ~1"). + psvar[15]=${prompt_impure_git_staging} + + # psvar[16]: Git working tree dirty marker ("*"). + psvar[16]=${prompt_impure_git_dirty} + + # psvar[17]: Git action (rebase/merge). + psvar[17]=${prompt_impure_vcs_info[action]} + + # psvar[18]: Git arrows (push/pull) with leading space. + psvar[18]= + [[ -n $prompt_impure_git_arrows ]] && psvar[18]=" ${prompt_impure_git_arrows}" + + # psvar[19]: Git stash symbol. + psvar[19]= + [[ -n $prompt_impure_git_stash ]] && psvar[19]=${IMPURE_GIT_STASH_SYMBOL-≡} + + # psvar[20]: Command execution time (used in RPROMPT). + psvar[20]=${prompt_impure_cmd_exec_time} + + # psvar[21]: virtualenv/nix-shell name — set in precmd, not here. + + # psvar[22]: Custom prefix, psvar[23]: Custom suffix. + # Set by the user-defined prompt_impure_precustom function. + psvar[22]= + psvar[23]= + if (( $+functions[prompt_impure_precustom] )); then + prompt_impure_precustom + fi + + # psvar[24]: ZMX session tag. + psvar[24]= + [[ -n ${ZMX_SESSION:-} ]] && psvar[24]="[${ZMX_SESSION}]" + + # psvar[25]: Jujutsu bookmark(s). + # psvar[26]: Jujutsu change ID (rendered in cyan). + # psvar[27]: Jujutsu working set changes. + psvar[25]=${prompt_impure_jj_bookmark} + psvar[26]=${prompt_impure_jj_changeid} + psvar[27]=${prompt_impure_jj_working} + + # Build a fingerprint from all dynamic prompt components to detect changes + # without expanding PROMPT (which forks a subshell when dimmed path is on). + local -a prompt_fingerprint_parts=( + "${psvar[12]}" + "${psvar[13]}" + "${psvar[14]}" + "${psvar[15]}" + "${psvar[16]}" + "${psvar[17]}" + "${psvar[18]}" + "${psvar[19]}" + # psvar[21] (exec time) excluded: changes every command, handled by precmd. + "${psvar[21]}" + "${psvar[22]}" + "${psvar[23]}" + "${psvar[24]}" + "${psvar[25]}" + "${psvar[26]}" + "${psvar[27]}" + "${prompt_impure_state[prompt]}" + "${prompt_impure_git_branch_color}" + "${PWD}" + ) + local prompt_fingerprint="${(pj:|:)${(@qqq)prompt_fingerprint_parts}}" + + if [[ $1 != precmd ]] && [[ $prompt_impure_last_prompt != $prompt_fingerprint ]]; then + # Redraw the prompt. + prompt_impure_reset_prompt + fi + + typeset -g prompt_impure_last_prompt=$prompt_fingerprint +} + +prompt_impure_precmd() { + setopt localoptions noshwordsplit + + # Check execution time and store it in a variable. + prompt_impure_check_cmd_exec_time + unset prompt_impure_cmd_timestamp + + # Shows the full path in the title. + prompt_impure_set_title 'expand-prompt' '%~' + + # Modify the colors if some have changed.. + prompt_impure_set_colors + + # Restore full prompt if transient swapped it. + if [[ -n ${prompt_impure_saved_prompt:-} ]]; then + PROMPT=$prompt_impure_saved_prompt + RPROMPT=$prompt_impure_saved_rprompt + unset prompt_impure_saved_prompt prompt_impure_saved_rprompt + prompt_impure_build_rprompt + fi + + # Handle Ctrl+C: install TRAPINT so transient fires on interrupt too. + TRAPINT() { + typeset -g prompt_impure_transient=1 + return $(( 128 + $1 )) + } + + # Perform async Git dirty check and fetch. + prompt_impure_async_tasks + + # Perform async Jujutsu status check. + prompt_impure_async_jj_tasks + + # Check if we should display the virtual env (psvar[21]). + psvar[21]= + if zstyle -T ":prompt:impure:environment:virtualenv" show; then + # When VIRTUAL_ENV_DISABLE_PROMPT is empty, it was unset by the user and + # Impure should take back control. + if [[ -n $VIRTUAL_ENV ]] && [[ -z $VIRTUAL_ENV_DISABLE_PROMPT || $VIRTUAL_ENV_DISABLE_PROMPT = 20 ]]; then + if [[ -n $VIRTUAL_ENV_PROMPT ]]; then + psvar[21]="${VIRTUAL_ENV_PROMPT}" + else + psvar[21]="${VIRTUAL_ENV:t}" + fi + export VIRTUAL_ENV_DISABLE_PROMPT=20 + fi + fi + + # Nix package manager integration. If used from within 'nix shell' - shell name is shown like so: + # ~/Projects/flake-utils-plus master + # flake-utils-plus ❯ + if zstyle -T ":prompt:impure:environment:nix-shell" show; then + if [[ -n $IN_NIX_SHELL ]]; then + psvar[21]="impure" + fi + fi + + # Make sure VIM prompt is reset. + prompt_impure_reset_prompt_symbol + + # Print the preprompt. + prompt_impure_preprompt_render "precmd" + + if [[ -n $ZSH_THEME ]]; then + print "WARNING: Oh My Zsh themes are enabled (ZSH_THEME='${ZSH_THEME}'). Impure might not be working correctly." + print "For more information, see: https://github.com/sindresorhus/pure#oh-my-zsh" + unset ZSH_THEME # Only show this warning once. + fi +} + +prompt_impure_async_git_aliases() { + setopt localoptions noshwordsplit + local -a gitalias pullalias + + # List all aliases and split on newline. + gitalias=(${(@f)"$(command git config --get-regexp "^alias\.")"}) + for line in $gitalias; do + parts=(${(@)=line}) # Split line on spaces. + aliasname=${parts[1]#alias.} # Grab the name (alias.[name]). + shift parts # Remove `aliasname` + + # Check alias for pull or fetch. Must be exact match. + if [[ $parts =~ ^(.*\ )?(pull|fetch)(\ .*)?$ ]]; then + pullalias+=($aliasname) + fi + done + + print -- ${(j:|:)pullalias} # Join on pipe, for use in regex. +} + +prompt_impure_async_vcs_info() { + setopt localoptions noshwordsplit + + # Configure `vcs_info` inside an async task. This frees up `vcs_info` + # to be used or configured as the user pleases. + zstyle ':vcs_info:*' enable git + zstyle ':vcs_info:*' use-simple true + # Only export four message variables from `vcs_info`. + zstyle ':vcs_info:*' max-exports 3 + # Export branch (%b), Git toplevel (%R), action (rebase/cherry-pick) (%a) + zstyle ':vcs_info:git*' formats '%b' '%R' '%a' + zstyle ':vcs_info:git*' actionformats '%b' '%R' '%a' + + vcs_info + + local -A info + info[pwd]=$PWD + info[branch]=${vcs_info_msg_0_//\%/%%} + info[top]=$vcs_info_msg_1_ + info[action]=$vcs_info_msg_2_ + + print -r - ${(@kvq)info} +} + +# Check if a Git repo is dirty. Outputs oh-my-posh style summary: +# STAGING|WORKING where STAGING is like "+2 ~1" and WORKING is "*" or empty. +prompt_impure_async_git_dirty() { + setopt localoptions noshwordsplit + local untracked_dirty=$1 + local untracked_git_mode=$(command git config --get status.showUntrackedFiles) + if [[ "$untracked_git_mode" != 'no' ]]; then + untracked_git_mode='normal' + fi + + # Prevent e.g. `git status` from refreshing the index as a side effect. + export GIT_OPTIONAL_LOCKS=0 + + local u_flag + if [[ $untracked_dirty = 0 ]]; then + u_flag='-uno' + else + u_flag="-u${untracked_git_mode}" + fi + + local output + output=$(command git status --porcelain $u_flag) + [[ -z $output ]] && return 0 + + local staged_add=0 staged_mod=0 staged_del=0 + local working_dirty=0 line + for line in "${(f)output}"; do + # Staging area (first column). + case ${line[1]} in + A) (( staged_add++ )) ;; + M|R|C) (( staged_mod++ )) ;; + D) (( staged_del++ )) ;; + esac + # Working tree (second column). + [[ ${line[2]} == [MTDUA] ]] && working_dirty=1 + # Untracked. + [[ $line == '??'* ]] && working_dirty=1 + done + + # Build staging summary: +N ~N -N (only non-zero parts). + local staging="" + (( staged_add )) && staging+="+${staged_add} " + (( staged_mod )) && staging+="~${staged_mod} " + (( staged_del )) && staging+="-${staged_del} " + staging=${staging% } # trim trailing space + + # Output: staging|working (* or empty) + local working="" + (( working_dirty )) && working="*" + print -r -- "${staging}|${working}" + return 1 +} + +prompt_impure_async_git_fetch() { + setopt localoptions noshwordsplit + + local only_upstream=${1:-0} + + # Sets `GIT_TERMINAL_PROMPT=0` to disable authentication prompt for Git fetch (Git 2.3+). + export GIT_TERMINAL_PROMPT=0 + # Set SSH `BachMode` to disable all interactive SSH password prompting. + export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-"ssh"} -o BatchMode=yes" + + # If gpg-agent is set to handle SSH keys for `git fetch`, make + # sure it doesn't corrupt the parent TTY. + # Setting an empty GPG_TTY forces pinentry-curses to close immediately rather + # than stall indefinitely waiting for user input. + export GPG_TTY= + + local -a remote + if ((only_upstream)); then + local ref + ref=$(command git symbolic-ref -q HEAD) + # Set remote to only fetch information for the current branch. + remote=($(command git for-each-ref --format='%(upstream:remotename) %(refname)' $ref)) + if [[ -z $remote[1] ]]; then + # No remote specified for this branch, skip fetch. + return 97 + fi + fi + + # Default return code, which indicates Git fetch failure. + local fail_code=99 + + # Guard against all forms of password prompts. By setting the shell into + # MONITOR mode we can notice when a child process prompts for user input + # because it will be suspended. Since we are inside an async worker, we + # have no way of transmitting the password and the only option is to + # kill it. If we don't do it this way, the process will corrupt with the + # async worker. + setopt localtraps monitor + + # Make sure local HUP trap is unset to allow for signal propagation when + # the async worker is flushed. + trap - HUP + + trap ' + # Unset trap to prevent infinite loop + trap - CHLD + if [[ $jobstates = suspended* ]]; then + # Set fail code to password prompt and kill the fetch. + fail_code=98 + kill %% + fi + ' CHLD + + # Do git fetch and avoid fetching tags or + # submodules to speed up the process. + command git -c gc.auto=0 -c fetch.prune=false fetch \ + --quiet \ + --no-tags \ + --no-prune-tags \ + --recurse-submodules=no \ + $remote &>/dev/null & + wait $! || return $fail_code + + unsetopt monitor + + # Check arrow status after a successful `git fetch`. + prompt_impure_async_git_arrows +} + +prompt_impure_async_git_arrows() { + setopt localoptions noshwordsplit + command git rev-list --left-right --count HEAD...@'{u}' +} + +prompt_impure_async_git_stash() { + command git rev-list --walk-reflogs --count refs/stash +} + +# Async jujutsu status: get change ID and working set modifications. +# Mirrors the git async pattern — runs in the background worker, never blocks. +prompt_impure_async_jj_status() { + setopt localoptions noshwordsplit + + # Check if we're in a jj repo by looking for .jj directory. + local jj_dir=$PWD + while [[ $jj_dir != "/" ]]; do + [[ -d "$jj_dir/.jj" ]] && break + jj_dir=${jj_dir:h} + done + [[ -d "$jj_dir/.jj" ]] || return 1 + + # Get bookmark and change ID in one call. + local jj_info + # Use | as delimiter between bookmarks and change ID for reliable parsing. + # Filter out jj/keep/ (auto-generated anonymous branch bookmarks) and strip heads/ prefix. + jj_info=$(command jj log --no-graph -r '@' -T 'bookmarks.filter(|b| !b.name().starts_with("jj/keep/")).map(|b| b.name().replace("heads/", "")).join(" ") ++ "|" ++ change_id.shortest()' 2>/dev/null) || return 1 + + # Get working copy status: count changed files via process substitution + # to avoid storing the full output in a variable. + local count=0 + local in_changes=0 + local line + while IFS= read -r line; do + if [[ $line == "Working copy changes:" ]]; then + in_changes=1 + continue + fi + (( in_changes )) || continue + [[ -z $line || $line == "Working copy"* || $line == "Parent commit"* ]] && break + (( count++ )) + done < <(command jj status --color=never 2>/dev/null) + + local working_changes="" + (( count > 0 )) && working_changes=" ~${count}" + + # Output: bookmark(s) space change_id space working_changes + # The renderer will split and color the change_id cyan. + print -r -- "${jj_info}${working_changes}" +} + +# Try to lower the priority of the worker so that disk heavy operations +# like `git status` has less impact on the system responsivity. +prompt_impure_async_renice() { + setopt localoptions noshwordsplit + + if command -v renice >/dev/null; then + command renice +15 -p $$ + fi + + if command -v ionice >/dev/null; then + command ionice -c 3 -p $$ + fi +} + +prompt_impure_async_worker_sync() { + setopt localoptions noshwordsplit + + local sync_token=$1 target_pwd=$2 has_git_dir=$3 git_dir=$4 has_git_work_tree=$5 git_work_tree=$6 + + if ! builtin cd -q "$target_pwd"; then + builtin cd -q / + unset GIT_DIR GIT_WORK_TREE + print -r -- "prompt_impure_worker_sync:$sync_token:1" + return 1 + fi + + if (( has_git_dir )); then + export GIT_DIR=$git_dir + else + unset GIT_DIR + fi + + if (( has_git_work_tree )); then + export GIT_WORK_TREE=$git_work_tree + else + unset GIT_WORK_TREE + fi + + print -r -- "prompt_impure_worker_sync:$sync_token:0" +} + +prompt_impure_clear_git_state() { + unset prompt_impure_git_dirty prompt_impure_git_staging prompt_impure_git_last_dirty_check_timestamp prompt_impure_git_arrows prompt_impure_git_stash prompt_impure_git_fetch_pattern + typeset -gA prompt_impure_worker_env=() + typeset -gA prompt_impure_worker_env_pending=() + typeset -gA prompt_impure_vcs_info + prompt_impure_vcs_info[branch]= + prompt_impure_vcs_info[top]= + prompt_impure_vcs_info[action]= + prompt_impure_vcs_info[pwd]= +} + +prompt_impure_clear_jj_state() { + typeset -g prompt_impure_jj_bookmark= + typeset -g prompt_impure_jj_changeid= + typeset -g prompt_impure_jj_working= +} + +prompt_impure_async_init() { + typeset -g prompt_impure_async_inited + if ((${prompt_impure_async_inited:-0})); then + return + fi + if ! async_start_worker "prompt_impure" -u -n 2>/dev/null; then + # Worker failed to start (e.g. zpty permission denied). + # Degrade gracefully by skipping async git operations. + return 1 + fi + prompt_impure_async_inited=1 + async_register_callback "prompt_impure" prompt_impure_async_callback + async_worker_eval "prompt_impure" prompt_impure_async_renice +} + +prompt_impure_async_tasks() { + setopt localoptions noshwordsplit + + # If inside a jj repo, skip git entirely (jj takes precedence). + local check_dir=$PWD + while [[ $check_dir != "/" ]]; do + if [[ -d "$check_dir/.jj" ]]; then + prompt_impure_clear_git_state + return + fi + check_dir=${check_dir:h} + done + + # Check if git integration is enabled (default: yes). + if ! zstyle -T ":prompt:impure:git" show; then + # Flush any in-flight async git jobs. + if (( ${prompt_impure_async_inited:-0} )); then + async_flush_jobs "prompt_impure" + fi + + prompt_impure_clear_git_state + return + fi + + # Initialize the async worker. If it fails (e.g. zpty unavailable), + # skip all async tasks and show prompt without git info. + if ! prompt_impure_async_init; then + prompt_impure_clear_git_state + return + fi + + # Sync working directory and git environment variables to the async worker. + # Skip if nothing changed since last sync (common case: running commands in same dir). + # Uses an associative array to avoid scalar globals triggering AUTO_NAME_DIRS. + typeset -gA prompt_impure_worker_env + typeset -gA prompt_impure_worker_env_pending + local cur_git_dir=${GIT_DIR-__unset__} + local cur_git_work_tree=${GIT_WORK_TREE-__unset__} + if [[ $PWD != ${prompt_impure_worker_env[pwd]-} || + $cur_git_dir != ${prompt_impure_worker_env[git_dir]-} || + $cur_git_work_tree != ${prompt_impure_worker_env[git_work_tree]-} ]]; then + (( ${#prompt_impure_worker_env_pending} )) && return + prompt_impure_clear_git_state + async_flush_jobs "prompt_impure" + typeset -gi prompt_impure_worker_sync_token + (( prompt_impure_worker_sync_token++ )) + local sync_token=$prompt_impure_worker_sync_token + prompt_impure_worker_env_pending[pwd]=$PWD + prompt_impure_worker_env_pending[git_dir]=$cur_git_dir + prompt_impure_worker_env_pending[git_work_tree]=$cur_git_work_tree + prompt_impure_worker_env_pending[token]=$sync_token + async_worker_eval "prompt_impure" \ + prompt_impure_async_worker_sync $sync_token "$PWD" ${+GIT_DIR} "${GIT_DIR-}" ${+GIT_WORK_TREE} "${GIT_WORK_TREE-}" || { + if [[ ${prompt_impure_worker_env_pending[token]-} == $sync_token ]]; then + typeset -gA prompt_impure_worker_env_pending=() + fi + return + } + return + fi + + typeset -gA prompt_impure_vcs_info + + local -H MATCH MBEGIN MEND + if [[ $PWD != ${prompt_impure_vcs_info[pwd]}* ]]; then + # Stop any running async jobs. + async_flush_jobs "prompt_impure" + + # Reset preprompt variables, switching working tree. + unset prompt_impure_git_dirty prompt_impure_git_staging + unset prompt_impure_git_last_dirty_check_timestamp + unset prompt_impure_git_arrows + unset prompt_impure_git_stash + unset prompt_impure_git_fetch_pattern + prompt_impure_vcs_info[branch]= + prompt_impure_vcs_info[top]= + fi + unset MATCH MBEGIN MEND + + async_job "prompt_impure" prompt_impure_async_vcs_info || return + + # Only perform tasks inside a Git working tree. + [[ -n $prompt_impure_vcs_info[top] ]] || return + + prompt_impure_async_refresh +} + +# Async jujutsu tasks: dispatch jj status check to the worker. +# Only runs when not inside a git repo (mutually exclusive with git). +prompt_impure_async_jj_tasks() { + setopt localoptions noshwordsplit + + # Skip if jj integration is disabled. + zstyle -T ":prompt:impure:jj" show || return + + # Git already skipped if .jj was found, so no need to check vcs_info here. + + # Ensure async worker is initialized. + prompt_impure_async_init || return + + # Clear state and re-dispatch on directory change. + if [[ $PWD != ${prompt_impure_jj_pwd:-} ]]; then + typeset -g prompt_impure_jj_pwd=$PWD + prompt_impure_clear_jj_state + async_flush_jobs "prompt_impure" + fi + + # Always dispatch: jj status can change without cd (e.g. jj commit, jj new). + # The async worker handles concurrent job dedup internally. + async_job "prompt_impure" prompt_impure_async_jj_status +} + +prompt_impure_async_refresh() { + setopt localoptions noshwordsplit + + if [[ -z $prompt_impure_git_fetch_pattern ]]; then + # We set the pattern here to avoid redoing the pattern check until the + # working tree has changed. Pull and fetch are always valid patterns. + typeset -g prompt_impure_git_fetch_pattern="pull|fetch" + async_job "prompt_impure" prompt_impure_async_git_aliases || return + fi + + async_job "prompt_impure" prompt_impure_async_git_arrows || return + + # Do not perform `git fetch` if it is disabled or in home folder. + if (( ${IMPURE_GIT_PULL:-1} )) && [[ $prompt_impure_vcs_info[top] != $HOME ]]; then + zstyle -t :prompt:impure:git:fetch only_upstream + local only_upstream=$((? == 0)) + async_job "prompt_impure" prompt_impure_async_git_fetch $only_upstream || return + fi + + # If dirty checking is sufficiently fast, + # tell the worker to check it again, or wait for timeout. + integer time_since_last_dirty_check=$(( EPOCHSECONDS - ${prompt_impure_git_last_dirty_check_timestamp:-0} )) + if (( time_since_last_dirty_check > ${IMPURE_GIT_DELAY_DIRTY_CHECK:-1800} )); then + unset prompt_impure_git_last_dirty_check_timestamp + async_job "prompt_impure" prompt_impure_async_git_dirty ${IMPURE_GIT_UNTRACKED_DIRTY:-1} || return + fi + + # If stash is enabled, tell async worker to count stashes + if zstyle -t ":prompt:impure:git:stash" show; then + async_job "prompt_impure" prompt_impure_async_git_stash || return + else + unset prompt_impure_git_stash + fi +} + +prompt_impure_check_git_arrows() { + setopt localoptions noshwordsplit + local arrows left=${1:-0} right=${2:-0} + + (( right > 0 )) && arrows+=${IMPURE_GIT_DOWN_ARROW:-⇣} + (( left > 0 )) && arrows+=${IMPURE_GIT_UP_ARROW:-⇡} + + [[ -n $arrows ]] || return + typeset -g REPLY=$arrows +} + +prompt_impure_async_callback() { + setopt localoptions noshwordsplit + local job=$1 code=$2 output=$3 exec_time=$4 next_pending=$6 + local do_render=0 + + if [[ $job != '[async]' ]] && + (( ! ${prompt_impure_async_inited:-0} )); then + return + fi + + case $job in + prompt_impure_async_vcs_info|prompt_impure_async_git_aliases|prompt_impure_async_git_dirty|prompt_impure_async_git_fetch|prompt_impure_async_git_arrows|prompt_impure_async_git_stash) + [[ ${prompt_impure_worker_env[pwd]-} == $PWD ]] || return + ;; + esac + + case $job in + \[async]) + # Handle all the errors that could indicate a crashed + # async worker. See zsh-async documentation for the + # definition of the exit codes. + if (( code == 2 )) || (( code == 3 )) || (( code == 130 )); then + # Our worker died unexpectedly, try to recover immediately. + typeset -g prompt_impure_async_inited=0 + async_stop_worker prompt_impure + typeset -gA prompt_impure_worker_env=() + typeset -gA prompt_impure_worker_env_pending=() + if prompt_impure_async_init; then + prompt_impure_async_tasks # Restart all tasks. + else + prompt_impure_clear_git_state + do_render=1 + next_pending=0 + fi + + # Reset render state due to restart. + unset prompt_impure_async_render_requested + fi + ;; + \[async/eval]) + typeset -gA prompt_impure_worker_env_pending + local worker_sync_output=${(M)${(f)output}:#prompt_impure_worker_sync:*} + local -a worker_sync_result + worker_sync_result=("${(@s.:.)worker_sync_output}") + if [[ -n $worker_sync_output ]] && + (( ${#prompt_impure_worker_env_pending} )); then + [[ $worker_sync_result[2] == ${prompt_impure_worker_env_pending[token]-} ]] || return + local worker_sync_status=$worker_sync_result[3] + if (( worker_sync_status )); then + prompt_impure_clear_git_state + do_render=1 + next_pending=0 + else + typeset -gA prompt_impure_worker_env + prompt_impure_worker_env[pwd]=$prompt_impure_worker_env_pending[pwd] + prompt_impure_worker_env[git_dir]=$prompt_impure_worker_env_pending[git_dir] + prompt_impure_worker_env[git_work_tree]=$prompt_impure_worker_env_pending[git_work_tree] + typeset -gA prompt_impure_worker_env_pending=() + prompt_impure_async_tasks + fi + elif (( code )); then + # Looks like async_worker_eval failed, + # rerun async tasks just in case. + typeset -gA prompt_impure_worker_env=() + typeset -gA prompt_impure_worker_env_pending=() + prompt_impure_clear_git_state + do_render=1 + next_pending=0 + fi + ;; + prompt_impure_async_vcs_info) + local -A info + typeset -gA prompt_impure_vcs_info + + # Parse output (z) and unquote as array (Q@). + info=("${(Q@)${(z)output}}") + local -H MATCH MBEGIN MEND + if [[ $info[pwd] != $PWD ]]; then + # The path has changed since the check started, abort. + return + fi + # Check if Git top-level has changed. + if [[ $info[top] = $prompt_impure_vcs_info[top] ]]; then + # If the stored pwd is part of $PWD, $PWD is shorter and likelier + # to be top-level, so we update pwd. + if [[ $prompt_impure_vcs_info[pwd] = ${PWD}* ]]; then + prompt_impure_vcs_info[pwd]=$PWD + fi + else + # Store $PWD to detect if we (maybe) left the Git path. + prompt_impure_vcs_info[pwd]=$PWD + fi + unset MATCH MBEGIN MEND + + # The update has a Git top-level set, which means we just entered a new + # Git directory. Run the async refresh tasks. + [[ -n $info[top] ]] && [[ -z $prompt_impure_vcs_info[top] ]] && prompt_impure_async_refresh + + # Always update branch, top-level and stash. + prompt_impure_vcs_info[branch]=$info[branch] + prompt_impure_vcs_info[top]=$info[top] + prompt_impure_vcs_info[action]=$info[action] + + do_render=1 + ;; + prompt_impure_async_git_aliases) + if [[ -n $output ]]; then + # Append custom Git aliases to the predefined ones. + prompt_impure_git_fetch_pattern+="|$output" + fi + ;; + prompt_impure_async_git_dirty) + local prev_staging=$prompt_impure_git_staging + local prev_dirty=$prompt_impure_git_dirty + if (( code == 0 )); then + unset prompt_impure_git_staging + unset prompt_impure_git_dirty + else + # Parse: "staging|working" (e.g. "+2 ~1|*" or "|*" or "+1|") + local staging_part=${output%%|*} + local working_part=${output#*|} + typeset -g prompt_impure_git_staging="$staging_part" + typeset -g prompt_impure_git_dirty="$working_part" + fi + + [[ $prev_staging != ${prompt_impure_git_staging:-} || $prev_dirty != ${prompt_impure_git_dirty:-} ]] && do_render=1 + + # When `prompt_impure_git_last_dirty_check_timestamp` is set, the Git info is displayed + # in a different color. To distinguish between a "fresh" and a "cached" result, the + # preprompt is rendered before setting this variable. Thus, only upon the next + # rendering of the preprompt will the result appear in a different color. + (( $exec_time > 5 )) && prompt_impure_git_last_dirty_check_timestamp=$EPOCHSECONDS + ;; + prompt_impure_async_git_fetch|prompt_impure_async_git_arrows) + # `prompt_impure_async_git_fetch` executes `prompt_impure_async_git_arrows` + # after a successful fetch. + case $code in + 0) + local REPLY + prompt_impure_check_git_arrows ${(ps:\t:)output} + if [[ $prompt_impure_git_arrows != $REPLY ]]; then + typeset -g prompt_impure_git_arrows=$REPLY + do_render=1 + fi + ;; + 97) + # No remote available, make sure to clear git arrows if set. + if [[ -n $prompt_impure_git_arrows ]]; then + typeset -g prompt_impure_git_arrows= + do_render=1 + fi + ;; + 99|98) + # Git fetch failed. + ;; + *) + # Non-zero exit status from `prompt_impure_async_git_arrows`, + # indicating that there is no upstream configured. + if [[ -n $prompt_impure_git_arrows ]]; then + unset prompt_impure_git_arrows + do_render=1 + fi + ;; + esac + ;; + prompt_impure_async_git_stash) + local prev_stash=$prompt_impure_git_stash + typeset -g prompt_impure_git_stash=$output + [[ $prev_stash != $prompt_impure_git_stash ]] && do_render=1 + ;; + prompt_impure_async_jj_status) + local prev_bookmark=$prompt_impure_jj_bookmark + local prev_changeid=$prompt_impure_jj_changeid + local prev_working=$prompt_impure_jj_working + if (( code == 0 )); then + # Parse: "bookmark(s)|changeID working_changes" + # Split on | to separate bookmarks from changeID+working. + local before_pipe after_pipe + before_pipe=${output%%|*} + after_pipe=${output#*|} + typeset -g prompt_impure_jj_bookmark="${before_pipe%% }" + # First token after | is the change ID, rest is working changes. + local -a after_parts + after_parts=(${=after_pipe}) + typeset -g prompt_impure_jj_changeid="${after_parts[1]:-}" + typeset -g prompt_impure_jj_working="${(j: :)after_parts[2,-1]}" + else + typeset -g prompt_impure_jj_bookmark= + typeset -g prompt_impure_jj_changeid= + typeset -g prompt_impure_jj_working= + fi + [[ $prev_bookmark != $prompt_impure_jj_bookmark || $prev_changeid != $prompt_impure_jj_changeid || $prev_working != $prompt_impure_jj_working ]] && do_render=1 + ;; + esac + + if (( next_pending )); then + (( do_render )) && typeset -g prompt_impure_async_render_requested=1 + return + fi + + [[ ${prompt_impure_async_render_requested:-$do_render} = 1 ]] && prompt_impure_preprompt_render + unset prompt_impure_async_render_requested +} + +prompt_impure_reset_prompt() { + if [[ $CONTEXT == cont ]]; then + # When the context is "cont", PS2 is active and calling + # reset-prompt will have no effect on PS1, but it will + # reset the execution context (%_) of PS2 which we don't + # want. Unfortunately, we can't save the output of "%_" + # either because it is only ever rendered as part of the + # prompt, expanding in-place won't work. + return + fi + + zle && zle .reset-prompt +} + +prompt_impure_reset_prompt_symbol() { + prompt_impure_state[prompt]=${IMPURE_PROMPT_SYMBOL:-❯} +} + +prompt_impure_update_vim_prompt_widget() { + setopt localoptions noshwordsplit + prompt_impure_state[prompt]=${${${KEYMAP/vicmd/${IMPURE_PROMPT_VICMD_SYMBOL:-❮}}/visual/${IMPURE_PROMPT_VICMD_SYMBOL:-❮}}/(main|viins)/${IMPURE_PROMPT_SYMBOL:-❯}} + + prompt_impure_reset_prompt +} + +prompt_impure_reset_vim_prompt_widget() { + setopt localoptions noshwordsplit + prompt_impure_reset_prompt_symbol + + # We can't perform a prompt reset at this point because it + # removes the prompt marks inserted by macOS Terminal. +} + +# Transient prompt: after Enter, replace the two-line prompt with just ❯. +# We wrap accept-line to set a flag, then use zle redisplay in line-finish +# to rewrite the prompt area before zsh commits it to scrollback. +prompt_impure_accept_line() { + typeset -g prompt_impure_transient=1 + zle .accept-line +} + +prompt_impure_transient_redraw() { + setopt localoptions noshwordsplit + + (( ${prompt_impure_transient:-0} )) || return + unset prompt_impure_transient + + local prompt_color + if [[ -n ${SSH_CONNECTION:-} ]]; then + prompt_color=$prompt_impure_colors[prompt:ssh] + else + prompt_color=$prompt_impure_colors[prompt:success] + fi + + # Save full prompt, swap to minimal, redraw. + typeset -g prompt_impure_saved_prompt="$PROMPT" + typeset -g prompt_impure_saved_rprompt="$RPROMPT" + PROMPT="%F{${prompt_color}}${IMPURE_PROMPT_SYMBOL:-❯}%f " + RPROMPT= + zle && zle .reset-prompt && zle -R +} + + +prompt_impure_state_setup() { + setopt localoptions noshwordsplit + + # Check SSH_CONNECTION and the current state. + local ssh_connection=${SSH_CONNECTION:-$PROMPT_IMPURE_SSH_CONNECTION} + local username hostname + if [[ -z $ssh_connection ]] && (( $+commands[who] )); then + # When changing user on a remote system, the $SSH_CONNECTION + # environment variable can be lost. Attempt detection via `who`. + local who_out + who_out=$(who -m 2>/dev/null) + if (( $? )); then + # Who am I not supported, fallback to plain who. + local -a who_in + who_in=( ${(f)"$(who 2>/dev/null)"} ) + who_out="${(M)who_in:#*[[:space:]]${TTY#/dev/}[[:space:]]*}" + fi + + local reIPv6='(([0-9a-fA-F]+:)|:){2,}[0-9a-fA-F]+' # Simplified, only checks partial pattern. + local reIPv4='([0-9]{1,3}\.){3}[0-9]+' # Simplified, allows invalid ranges. + # Here we assume two non-consecutive periods represents a + # hostname. This matches `foo.bar.baz`, but not `foo.bar`. + local reHostname='([.][^. ]+){2}' + + # Usually the remote address is surrounded by parenthesis, but + # not on all systems (e.g. busybox). + local -H MATCH MBEGIN MEND + if [[ $who_out =~ "\(?($reIPv4|$reIPv6|$reHostname)\)?\$" ]]; then + ssh_connection=$MATCH + + # Export variable to allow detection propagation inside + # shells spawned by this one (e.g. tmux does not always + # inherit the same tty, which breaks detection). + export PROMPT_IMPURE_SSH_CONNECTION=$ssh_connection + fi + unset MATCH MBEGIN MEND + fi + + local user_color + # Show `username@host` if logged in through SSH. + [[ -n $ssh_connection ]] && user_color=user + + # Show `username@host` if inside a container and not in GitHub Codespaces. + [[ -z "${CODESPACES}" ]] && prompt_impure_is_inside_container && user_color=user + + # Show `username@host` if root, with username in default color. + [[ $UID -eq 0 ]] && user_color=user:root + + # Set psvar[13] flag for username display in PROMPT. + [[ -n $user_color ]] && psvar[13]=1 + + # Check if hostname display is enabled (default: yes). + local show_host=1 + zstyle -T ":prompt:impure:host" show || show_host=0 + + typeset -gA prompt_impure_state + prompt_impure_state[version]="0.1.0" + prompt_impure_state+=( + user_color "$user_color" + show_host "$show_host" + prompt "${IMPURE_PROMPT_SYMBOL:-❯}" + ) +} + +# Return true if executing inside a Docker, OCI, LXC, or systemd-nspawn container. +prompt_impure_is_inside_container() { + local -r nspawn_file='/run/host/container-manager' + local -r podman_crio_file='/run/.containerenv' + local -r docker_file='/.dockerenv' + local -r k8s_token_file='/var/run/secrets/kubernetes.io/serviceaccount/token' + local -r cgroup_file='/proc/1/cgroup' + [[ "$container" == "lxc" ]] \ + || [[ "$container" == "oci" ]] \ + || [[ "$container" == "podman" ]] \ + || [[ -r "$nspawn_file" ]] \ + || [[ -r "$podman_crio_file" ]] \ + || [[ -r "$docker_file" ]] \ + || [[ -r "$k8s_token_file" ]] \ + || [[ -r "$cgroup_file" && "$(< $cgroup_file)" = *(lxc|docker|containerd)* ]] +} + +prompt_impure_system_report() { + setopt localoptions noshwordsplit + + local shell=$SHELL + if [[ -z $shell ]]; then + shell=$commands[zsh] + fi + print - "- Zsh: $($shell --version) ($shell)" + print -n - "- Operating system: " + case "$(uname -s)" in + Darwin) print "$(sw_vers -productName) $(sw_vers -productVersion) ($(sw_vers -buildVersion))";; + *) print "$(uname -s) ($(uname -r) $(uname -v) $(uname -m) $(uname -o))";; + esac + print - "- Terminal program: ${TERM_PROGRAM:-unknown} (${TERM_PROGRAM_VERSION:-unknown})" + print -n - "- Tmux: " + [[ -n $TMUX ]] && print "yes" || print "no" + + local git_version + git_version=($(git --version)) # Remove newlines, if hub is present. + print - "- Git: $git_version" + + print - "- Impure state:" + for k v in "${(@kv)prompt_impure_state}"; do + print - " - $k: \`${(q-)v}\`" + done + print - "- zsh-async version: \`${ASYNC_VERSION}\`" + print - "- PROMPT: \`$(typeset -p PROMPT)\`" + print - "- Colors: \`$(typeset -p prompt_impure_colors)\`" + print - "- TERM: \`$(typeset -p TERM)\`" + print - "- Virtualenv: \`$(typeset -p VIRTUAL_ENV_DISABLE_PROMPT)\`" + + + local ohmyzsh=0 + typeset -la frameworks + (( $+ANTIBODY_HOME )) && frameworks+=("Antibody") + (( $+ADOTDIR )) && frameworks+=("Antigen") + (( $+ANTIGEN_HS_HOME )) && frameworks+=("Antigen-hs") + (( $+functions[upgrade_oh_my_zsh] )) && { + ohmyzsh=1 + frameworks+=("Oh My Zsh") + } + (( $+ZPREZTODIR )) && frameworks+=("Prezto") + (( $+ZPLUG_ROOT )) && frameworks+=("Zplug") + (( $+ZPLGM )) && frameworks+=("Zplugin") + + (( $#frameworks == 0 )) && frameworks+=("None") + print - "- Detected frameworks: ${(j:, :)frameworks}" + + if (( ohmyzsh )); then + print - " - Oh My Zsh:" + print - " - Plugins: ${(j:, :)plugins}" + fi +} + +prompt_impure_preview() { + setopt localoptions noshwordsplit + + prompt_impure_set_colors + + local -A c=("${(@kv)prompt_impure_colors}") + + local path_sample="%F{$c[path]}~/dev/impure%f" + if zstyle -t ':prompt:impure:path:separator' dim; then + path_sample=$(prompt_impure_render_dimmed_path '~/dev/impure') + fi + + local host_sample='' + if zstyle -T ":prompt:impure:host" show; then + host_sample="%F{$c[host]}@heartofgold%f" + fi + + # Sample prompt with all components visible. + # Left side: info segments. Right side (RPROMPT): exec time + nix shell + symbol. + print -P "%F{$c[custom:prefix]}prefix%f %F{$c[suspended_jobs]}${IMPURE_SUSPENDED_JOBS_SYMBOL-✦}%f %F{$c[user]}zaphod%f${host_sample} %F{$c[zmx]}[zmx]%f ${path_sample} %F{$c[git:branch]}main%f%F{$c[git:dirty]}*%f %F{$c[git:action]}rebase-i%f %F{$c[git:arrow]}${IMPURE_GIT_DOWN_ARROW:-⇣}${IMPURE_GIT_UP_ARROW:-⇡}%f %F{$c[git:stash]}${IMPURE_GIT_STASH_SYMBOL-≡}%f %F{$c[custom:suffix]}suffix%f" + print -P " ← left side above | right side below →" + print -P "%F{$c[execution_time]}42s%f %F{$c[nix-shell]}pure%f %F{$c[prompt:success]}${IMPURE_PROMPT_SYMBOL:-❯}%f" + print + print -P "%F{$c[prompt:error]}${IMPURE_PROMPT_SYMBOL:-❯}%f prompt after error" + print; print + print -P "%F{$c[git:branch:cached]}main%f branch color when data is cached" + print; print + print -P "%F{$c[user:root]}root%f${host_sample} root user" + print; print + print -P "%F{$c[prompt:continuation]}… if%f %F{$c[prompt:success]}${IMPURE_PROMPT_SYMBOL:-❯}%f continuation prompt" +} + +# Build RPROMPT string from current psvar state. +prompt_impure_build_rprompt() { + setopt localoptions noshwordsplit + + RPROMPT='' + # Execution time (only if above threshold). + RPROMPT+='%(20V.%F{$prompt_impure_colors[execution_time]}%20v%f.)' + # Nix shell type indicator. + RPROMPT+='%(21V. %F{$prompt_impure_colors[nix-shell]}%21v%f.)' +} + +prompt_impure_setup() { + # Prevent percentage showing up if output doesn't end with a newline. + export PROMPT_EOL_MARK='' + + prompt_opts=(subst percent) + + # Borrowed from `promptinit`. Sets the prompt options in case Impure was not + # initialized via `promptinit`. + setopt noprompt{bang,cr,percent,subst} "prompt${^prompt_opts[@]}" + + if [[ -z $prompt_newline ]]; then + # This variable needs to be set, usually set by promptinit. + typeset -g prompt_newline=$'\n%{\r%}' + fi + + zmodload zsh/datetime + zmodload zsh/zle + zmodload zsh/parameter + zmodload zsh/zutil + + autoload -Uz add-zsh-hook + autoload -Uz vcs_info + autoload -Uz async && async + + # The `add-zle-hook-widget` function is not guaranteed to be available. + # It was added in Zsh 5.3. + autoload -Uz +X add-zle-hook-widget 2>/dev/null + + # Set the colors. + typeset -gA prompt_impure_colors_default prompt_impure_colors + prompt_impure_colors_default=( + custom:prefix 242 + custom:suffix 242 + execution_time yellow + git:arrow cyan + git:stash cyan + git:branch 242 + git:branch:cached red + git:action yellow + git:dirty 242 + host 242 + jj 242 + nix-shell red + path blue + path:ephemeral red + prompt:error red + prompt:success magenta + prompt:ssh cyan + prompt:continuation 242 + suspended_jobs red + user 242 + user:root default + virtualenv 242 + zmx green + ) + prompt_impure_colors=("${(@kv)prompt_impure_colors_default}") + + add-zsh-hook precmd prompt_impure_precmd + add-zsh-hook preexec prompt_impure_preexec + + prompt_impure_state_setup + + zle -N prompt_impure_reset_prompt + zle -N prompt_impure_update_vim_prompt_widget + zle -N prompt_impure_reset_vim_prompt_widget + zle -N prompt_impure_accept_line + zle -N prompt_impure_transient_redraw + if (( $+functions[add-zle-hook-widget] )); then + add-zle-hook-widget zle-line-finish prompt_impure_reset_vim_prompt_widget + add-zle-hook-widget zle-keymap-select prompt_impure_update_vim_prompt_widget + add-zle-hook-widget zle-line-finish prompt_impure_transient_redraw + fi + + # Bind accept-line to our wrapper so we can set the transient flag. + bindkey '^M' prompt_impure_accept_line + bindkey '^J' prompt_impure_accept_line + + # Initialize git globals referenced by PROMPT via prompt subst. + typeset -gA prompt_impure_vcs_info + typeset -g prompt_impure_git_branch_color=$prompt_impure_colors[git:branch] + + # Initialize jj globals. + typeset -g prompt_impure_jj_bookmark= + typeset -g prompt_impure_jj_changeid= + typeset -g prompt_impure_jj_working= + typeset -g prompt_impure_jj_pwd= + + # Construct PROMPT once, both preprompt and prompt line. Kept + # dynamic via variables and psvar[12-25], updated each render + # in prompt_impure_preprompt_render. Numbering starts at 12 for + # legacy reasons (Pure originally used psvar[12] for virtualenv) + # and to avoid collisions with low psvar indices which users + # may rely on (e.g. %v expands psvar[1]). + # + # psvar[12] = suspended jobs symbol (e.g. ✦) + # psvar[13] = username flag, renders user/host (e.g. user@host) + # psvar[14] = git branch + # psvar[15] = git dirty marker, nested inside [14] conditional + # psvar[16] = git action (e.g. rebase, merge) + # psvar[17] = git arrows (e.g. ⇣⇡) + # psvar[18] = git stash symbol (e.g. ≡) + # psvar[19] = exec time (e.g. 1d 3h 2m 5s) — shown in RPROMPT + # psvar[21] = virtualenv/nix-shell name — shown in RPROMPT + # psvar[22] = custom prefix (set by prompt_impure_precustom) + # psvar[23] = custom suffix (set by prompt_impure_precustom) + # psvar[24] = ZMX session tag (e.g. [session-name]) + # psvar[25] = Jujutsu bookmark(s) + # psvar[26] = Jujutsu change ID (cyan) + # psvar[27] = Jujutsu working set changes + # + # Example output: + # hostname [zmx] ~/Code/impure main* rebase ⇣⇡ ≡ ⬢22 3s suffix + # @abc1234 modified ❯ + # + # Preprompt line: each %(NV..) section only renders when its psvar is non-empty. + PROMPT='${prompt_newline}' + PROMPT+='%(13V.%F{$prompt_impure_colors[host]}%m%f .)' + PROMPT+='%(24V.%F{$prompt_impure_colors[zmx]}%24v%f .)' + prompt_impure_set_path_separator + PROMPT+='${${prompt_impure_path_separator_dimmed:+$(prompt_impure_render_dimmed_path)}:-${prompt_impure_path_segment}}' + # Git: branch + staging summary + dirty marker + arrows (oh-my-posh style) + PROMPT+='%(14V. %F{${prompt_impure_git_branch_color}}%14v%f.)' + PROMPT+='%(15V.%F{$prompt_impure_colors[git:dirty]} (%15v)%f.)' + PROMPT+='%(16V.%F{$prompt_impure_colors[git:dirty]}%16v%f.)' + PROMPT+='%(17V. %F{$prompt_impure_colors[git:action]}%17v%f.)' + PROMPT+='%(18V. %F{cyan}%18v%f.)' + PROMPT+='%(19V. %F{$prompt_impure_colors[git:stash]}%19v%f.)' + # Jujutsu: bookmark (grey) + @changeID (cyan) + working changes (grey) + PROMPT+='%(25V. %F{$prompt_impure_colors[jj]}%25v%f.)' + PROMPT+='%(26V. %F{cyan}@%26v%f.)' + PROMPT+='%(27V. %F{$prompt_impure_colors[jj]}%27v%f.)' + PROMPT+='%(23V. %F{$prompt_impure_colors[custom:prefix]}%23v%f.)' + PROMPT+='%(24V. %F{$prompt_impure_colors[custom:suffix]}%24v%f.)' + + # Newline before the prompt symbol. + PROMPT+='${prompt_newline}' + # Prompt symbol: cyan over SSH, magenta locally, red on error. + PROMPT+='%(?.%(13V.%F{$prompt_impure_colors[prompt:ssh]}.%F{$prompt_impure_colors[prompt:success]}).%F{$prompt_impure_colors[prompt:error]})${prompt_impure_state[prompt]}%f ' + + # Indicate continuation prompt by … and use a darker color for it. + PROMPT2='%F{$prompt_impure_colors[prompt:continuation]}… %(1_.%_ .%_)%f%(?.%(13V.%F{$prompt_impure_colors[prompt:ssh]}.%F{$prompt_impure_colors[prompt:success]}).%F{$prompt_impure_colors[prompt:error]})${prompt_impure_state[prompt]}%f ' + + # Build right prompt. + prompt_impure_build_rprompt + + # Store prompt expansion symbols for in-place expansion via (%). For + # some reason it does not work without storing them in a variable first. + typeset -ga prompt_impure_debug_depth + prompt_impure_debug_depth=('%e' '%N' '%x') + + # Compare is used to check if %N equals %x. When they differ, the main + # prompt is used to allow displaying both filename and function. When + # they match, we use the secondary prompt to avoid displaying duplicate + # information. + local -A ps4_parts + ps4_parts=( + depth '%F{yellow}${(l:${(%)prompt_impure_debug_depth[1]}::+:)}%f' + compare '${${(%)prompt_impure_debug_depth[2]}:#${(%)prompt_impure_debug_depth[3]}}' + main '%F{blue}${${(%)prompt_impure_debug_depth[3]}:t}%f%F{242}:%I%f %F{242}@%f%F{blue}%N%f%F{242}:%i%f' + secondary '%F{blue}%N%f%F{242}:%i' + prompt '%F{242}>%f ' + ) + # Combine the parts with conditional logic. First the `:+` operator is + # used to replace `compare` either with `main` or an empty string. Then + # the `:-` operator is used so that if `compare` becomes an empty + # string, it is replaced with `secondary`. + local ps4_symbols='${${'${ps4_parts[compare]}':+"'${ps4_parts[main]}'"}:-"'${ps4_parts[secondary]}'"}' + + # Improve the debug prompt (PS4), show depth by repeating the +-sign and + # add colors to highlight essential parts like file and function name. + PROMPT4="${ps4_parts[depth]} ${ps4_symbols}${ps4_parts[prompt]}" + + # Guard against Oh My Zsh themes overriding Impure. + unset ZSH_THEME + + # Guard against (ana)conda changing the PS1 prompt + # (we manually insert the env when it's available). + + # Guard against pyenv-virtualenv changing the PS1 prompt + # (we manually insert the env when it's available). + export PYENV_VIRTUALENV_DISABLE_PROMPT=1 +} + +prompt_impure_setup "$@" diff --git a/prompt_impure_setup b/prompt_impure_setup new file mode 120000 index 0000000..8dc4c34 --- /dev/null +++ b/prompt_impure_setup @@ -0,0 +1 @@ +impure.zsh \ No newline at end of file diff --git a/prompt_pure_setup b/prompt_pure_setup deleted file mode 120000 index f5f60e7..0000000 --- a/prompt_pure_setup +++ /dev/null @@ -1 +0,0 @@ -pure.zsh \ No newline at end of file diff --git a/pure.plugin.zsh b/pure.plugin.zsh deleted file mode 120000 index f5f60e7..0000000 --- a/pure.plugin.zsh +++ /dev/null @@ -1 +0,0 @@ -pure.zsh \ No newline at end of file diff --git a/pure.zsh b/pure.zsh deleted file mode 100644 index 0f287cb..0000000 --- a/pure.zsh +++ /dev/null @@ -1,1243 +0,0 @@ -# Pure -# by Sindre Sorhus -# https://github.com/sindresorhus/pure -# MIT License - -# For my own and others sanity -# git: -# %b => current branch -# %a => current action (rebase/merge) -# prompt: -# %F => color dict -# %f => reset color -# %~ => current path -# %* => time -# %n => username -# %m => shortname host -# %(?..) => prompt conditional - %(condition.true.false) -# terminal codes: -# \e7 => save cursor position -# \e[2A => move cursor 2 lines up -# \e[1G => go to position 1 in terminal -# \e8 => restore cursor position -# \e[K => clears everything after the cursor on the current line -# \e[2K => clear everything on the current line - - -# Turns seconds into human readable time. -# 165392 => 1d 21h 56m 32s -# https://github.com/sindresorhus/pretty-time-zsh -prompt_pure_human_time_to_var() { - local human total_seconds=$1 var=$2 - local days=$(( total_seconds / 60 / 60 / 24 )) - local hours=$(( total_seconds / 60 / 60 % 24 )) - local minutes=$(( total_seconds / 60 % 60 )) - local seconds=$(( total_seconds % 60 )) - (( days > 0 )) && human+="${days}d " - (( hours > 0 )) && human+="${hours}h " - (( minutes > 0 )) && human+="${minutes}m " - human+="${seconds}s" - - # Store human readable time in a variable as specified by the caller - typeset -g "${var}"="${human}" -} - -# Stores (into prompt_pure_cmd_exec_time) the execution -# time of the last command if set threshold was exceeded. -prompt_pure_check_cmd_exec_time() { - integer elapsed - (( elapsed = EPOCHSECONDS - ${prompt_pure_cmd_timestamp:-$EPOCHSECONDS} )) - typeset -g prompt_pure_cmd_exec_time= - (( elapsed > ${PURE_CMD_MAX_EXEC_TIME:-5} )) && { - prompt_pure_human_time_to_var $elapsed "prompt_pure_cmd_exec_time" - } -} - -prompt_pure_set_title() { - setopt localoptions noshwordsplit - - # Allow disabling title management. - zstyle -T ":prompt:pure:title" show || return - - # Emacs terminal does not support settings the title. - (( ${+EMACS} || ${+INSIDE_EMACS} )) && return - - case $TTY in - # Don't set title over serial console. - /dev/ttyS[0-9]*) return;; - esac - - # Show hostname if connected via SSH and host display is enabled. - local hostname= - if (( psvar[13] )) && (( ${prompt_pure_state[show_host]:-1} )); then - # Expand in-place in case ignore-escape is used. - hostname="${(%):-(%m) }" - fi - - local -a opts - case $1 in - expand-prompt) opts=(-P);; - ignore-escape) opts=(-r);; - esac - - # Set title atomically in one print statement so that it works when XTRACE is enabled. - print -n $opts $'\e]0;'${hostname}${2}$'\a' -} - -prompt_pure_preexec() { - if [[ -n $prompt_pure_git_fetch_pattern ]]; then - # Detect when Git is performing pull/fetch, including Git aliases. - local -H MATCH MBEGIN MEND match mbegin mend - if [[ $2 =~ (git|hub)\ (.*\ )?($prompt_pure_git_fetch_pattern)(\ .*)?$ ]]; then - # We must flush the async jobs to cancel our git fetch in order - # to avoid conflicts with the user issued pull / fetch. - async_flush_jobs 'prompt_pure' - fi - fi - - typeset -g prompt_pure_cmd_timestamp=$EPOCHSECONDS - - # Shows the current directory and executed command in the title while a process is active. - prompt_pure_set_title 'ignore-escape' "$PWD:t: $2" - - # Disallow Python virtualenv from updating the prompt. Set it to 20 if - # untouched by the user to indicate that Pure modified it. Here we use - # the magic number 20, same as in `psvar`. - export VIRTUAL_ENV_DISABLE_PROMPT=${VIRTUAL_ENV_DISABLE_PROMPT:-20} -} - -# Change the colors if their value are different from the current ones. -prompt_pure_set_colors() { - local color_temp key value - for key value in ${(kv)prompt_pure_colors}; do - zstyle -t ":prompt:pure:$key" color "$value" && continue - case $? in - 1) # The current style is different from the one from zstyle. - zstyle -s ":prompt:pure:$key" color color_temp - prompt_pure_colors[${key}]=$color_temp ;; - 2) # No style is defined. - prompt_pure_colors[${key}]=${prompt_pure_colors_default[${key}]} ;; - esac - done - - prompt_pure_set_path_separator - - return 0 -} - -prompt_pure_set_path_separator() { - typeset -g prompt_pure_path_segment="%F{${prompt_pure_colors[path]}}%~%f" - - if zstyle -t ':prompt:pure:path:separator' dim; then - typeset -g prompt_pure_path_separator_dimmed=1 - else - typeset -g prompt_pure_path_separator_dimmed= - fi -} - -prompt_pure_render_dimmed_path() { - setopt localoptions noshwordsplit - - # This runs from PROMPT_SUBST so directory changes followed by reset-prompt redraw correctly without precmd. - local current_path=${1:-${(%):-%~}} - current_path=${current_path//\%/%%} - - local separator=$'%{\e[2m%}/%{\e[22m%}' - # Keep the leading / on absolute paths at full brightness. - local prefix= - if [[ $current_path == /* ]]; then - prefix=/ - current_path=${current_path:1} - fi - print -n -r -- "%F{${prompt_pure_colors[path]}}${prefix}${current_path//\//$separator}%f" -} - -prompt_pure_preprompt_render() { - setopt localoptions noshwordsplit - - unset prompt_pure_async_render_requested - - # Update git branch color based on cache state. - typeset -g prompt_pure_git_branch_color=$prompt_pure_colors[git:branch] - [[ -n ${prompt_pure_git_last_dirty_check_timestamp+x} ]] && prompt_pure_git_branch_color=$prompt_pure_colors[git:branch:cached] - - # Update psvar values. PROMPT uses %(NV.true.false) to conditionally - # render each part. See prompt_pure_setup for the PROMPT template. - # - # psvar[12]: Suspended jobs symbol. - psvar[12]= - ((${(M)#jobstates:#suspended:*} != 0)) && psvar[12]=${PURE_SUSPENDED_JOBS_SYMBOL-✦} - - # psvar[13]: Username flag (set once in prompt_pure_state_setup). - - # psvar[14]: Git branch name. - psvar[14]=${prompt_pure_vcs_info[branch]} - - # psvar[15]: Git dirty marker. - psvar[15]=${prompt_pure_git_dirty} - - # psvar[16]: Git action (rebase/merge). - psvar[16]=${prompt_pure_vcs_info[action]} - - # psvar[17]: Git arrows (push/pull). - psvar[17]=${prompt_pure_git_arrows} - - # psvar[18]: Git stash symbol. - psvar[18]= - [[ -n $prompt_pure_git_stash ]] && psvar[18]=${PURE_GIT_STASH_SYMBOL-≡} - - # psvar[19]: Command execution time. - psvar[19]=${prompt_pure_cmd_exec_time} - - # psvar[21]: Node.js version. - psvar[21]= - if [[ -n $prompt_pure_node_version ]]; then - local node_symbol - zstyle -s ":prompt:pure:environment:node_version" symbol node_symbol || node_symbol='⬢' - psvar[21]="${node_symbol}${prompt_pure_node_version}" - fi - - # psvar[22]: Custom prefix, psvar[23]: Custom suffix. - # Set by the user-defined prompt_pure_precustom function. - psvar[22]= - psvar[23]= - if (( $+functions[prompt_pure_precustom] )); then - prompt_pure_precustom - fi - - # Build a fingerprint from all dynamic prompt components to detect changes - # without expanding PROMPT (which forks a subshell when dimmed path is on). - local -a prompt_fingerprint_parts=( - "${psvar[12]}" - "${psvar[13]}" - "${psvar[14]}" - "${psvar[15]}" - "${psvar[16]}" - "${psvar[17]}" - "${psvar[18]}" - "${psvar[19]}" - "${psvar[20]}" - "${psvar[21]}" - "${psvar[22]}" - "${psvar[23]}" - "${prompt_pure_state[prompt]}" - "${prompt_pure_git_branch_color}" - "${PWD}" - ) - local prompt_fingerprint="${(pj:|:)${(@qqq)prompt_fingerprint_parts}}" - - if [[ $1 == precmd ]]; then - # Initial newline, for spaciousness. - print - elif [[ $prompt_pure_last_prompt != $prompt_fingerprint ]]; then - # Redraw the prompt. - prompt_pure_reset_prompt - fi - - typeset -g prompt_pure_last_prompt=$prompt_fingerprint -} - -prompt_pure_precmd() { - setopt localoptions noshwordsplit - - # Check execution time and store it in a variable. - prompt_pure_check_cmd_exec_time - unset prompt_pure_cmd_timestamp - - # Shows the full path in the title. - prompt_pure_set_title 'expand-prompt' '%~' - - # Modify the colors if some have changed.. - prompt_pure_set_colors - - # Perform async Git dirty check and fetch. - prompt_pure_async_tasks - - # Check if we should display the virtual env (psvar[20]). - psvar[20]= - if zstyle -T ":prompt:pure:environment:virtualenv" show; then - # Check if a Conda environment is active and display its name. - # The 'base' environment is always active and not informative. - if [[ -n $CONDA_DEFAULT_ENV ]] && [[ ${CONDA_DEFAULT_ENV:t} != base ]]; then - psvar[20]="${${CONDA_DEFAULT_ENV:t}//[$'\t\r\n']}" - fi - # When VIRTUAL_ENV_DISABLE_PROMPT is empty, it was unset by the user and - # Pure should take back control. - if [[ -n $VIRTUAL_ENV ]] && [[ -z $VIRTUAL_ENV_DISABLE_PROMPT || $VIRTUAL_ENV_DISABLE_PROMPT = 20 ]]; then - if [[ -n $VIRTUAL_ENV_PROMPT ]]; then - psvar[20]="${VIRTUAL_ENV_PROMPT}" - else - psvar[20]="${VIRTUAL_ENV:t}" - fi - export VIRTUAL_ENV_DISABLE_PROMPT=20 - fi - fi - - # Nix package manager integration. If used from within 'nix shell' - shell name is shown like so: - # ~/Projects/flake-utils-plus master - # flake-utils-plus ❯ - if zstyle -T ":prompt:pure:environment:nix-shell" show; then - if [[ -n $IN_NIX_SHELL ]]; then - psvar[20]="${name:-nix-shell}" - fi - fi - - # Make sure VIM prompt is reset. - prompt_pure_reset_prompt_symbol - - # Print the preprompt. - prompt_pure_preprompt_render "precmd" - - if [[ -n $ZSH_THEME ]]; then - print "WARNING: Oh My Zsh themes are enabled (ZSH_THEME='${ZSH_THEME}'). Pure might not be working correctly." - print "For more information, see: https://github.com/sindresorhus/pure#oh-my-zsh" - unset ZSH_THEME # Only show this warning once. - fi -} - -prompt_pure_async_git_aliases() { - setopt localoptions noshwordsplit - local -a gitalias pullalias - - # List all aliases and split on newline. - gitalias=(${(@f)"$(command git config --get-regexp "^alias\.")"}) - for line in $gitalias; do - parts=(${(@)=line}) # Split line on spaces. - aliasname=${parts[1]#alias.} # Grab the name (alias.[name]). - shift parts # Remove `aliasname` - - # Check alias for pull or fetch. Must be exact match. - if [[ $parts =~ ^(.*\ )?(pull|fetch)(\ .*)?$ ]]; then - pullalias+=($aliasname) - fi - done - - print -- ${(j:|:)pullalias} # Join on pipe, for use in regex. -} - -prompt_pure_async_vcs_info() { - setopt localoptions noshwordsplit - - # Configure `vcs_info` inside an async task. This frees up `vcs_info` - # to be used or configured as the user pleases. - zstyle ':vcs_info:*' enable git - zstyle ':vcs_info:*' use-simple true - # Only export four message variables from `vcs_info`. - zstyle ':vcs_info:*' max-exports 3 - # Export branch (%b), Git toplevel (%R), action (rebase/cherry-pick) (%a) - zstyle ':vcs_info:git*' formats '%b' '%R' '%a' - zstyle ':vcs_info:git*' actionformats '%b' '%R' '%a' - - vcs_info - - local -A info - info[pwd]=$PWD - info[branch]=${vcs_info_msg_0_//\%/%%} - info[top]=$vcs_info_msg_1_ - info[action]=$vcs_info_msg_2_ - - print -r - ${(@kvq)info} -} - -# Fastest possible way to check if a Git repo is dirty. -# When detailed mode is enabled, outputs markers: * (unstaged), + (staged), ? (untracked). -prompt_pure_async_git_dirty() { - setopt localoptions noshwordsplit - local untracked_dirty=$1 - local detailed=${2:-0} - local untracked_git_mode=$(command git config --get status.showUntrackedFiles) - if [[ "$untracked_git_mode" != 'no' ]]; then - untracked_git_mode='normal' - fi - - # Prevent e.g. `git status` from refreshing the index as a side effect. - export GIT_OPTIONAL_LOCKS=0 - - if (( ! detailed )); then - if [[ $untracked_dirty = 0 ]]; then - command git diff --no-ext-diff --quiet --exit-code || return $? - command git diff --no-ext-diff --cached --quiet --exit-code - else - test -z "$(command git status --porcelain -u${untracked_git_mode})" - fi - - return - fi - - local u_flag - if [[ $untracked_dirty = 0 ]]; then - u_flag='-uno' - else - u_flag="-u${untracked_git_mode}" - fi - - local output - output=$(command git status --porcelain $u_flag) - [[ -z $output ]] && return 0 - - local has_unstaged=0 has_staged=0 has_untracked=0 line - for line in "${(f)output}"; do - (( ! has_unstaged )) && [[ ${line[2]} == [MTDUA] ]] && has_unstaged=1 - (( ! has_staged )) && [[ ${line[1]} == [MTADRCU] ]] && has_staged=1 - (( ! has_untracked )) && [[ $line == '??'* ]] && has_untracked=1 - (( has_unstaged + has_staged + has_untracked == 3 )) && break - done - - local markers="" - (( has_unstaged )) && markers+="*" - (( has_staged )) && markers+="+" - (( has_untracked )) && markers+="?" - - print -r - "$markers" - return 1 -} - -prompt_pure_async_git_fetch() { - setopt localoptions noshwordsplit - - local only_upstream=${1:-0} - - # Sets `GIT_TERMINAL_PROMPT=0` to disable authentication prompt for Git fetch (Git 2.3+). - export GIT_TERMINAL_PROMPT=0 - # Set SSH `BachMode` to disable all interactive SSH password prompting. - export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-"ssh"} -o BatchMode=yes" - - # If gpg-agent is set to handle SSH keys for `git fetch`, make - # sure it doesn't corrupt the parent TTY. - # Setting an empty GPG_TTY forces pinentry-curses to close immediately rather - # than stall indefinitely waiting for user input. - export GPG_TTY= - - local -a remote - if ((only_upstream)); then - local ref - ref=$(command git symbolic-ref -q HEAD) - # Set remote to only fetch information for the current branch. - remote=($(command git for-each-ref --format='%(upstream:remotename) %(refname)' $ref)) - if [[ -z $remote[1] ]]; then - # No remote specified for this branch, skip fetch. - return 97 - fi - fi - - # Default return code, which indicates Git fetch failure. - local fail_code=99 - - # Guard against all forms of password prompts. By setting the shell into - # MONITOR mode we can notice when a child process prompts for user input - # because it will be suspended. Since we are inside an async worker, we - # have no way of transmitting the password and the only option is to - # kill it. If we don't do it this way, the process will corrupt with the - # async worker. - setopt localtraps monitor - - # Make sure local HUP trap is unset to allow for signal propagation when - # the async worker is flushed. - trap - HUP - - trap ' - # Unset trap to prevent infinite loop - trap - CHLD - if [[ $jobstates = suspended* ]]; then - # Set fail code to password prompt and kill the fetch. - fail_code=98 - kill %% - fi - ' CHLD - - # Do git fetch and avoid fetching tags or - # submodules to speed up the process. - command git -c gc.auto=0 -c fetch.prune=false fetch \ - --quiet \ - --no-tags \ - --no-prune-tags \ - --recurse-submodules=no \ - $remote &>/dev/null & - wait $! || return $fail_code - - unsetopt monitor - - # Check arrow status after a successful `git fetch`. - prompt_pure_async_git_arrows -} - -prompt_pure_async_git_arrows() { - setopt localoptions noshwordsplit - command git rev-list --left-right --count HEAD...@'{u}' -} - -prompt_pure_async_git_stash() { - command git rev-list --walk-reflogs --count refs/stash -} - -prompt_pure_check_node_version() { - setopt localoptions noshwordsplit - - # Walk up to find package.json (similar to how git detects repos). - local dir=$PWD - while [[ $dir != "/" ]]; do - [[ -f "$dir/package.json" ]] && break - dir=${dir:h} - done - - local version= - if [[ -f "$dir/package.json" ]]; then - version=$(command node --version 2>/dev/null) || version= - version=${${${version#v}%%.*}//[$'\t\r\n']} - fi - - print -r -- "$version" -} - -# Try to lower the priority of the worker so that disk heavy operations -# like `git status` has less impact on the system responsivity. -prompt_pure_async_renice() { - setopt localoptions noshwordsplit - - if command -v renice >/dev/null; then - command renice +15 -p $$ - fi - - if command -v ionice >/dev/null; then - command ionice -c 3 -p $$ - fi -} - -prompt_pure_async_worker_sync() { - setopt localoptions noshwordsplit - - local sync_token=$1 target_pwd=$2 has_git_dir=$3 git_dir=$4 has_git_work_tree=$5 git_work_tree=$6 - - if ! builtin cd -q "$target_pwd"; then - builtin cd -q / - unset GIT_DIR GIT_WORK_TREE - print -r -- "prompt_pure_worker_sync:$sync_token:1" - return 1 - fi - - if (( has_git_dir )); then - export GIT_DIR=$git_dir - else - unset GIT_DIR - fi - - if (( has_git_work_tree )); then - export GIT_WORK_TREE=$git_work_tree - else - unset GIT_WORK_TREE - fi - - print -r -- "prompt_pure_worker_sync:$sync_token:0" -} - -prompt_pure_clear_git_state() { - unset prompt_pure_git_dirty prompt_pure_git_last_dirty_check_timestamp prompt_pure_git_arrows prompt_pure_git_stash prompt_pure_git_fetch_pattern - typeset -gA prompt_pure_worker_env=() - typeset -gA prompt_pure_worker_env_pending=() - typeset -gA prompt_pure_vcs_info - prompt_pure_vcs_info[branch]= - prompt_pure_vcs_info[top]= - prompt_pure_vcs_info[action]= - prompt_pure_vcs_info[pwd]= -} - -prompt_pure_async_init() { - typeset -g prompt_pure_async_inited - if ((${prompt_pure_async_inited:-0})); then - return - fi - if ! async_start_worker "prompt_pure" -u -n 2>/dev/null; then - # Worker failed to start (e.g. zpty permission denied). - # Degrade gracefully by skipping async git operations. - return 1 - fi - prompt_pure_async_inited=1 - async_register_callback "prompt_pure" prompt_pure_async_callback - async_worker_eval "prompt_pure" prompt_pure_async_renice -} - -prompt_pure_async_tasks() { - setopt localoptions noshwordsplit - - # Check if Node.js version display is enabled (independent of Git). - if zstyle -t ":prompt:pure:environment:node_version" show; then - # Cache key uses "|" separator so the value is never a valid directory - # path, preventing zsh from treating it as a named directory for %~. - local node_cache_key="$PWD|$PATH" - if [[ ${prompt_pure_node_cache_key-} != "$node_cache_key" ]]; then - typeset -g prompt_pure_node_version=$(prompt_pure_check_node_version) - typeset -g prompt_pure_node_cache_key=$node_cache_key - fi - else - unset prompt_pure_node_version - unset prompt_pure_node_cache_key - fi - - # Check if git integration is enabled (default: yes). - if ! zstyle -T ":prompt:pure:git" show; then - # Flush any in-flight async git jobs. - if (( ${prompt_pure_async_inited:-0} )); then - async_flush_jobs "prompt_pure" - fi - - prompt_pure_clear_git_state - return - fi - - # Initialize the async worker. If it fails (e.g. zpty unavailable), - # skip all async tasks and show prompt without git info. - if ! prompt_pure_async_init; then - prompt_pure_clear_git_state - return - fi - - # Sync working directory and git environment variables to the async worker. - # Skip if nothing changed since last sync (common case: running commands in same dir). - # Uses an associative array to avoid scalar globals triggering AUTO_NAME_DIRS. - typeset -gA prompt_pure_worker_env - typeset -gA prompt_pure_worker_env_pending - local cur_git_dir=${GIT_DIR-__unset__} - local cur_git_work_tree=${GIT_WORK_TREE-__unset__} - if [[ $PWD != ${prompt_pure_worker_env[pwd]-} || - $cur_git_dir != ${prompt_pure_worker_env[git_dir]-} || - $cur_git_work_tree != ${prompt_pure_worker_env[git_work_tree]-} ]]; then - (( ${#prompt_pure_worker_env_pending} )) && return - prompt_pure_clear_git_state - async_flush_jobs "prompt_pure" - typeset -gi prompt_pure_worker_sync_token - (( prompt_pure_worker_sync_token++ )) - local sync_token=$prompt_pure_worker_sync_token - prompt_pure_worker_env_pending[pwd]=$PWD - prompt_pure_worker_env_pending[git_dir]=$cur_git_dir - prompt_pure_worker_env_pending[git_work_tree]=$cur_git_work_tree - prompt_pure_worker_env_pending[token]=$sync_token - async_worker_eval "prompt_pure" \ - prompt_pure_async_worker_sync $sync_token "$PWD" ${+GIT_DIR} "${GIT_DIR-}" ${+GIT_WORK_TREE} "${GIT_WORK_TREE-}" || { - if [[ ${prompt_pure_worker_env_pending[token]-} == $sync_token ]]; then - typeset -gA prompt_pure_worker_env_pending=() - fi - return - } - return - fi - - typeset -gA prompt_pure_vcs_info - - local -H MATCH MBEGIN MEND - if [[ $PWD != ${prompt_pure_vcs_info[pwd]}* ]]; then - # Stop any running async jobs. - async_flush_jobs "prompt_pure" - - # Reset preprompt variables, switching working tree. - unset prompt_pure_git_dirty - unset prompt_pure_git_last_dirty_check_timestamp - unset prompt_pure_git_arrows - unset prompt_pure_git_stash - unset prompt_pure_git_fetch_pattern - prompt_pure_vcs_info[branch]= - prompt_pure_vcs_info[top]= - fi - unset MATCH MBEGIN MEND - - async_job "prompt_pure" prompt_pure_async_vcs_info || return - - # Only perform tasks inside a Git working tree. - [[ -n $prompt_pure_vcs_info[top] ]] || return - - prompt_pure_async_refresh -} - -prompt_pure_async_refresh() { - setopt localoptions noshwordsplit - - if [[ -z $prompt_pure_git_fetch_pattern ]]; then - # We set the pattern here to avoid redoing the pattern check until the - # working tree has changed. Pull and fetch are always valid patterns. - typeset -g prompt_pure_git_fetch_pattern="pull|fetch" - async_job "prompt_pure" prompt_pure_async_git_aliases || return - fi - - async_job "prompt_pure" prompt_pure_async_git_arrows || return - - # Do not perform `git fetch` if it is disabled or in home folder. - if (( ${PURE_GIT_PULL:-1} )) && [[ $prompt_pure_vcs_info[top] != $HOME ]]; then - zstyle -t :prompt:pure:git:fetch only_upstream - local only_upstream=$((? == 0)) - async_job "prompt_pure" prompt_pure_async_git_fetch $only_upstream || return - fi - - # If dirty checking is sufficiently fast, - # tell the worker to check it again, or wait for timeout. - integer time_since_last_dirty_check=$(( EPOCHSECONDS - ${prompt_pure_git_last_dirty_check_timestamp:-0} )) - if (( time_since_last_dirty_check > ${PURE_GIT_DELAY_DIRTY_CHECK:-1800} )); then - unset prompt_pure_git_last_dirty_check_timestamp - # Check if the working tree is dirty. - zstyle -t ":prompt:pure:git:dirty" detailed - local detailed_dirty=$((? == 0)) - async_job "prompt_pure" prompt_pure_async_git_dirty ${PURE_GIT_UNTRACKED_DIRTY:-1} $detailed_dirty || return - fi - - # If stash is enabled, tell async worker to count stashes - if zstyle -t ":prompt:pure:git:stash" show; then - async_job "prompt_pure" prompt_pure_async_git_stash || return - else - unset prompt_pure_git_stash - fi -} - -prompt_pure_check_git_arrows() { - setopt localoptions noshwordsplit - local arrows left=${1:-0} right=${2:-0} - - (( right > 0 )) && arrows+=${PURE_GIT_DOWN_ARROW:-⇣} - (( left > 0 )) && arrows+=${PURE_GIT_UP_ARROW:-⇡} - - [[ -n $arrows ]] || return - typeset -g REPLY=$arrows -} - -prompt_pure_async_callback() { - setopt localoptions noshwordsplit - local job=$1 code=$2 output=$3 exec_time=$4 next_pending=$6 - local do_render=0 - - if [[ $job != '[async]' ]] && - (( ! ${prompt_pure_async_inited:-0} )); then - return - fi - - case $job in - prompt_pure_async_vcs_info|prompt_pure_async_git_aliases|prompt_pure_async_git_dirty|prompt_pure_async_git_fetch|prompt_pure_async_git_arrows|prompt_pure_async_git_stash) - [[ ${prompt_pure_worker_env[pwd]-} == $PWD ]] || return - ;; - esac - - case $job in - \[async]) - # Handle all the errors that could indicate a crashed - # async worker. See zsh-async documentation for the - # definition of the exit codes. - if (( code == 2 )) || (( code == 3 )) || (( code == 130 )); then - # Our worker died unexpectedly, try to recover immediately. - # TODO(mafredri): Do we need to handle next_pending - # and defer the restart? - typeset -g prompt_pure_async_inited=0 - async_stop_worker prompt_pure - typeset -gA prompt_pure_worker_env=() - typeset -gA prompt_pure_worker_env_pending=() - if prompt_pure_async_init; then - prompt_pure_async_tasks # Restart all tasks. - else - prompt_pure_clear_git_state - do_render=1 - next_pending=0 - fi - - # Reset render state due to restart. - unset prompt_pure_async_render_requested - fi - ;; - \[async/eval]) - typeset -gA prompt_pure_worker_env_pending - local worker_sync_output=${(M)${(f)output}:#prompt_pure_worker_sync:*} - local -a worker_sync_result - worker_sync_result=("${(@s.:.)worker_sync_output}") - if [[ -n $worker_sync_output ]] && - (( ${#prompt_pure_worker_env_pending} )); then - [[ $worker_sync_result[2] == ${prompt_pure_worker_env_pending[token]-} ]] || return - local worker_sync_status=$worker_sync_result[3] - if (( worker_sync_status )); then - prompt_pure_clear_git_state - do_render=1 - next_pending=0 - else - typeset -gA prompt_pure_worker_env - prompt_pure_worker_env[pwd]=$prompt_pure_worker_env_pending[pwd] - prompt_pure_worker_env[git_dir]=$prompt_pure_worker_env_pending[git_dir] - prompt_pure_worker_env[git_work_tree]=$prompt_pure_worker_env_pending[git_work_tree] - typeset -gA prompt_pure_worker_env_pending=() - prompt_pure_async_tasks - fi - elif (( code )); then - # Looks like async_worker_eval failed, - # rerun async tasks just in case. - typeset -gA prompt_pure_worker_env=() - typeset -gA prompt_pure_worker_env_pending=() - prompt_pure_clear_git_state - do_render=1 - next_pending=0 - fi - ;; - prompt_pure_async_vcs_info) - local -A info - typeset -gA prompt_pure_vcs_info - - # Parse output (z) and unquote as array (Q@). - info=("${(Q@)${(z)output}}") - local -H MATCH MBEGIN MEND - if [[ $info[pwd] != $PWD ]]; then - # The path has changed since the check started, abort. - return - fi - # Check if Git top-level has changed. - if [[ $info[top] = $prompt_pure_vcs_info[top] ]]; then - # If the stored pwd is part of $PWD, $PWD is shorter and likelier - # to be top-level, so we update pwd. - if [[ $prompt_pure_vcs_info[pwd] = ${PWD}* ]]; then - prompt_pure_vcs_info[pwd]=$PWD - fi - else - # Store $PWD to detect if we (maybe) left the Git path. - prompt_pure_vcs_info[pwd]=$PWD - fi - unset MATCH MBEGIN MEND - - # The update has a Git top-level set, which means we just entered a new - # Git directory. Run the async refresh tasks. - [[ -n $info[top] ]] && [[ -z $prompt_pure_vcs_info[top] ]] && prompt_pure_async_refresh - - # Always update branch, top-level and stash. - prompt_pure_vcs_info[branch]=$info[branch] - prompt_pure_vcs_info[top]=$info[top] - prompt_pure_vcs_info[action]=$info[action] - - do_render=1 - ;; - prompt_pure_async_git_aliases) - if [[ -n $output ]]; then - # Append custom Git aliases to the predefined ones. - prompt_pure_git_fetch_pattern+="|$output" - fi - ;; - prompt_pure_async_git_dirty) - local prev_dirty=$prompt_pure_git_dirty - if (( code == 0 )); then - unset prompt_pure_git_dirty - else - typeset -g prompt_pure_git_dirty="${output:-*}" - fi - - [[ $prev_dirty != $prompt_pure_git_dirty ]] && do_render=1 - - # When `prompt_pure_git_last_dirty_check_timestamp` is set, the Git info is displayed - # in a different color. To distinguish between a "fresh" and a "cached" result, the - # preprompt is rendered before setting this variable. Thus, only upon the next - # rendering of the preprompt will the result appear in a different color. - (( $exec_time > 5 )) && prompt_pure_git_last_dirty_check_timestamp=$EPOCHSECONDS - ;; - prompt_pure_async_git_fetch|prompt_pure_async_git_arrows) - # `prompt_pure_async_git_fetch` executes `prompt_pure_async_git_arrows` - # after a successful fetch. - case $code in - 0) - local REPLY - prompt_pure_check_git_arrows ${(ps:\t:)output} - if [[ $prompt_pure_git_arrows != $REPLY ]]; then - typeset -g prompt_pure_git_arrows=$REPLY - do_render=1 - fi - ;; - 97) - # No remote available, make sure to clear git arrows if set. - if [[ -n $prompt_pure_git_arrows ]]; then - typeset -g prompt_pure_git_arrows= - do_render=1 - fi - ;; - 99|98) - # Git fetch failed. - ;; - *) - # Non-zero exit status from `prompt_pure_async_git_arrows`, - # indicating that there is no upstream configured. - if [[ -n $prompt_pure_git_arrows ]]; then - unset prompt_pure_git_arrows - do_render=1 - fi - ;; - esac - ;; - prompt_pure_async_git_stash) - local prev_stash=$prompt_pure_git_stash - typeset -g prompt_pure_git_stash=$output - [[ $prev_stash != $prompt_pure_git_stash ]] && do_render=1 - ;; - esac - - if (( next_pending )); then - (( do_render )) && typeset -g prompt_pure_async_render_requested=1 - return - fi - - [[ ${prompt_pure_async_render_requested:-$do_render} = 1 ]] && prompt_pure_preprompt_render - unset prompt_pure_async_render_requested -} - -prompt_pure_reset_prompt() { - if [[ $CONTEXT == cont ]]; then - # When the context is "cont", PS2 is active and calling - # reset-prompt will have no effect on PS1, but it will - # reset the execution context (%_) of PS2 which we don't - # want. Unfortunately, we can't save the output of "%_" - # either because it is only ever rendered as part of the - # prompt, expanding in-place won't work. - return - fi - - zle && zle .reset-prompt -} - -prompt_pure_reset_prompt_symbol() { - prompt_pure_state[prompt]=${PURE_PROMPT_SYMBOL:-❯} -} - -prompt_pure_update_vim_prompt_widget() { - setopt localoptions noshwordsplit - prompt_pure_state[prompt]=${${${KEYMAP/vicmd/${PURE_PROMPT_VICMD_SYMBOL:-❮}}/visual/${PURE_PROMPT_VICMD_SYMBOL:-❮}}/(main|viins)/${PURE_PROMPT_SYMBOL:-❯}} - - prompt_pure_reset_prompt -} - -prompt_pure_reset_vim_prompt_widget() { - setopt localoptions noshwordsplit - prompt_pure_reset_prompt_symbol - - # We can't perform a prompt reset at this point because it - # removes the prompt marks inserted by macOS Terminal. -} - -prompt_pure_state_setup() { - setopt localoptions noshwordsplit - - # Check SSH_CONNECTION and the current state. - local ssh_connection=${SSH_CONNECTION:-$PROMPT_PURE_SSH_CONNECTION} - local username hostname - if [[ -z $ssh_connection ]] && (( $+commands[who] )); then - # When changing user on a remote system, the $SSH_CONNECTION - # environment variable can be lost. Attempt detection via `who`. - local who_out - who_out=$(who -m 2>/dev/null) - if (( $? )); then - # Who am I not supported, fallback to plain who. - local -a who_in - who_in=( ${(f)"$(who 2>/dev/null)"} ) - who_out="${(M)who_in:#*[[:space:]]${TTY#/dev/}[[:space:]]*}" - fi - - local reIPv6='(([0-9a-fA-F]+:)|:){2,}[0-9a-fA-F]+' # Simplified, only checks partial pattern. - local reIPv4='([0-9]{1,3}\.){3}[0-9]+' # Simplified, allows invalid ranges. - # Here we assume two non-consecutive periods represents a - # hostname. This matches `foo.bar.baz`, but not `foo.bar`. - local reHostname='([.][^. ]+){2}' - - # Usually the remote address is surrounded by parenthesis, but - # not on all systems (e.g. busybox). - local -H MATCH MBEGIN MEND - if [[ $who_out =~ "\(?($reIPv4|$reIPv6|$reHostname)\)?\$" ]]; then - ssh_connection=$MATCH - - # Export variable to allow detection propagation inside - # shells spawned by this one (e.g. tmux does not always - # inherit the same tty, which breaks detection). - export PROMPT_PURE_SSH_CONNECTION=$ssh_connection - fi - unset MATCH MBEGIN MEND - fi - - local user_color - # Show `username@host` if logged in through SSH. - [[ -n $ssh_connection ]] && user_color=user - - # Show `username@host` if inside a container and not in GitHub Codespaces. - [[ -z "${CODESPACES}" ]] && prompt_pure_is_inside_container && user_color=user - - # Show `username@host` if root, with username in default color. - [[ $UID -eq 0 ]] && user_color=user:root - - # Set psvar[13] flag for username display in PROMPT. - [[ -n $user_color ]] && psvar[13]=1 - - # Check if hostname display is enabled (default: yes). - local show_host=1 - zstyle -T ":prompt:pure:host" show || show_host=0 - - typeset -gA prompt_pure_state - prompt_pure_state[version]="1.28.1" - prompt_pure_state+=( - user_color "$user_color" - show_host "$show_host" - prompt "${PURE_PROMPT_SYMBOL:-❯}" - ) -} - -# Return true if executing inside a Docker, OCI, LXC, or systemd-nspawn container. -prompt_pure_is_inside_container() { - local -r nspawn_file='/run/host/container-manager' - local -r podman_crio_file='/run/.containerenv' - local -r docker_file='/.dockerenv' - local -r k8s_token_file='/var/run/secrets/kubernetes.io/serviceaccount/token' - local -r cgroup_file='/proc/1/cgroup' - [[ "$container" == "lxc" ]] \ - || [[ "$container" == "oci" ]] \ - || [[ "$container" == "podman" ]] \ - || [[ -r "$nspawn_file" ]] \ - || [[ -r "$podman_crio_file" ]] \ - || [[ -r "$docker_file" ]] \ - || [[ -r "$k8s_token_file" ]] \ - || [[ -r "$cgroup_file" && "$(< $cgroup_file)" = *(lxc|docker|containerd)* ]] -} - -prompt_pure_system_report() { - setopt localoptions noshwordsplit - - local shell=$SHELL - if [[ -z $shell ]]; then - shell=$commands[zsh] - fi - print - "- Zsh: $($shell --version) ($shell)" - print -n - "- Operating system: " - case "$(uname -s)" in - Darwin) print "$(sw_vers -productName) $(sw_vers -productVersion) ($(sw_vers -buildVersion))";; - *) print "$(uname -s) ($(uname -r) $(uname -v) $(uname -m) $(uname -o))";; - esac - print - "- Terminal program: ${TERM_PROGRAM:-unknown} (${TERM_PROGRAM_VERSION:-unknown})" - print -n - "- Tmux: " - [[ -n $TMUX ]] && print "yes" || print "no" - - local git_version - git_version=($(git --version)) # Remove newlines, if hub is present. - print - "- Git: $git_version" - - print - "- Pure state:" - for k v in "${(@kv)prompt_pure_state}"; do - print - " - $k: \`${(q-)v}\`" - done - print - "- zsh-async version: \`${ASYNC_VERSION}\`" - print - "- PROMPT: \`$(typeset -p PROMPT)\`" - print - "- Colors: \`$(typeset -p prompt_pure_colors)\`" - print - "- TERM: \`$(typeset -p TERM)\`" - print - "- Virtualenv: \`$(typeset -p VIRTUAL_ENV_DISABLE_PROMPT)\`" - print - "- Conda: \`$(typeset -p CONDA_CHANGEPS1)\`" - - local ohmyzsh=0 - typeset -la frameworks - (( $+ANTIBODY_HOME )) && frameworks+=("Antibody") - (( $+ADOTDIR )) && frameworks+=("Antigen") - (( $+ANTIGEN_HS_HOME )) && frameworks+=("Antigen-hs") - (( $+functions[upgrade_oh_my_zsh] )) && { - ohmyzsh=1 - frameworks+=("Oh My Zsh") - } - (( $+ZPREZTODIR )) && frameworks+=("Prezto") - (( $+ZPLUG_ROOT )) && frameworks+=("Zplug") - (( $+ZPLGM )) && frameworks+=("Zplugin") - - (( $#frameworks == 0 )) && frameworks+=("None") - print - "- Detected frameworks: ${(j:, :)frameworks}" - - if (( ohmyzsh )); then - print - " - Oh My Zsh:" - print - " - Plugins: ${(j:, :)plugins}" - fi -} - -prompt_pure_preview() { - setopt localoptions noshwordsplit - - prompt_pure_set_colors - - local -A c=("${(@kv)prompt_pure_colors}") - local node_symbol - zstyle -s ":prompt:pure:environment:node_version" symbol node_symbol || node_symbol='⬢' - - local path_sample="%F{$c[path]}~/dev/pure%f" - if zstyle -t ':prompt:pure:path:separator' dim; then - path_sample=$(prompt_pure_render_dimmed_path '~/dev/pure') - fi - - local host_sample='' - if zstyle -T ":prompt:pure:host" show; then - host_sample="%F{$c[host]}@heartofgold%f" - fi - - # Sample preprompt with all components visible. - print -P "%F{$c[custom:prefix]}prefix%f %F{$c[suspended_jobs]}${PURE_SUSPENDED_JOBS_SYMBOL-✦}%f %F{$c[user]}zaphod%f${host_sample} ${path_sample} %F{$c[git:branch]}main%f%F{$c[git:dirty]}*%f %F{$c[git:action]}rebase-i%f %F{$c[git:arrow]}${PURE_GIT_DOWN_ARROW:-⇣}${PURE_GIT_UP_ARROW:-⇡}%f %F{$c[git:stash]}${PURE_GIT_STASH_SYMBOL-≡}%f %F{$c[node_version]}${node_symbol}22%f %F{$c[execution_time]}42s%f %F{$c[custom:suffix]}suffix%f" - print -P "%F{$c[virtualenv]}venv%f %F{$c[prompt:success]}${PURE_PROMPT_SYMBOL:-❯}%f" - print - print -P "%F{$c[prompt:error]}${PURE_PROMPT_SYMBOL:-❯}%f prompt after error" - print; print - print -P "%F{$c[git:branch:cached]}main%f branch color when data is cached" - print; print - print -P "%F{$c[user:root]}root%f${host_sample} root user" - print; print - print -P "%F{$c[prompt:continuation]}… if%f %F{$c[prompt:success]}${PURE_PROMPT_SYMBOL:-❯}%f continuation prompt" -} - -prompt_pure_setup() { - # Prevent percentage showing up if output doesn't end with a newline. - export PROMPT_EOL_MARK='' - - prompt_opts=(subst percent) - - # Borrowed from `promptinit`. Sets the prompt options in case Pure was not - # initialized via `promptinit`. - setopt noprompt{bang,cr,percent,subst} "prompt${^prompt_opts[@]}" - - if [[ -z $prompt_newline ]]; then - # This variable needs to be set, usually set by promptinit. - typeset -g prompt_newline=$'\n%{\r%}' - fi - - zmodload zsh/datetime - zmodload zsh/zle - zmodload zsh/parameter - zmodload zsh/zutil - - autoload -Uz add-zsh-hook - autoload -Uz vcs_info - autoload -Uz async && async - - # The `add-zle-hook-widget` function is not guaranteed to be available. - # It was added in Zsh 5.3. - autoload -Uz +X add-zle-hook-widget 2>/dev/null - - # Set the colors. - typeset -gA prompt_pure_colors_default prompt_pure_colors - prompt_pure_colors_default=( - custom:prefix 242 - custom:suffix 242 - execution_time yellow - git:arrow cyan - git:stash cyan - git:branch 242 - git:branch:cached red - git:action yellow - git:dirty 218 - host 242 - node_version green - path blue - prompt:error red - prompt:success magenta - prompt:continuation 242 - suspended_jobs red - user 242 - user:root default - virtualenv 242 - ) - prompt_pure_colors=("${(@kv)prompt_pure_colors_default}") - - add-zsh-hook precmd prompt_pure_precmd - add-zsh-hook preexec prompt_pure_preexec - - prompt_pure_state_setup - - zle -N prompt_pure_reset_prompt - zle -N prompt_pure_update_vim_prompt_widget - zle -N prompt_pure_reset_vim_prompt_widget - if (( $+functions[add-zle-hook-widget] )); then - add-zle-hook-widget zle-line-finish prompt_pure_reset_vim_prompt_widget - add-zle-hook-widget zle-keymap-select prompt_pure_update_vim_prompt_widget - fi - - # Initialize git globals referenced by PROMPT via prompt subst. - typeset -gA prompt_pure_vcs_info - typeset -g prompt_pure_git_branch_color=$prompt_pure_colors[git:branch] - - # Construct PROMPT once, both preprompt and prompt line. Kept - # dynamic via variables and psvar[12-21], updated each render - # in prompt_pure_preprompt_render. Numbering starts at 12 for - # legacy reasons (Pure originally used psvar[12] for virtualenv) - # and to avoid collisions with low psvar indices which users - # may rely on (e.g. %v expands psvar[1]). - # - # psvar[12] = suspended jobs symbol (e.g. ✦) - # psvar[13] = username flag, renders user/host (e.g. user@host) - # psvar[14] = git branch - # psvar[15] = git dirty marker, nested inside [14] conditional - # psvar[16] = git action (e.g. rebase, merge) - # psvar[17] = git arrows (e.g. ⇣⇡) - # psvar[18] = git stash symbol (e.g. ≡) - # psvar[19] = exec time (e.g. 1d 3h 2m 5s) - # psvar[20] = virtualenv/conda/nix-shell name - # psvar[21] = Node.js version (e.g. ⬢22) - # psvar[22] = custom prefix (set by prompt_pure_precustom) - # psvar[23] = custom suffix (set by prompt_pure_precustom) - # - # Example output: - # prefix ✦ user@host ~/Code/pure main* rebase ⇣⇡ ≡ ⬢22 3s suffix - # myenv ❯ - # - # Preprompt line: each %(NV..) section only renders when its psvar is non-empty. - PROMPT='%(22V.%F{$prompt_pure_colors[custom:prefix]}%22v%f .)' - PROMPT+='%(12V.%F{$prompt_pure_colors[suspended_jobs]}%12v%f .)' - local hostname_part='' - if (( prompt_pure_state[show_host] )); then - hostname_part='%F{$prompt_pure_colors[host]}@%m%f' - fi - PROMPT+='%(13V.%F{$prompt_pure_colors['"${prompt_pure_state[user_color]:-user}"']}%n%f'"${hostname_part}"' .)' - prompt_pure_set_path_separator - PROMPT+='${${prompt_pure_path_separator_dimmed:+$(prompt_pure_render_dimmed_path)}:-${prompt_pure_path_segment}}' - PROMPT+='%(14V. %F{${prompt_pure_git_branch_color}}%14v%(15V.%F{$prompt_pure_colors[git:dirty]}%15v.)%f.)' - PROMPT+='%(16V. %F{$prompt_pure_colors[git:action]}%16v%f.)' - PROMPT+='%(17V. %F{$prompt_pure_colors[git:arrow]}%17v%f.)' - PROMPT+='%(18V. %F{$prompt_pure_colors[git:stash]}%18v%f.)' - PROMPT+='%(21V. %F{$prompt_pure_colors[node_version]}%21v%f.)' - PROMPT+='%(19V. %F{$prompt_pure_colors[execution_time]}%19v%f.)' - PROMPT+='%(23V. %F{$prompt_pure_colors[custom:suffix]}%23v%f.)' - - # Newline separating preprompt from prompt. - PROMPT+='${prompt_newline}' - - # Prompt line: virtualenv and prompt symbol. - PROMPT+='%(20V.%F{$prompt_pure_colors[virtualenv]}%20v%f .)' - # Prompt symbol: turns red if the previous command didn't exit with 0. - local prompt_indicator='%(?.%F{$prompt_pure_colors[prompt:success]}.%F{$prompt_pure_colors[prompt:error]})${prompt_pure_state[prompt]}%f ' - PROMPT+=$prompt_indicator - - # Indicate continuation prompt by … and use a darker color for it. - PROMPT2='%F{$prompt_pure_colors[prompt:continuation]}… %(1_.%_ .%_)%f'$prompt_indicator - - # Store prompt expansion symbols for in-place expansion via (%). For - # some reason it does not work without storing them in a variable first. - typeset -ga prompt_pure_debug_depth - prompt_pure_debug_depth=('%e' '%N' '%x') - - # Compare is used to check if %N equals %x. When they differ, the main - # prompt is used to allow displaying both filename and function. When - # they match, we use the secondary prompt to avoid displaying duplicate - # information. - local -A ps4_parts - ps4_parts=( - depth '%F{yellow}${(l:${(%)prompt_pure_debug_depth[1]}::+:)}%f' - compare '${${(%)prompt_pure_debug_depth[2]}:#${(%)prompt_pure_debug_depth[3]}}' - main '%F{blue}${${(%)prompt_pure_debug_depth[3]}:t}%f%F{242}:%I%f %F{242}@%f%F{blue}%N%f%F{242}:%i%f' - secondary '%F{blue}%N%f%F{242}:%i' - prompt '%F{242}>%f ' - ) - # Combine the parts with conditional logic. First the `:+` operator is - # used to replace `compare` either with `main` or an empty string. Then - # the `:-` operator is used so that if `compare` becomes an empty - # string, it is replaced with `secondary`. - local ps4_symbols='${${'${ps4_parts[compare]}':+"'${ps4_parts[main]}'"}:-"'${ps4_parts[secondary]}'"}' - - # Improve the debug prompt (PS4), show depth by repeating the +-sign and - # add colors to highlight essential parts like file and function name. - PROMPT4="${ps4_parts[depth]} ${ps4_symbols}${ps4_parts[prompt]}" - - # Pure does not use a right-side prompt. Clear RPROMPT to prevent - # frameworks (e.g. Prezto) from leaking a previous theme's RPROMPT. - RPROMPT= - - # Guard against Oh My Zsh themes overriding Pure. - unset ZSH_THEME - - # Guard against (ana)conda changing the PS1 prompt - # (we manually insert the env when it's available). - export CONDA_CHANGEPS1=no - - # Guard against pyenv-virtualenv changing the PS1 prompt - # (we manually insert the env when it's available). - export PYENV_VIRTUALENV_DISABLE_PROMPT=1 -} - -prompt_pure_setup "$@" diff --git a/tests/test-prompt.zsh b/tests/test-prompt.zsh new file mode 100644 index 0000000..62777a0 --- /dev/null +++ b/tests/test-prompt.zsh @@ -0,0 +1,266 @@ +#!/usr/bin/env zsh +# Test harness for impure prompt — automated edge case testing. +# Usage: zsh tests/test-prompt.zsh [test-name] +# test-name: transient | fingerprint | jj | all (default: all) + +setopt NO_GLOBAL_RCS NO_RCS + +TESTS_DIR="${0:A:h}" +IMPURE_DIR="${TESTS_DIR:A:h}" + +source "$IMPURE_DIR/async.zsh" +source "$IMPURE_DIR/impure.zsh" + +PASS=0 +FAIL=0 + +assert_eq() { + local desc=$1 expected=$2 actual=$3 + if [[ "$expected" == "$actual" ]]; then + print -P "%F{green}✓%f $desc" + (( PASS++ )) + else + print -P "%F{red}✗%f $desc" + print -P " expected: ${(qq)expected}" + print -P " actual: ${(qq)actual}" + (( FAIL++ )) + fi +} + +assert_not_empty() { + local desc=$1 value=$2 + if [[ -n "$value" ]]; then + print -P "%F{green}✓%f $desc" + (( PASS++ )) + else + print -P "%F{red}✗%f $desc (empty)" + (( FAIL++ )) + fi +} + +assert_empty() { + local desc=$1 value=$2 + if [[ -z "$value" ]]; then + print -P "%F{green}✓%f $desc" + (( PASS++ )) + else + print -P "%F{red}✗%f $desc (got: ${(qq)value})" + (( FAIL++ )) + fi +} + +# --- Tests --- + +test_colors_initialized() { + print "\n--- Colors ---" + assert_not_empty "prompt:success color set" "$prompt_impure_colors[prompt:success]" + assert_not_empty "prompt:error color set" "$prompt_impure_colors[prompt:error]" + assert_not_empty "prompt:ssh color set" "$prompt_impure_colors[prompt:ssh]" + assert_not_empty "path color set" "$prompt_impure_colors[path]" + assert_not_empty "path:ephemeral color set" "$prompt_impure_colors[path:ephemeral]" + assert_not_empty "jj color set" "$prompt_impure_colors[jj]" + assert_not_empty "zmx color set" "$prompt_impure_colors[zmx]" + assert_not_empty "nix-shell color set" "$prompt_impure_colors[nix-shell]" +} + +test_prompt_contains_symbol() { + print "\n--- Prompt structure ---" + # PROMPT should contain the prompt symbol reference + if [[ "$PROMPT" == *'prompt_impure_state[prompt]'* ]]; then + print -P "%F{green}✓%f PROMPT contains symbol reference" + (( PASS++ )) + else + print -P "%F{red}✗%f PROMPT missing symbol reference" + (( FAIL++ )) + fi + + # PROMPT should NOT contain prompt_newline before the symbol (single info line) + # but SHOULD contain it somewhere (two-line layout: info + chevron) + if [[ "$PROMPT" == *'prompt_newline'* ]]; then + print -P "%F{green}✓%f PROMPT has newline (two-line layout)" + (( PASS++ )) + else + print -P "%F{red}✗%f PROMPT missing newline" + (( FAIL++ )) + fi +} + +test_rprompt_built() { + print "\n--- RPROMPT ---" + assert_not_empty "RPROMPT is set" "$RPROMPT" + # RPROMPT should contain exec time conditional + if [[ "$RPROMPT" == *'20V'* ]]; then + print -P "%F{green}✓%f RPROMPT contains exec time" + (( PASS++ )) + else + print -P "%F{red}✗%f RPROMPT missing exec time" + (( FAIL++ )) + fi +} + +test_fingerprint_stable() { + print "\n--- Fingerprint stability ---" + # Simulate two consecutive precmd renders with no state change + prompt_impure_preprompt_render "precmd" + local fp1="$prompt_impure_last_prompt" + + prompt_impure_preprompt_render + local fp2="$prompt_impure_last_prompt" + + assert_eq "fingerprint stable across renders" "$fp1" "$fp2" + + # Exec time should NOT be in fingerprint + # Set exec time, check fingerprint doesn't change + typeset -g prompt_impure_cmd_exec_time="5s" + psvar[20]="5s" + psvar[21]= # clear nix-shell to avoid interference + prompt_impure_preprompt_render + local fp3="$prompt_impure_last_prompt" + + assert_eq "exec time excluded from fingerprint" "$fp1" "$fp3" + + unset prompt_impure_cmd_exec_time + psvar[19]= +} + +test_transient_flag() { + print "\n--- Transient prompt ---" + # accept-line wrapper should exist + if (( $+widgets[prompt_impure_accept_line] )); then + print -P "%F{green}✓%f accept-line widget registered" + (( PASS++ )) + else + print -P "%F{red}✗%f accept-line widget not registered" + (( FAIL++ )) + fi + + # transient redraw widget should exist + if (( $+widgets[prompt_impure_transient_redraw] )); then + print -P "%F{green}✓%f transient redraw widget registered" + (( PASS++ )) + else + print -P "%F{red}✗%f transient redraw widget not registered" + (( FAIL++ )) + fi + + # Simulate transient cycle + typeset -g prompt_impure_transient=1 + local saved_prompt="$PROMPT" + local saved_rprompt="$RPROMPT" + + # Manually call the redraw (outside zle context, so skip zle calls) + prompt_impure_transient_redraw 2>/dev/null + + assert_eq "PROMPT swapped to minimal" "%F{${prompt_impure_colors[prompt:success]}}❯%f " "$PROMPT" + assert_empty "RPROMPT cleared during transient" "$RPROMPT" + assert_not_empty "saved prompt stored" "$prompt_impure_saved_prompt" + + # Restore via precmd path + PROMPT=$prompt_impure_saved_prompt + RPROMPT=$prompt_impure_saved_rprompt + unset prompt_impure_saved_prompt prompt_impure_saved_rprompt + prompt_impure_build_rprompt + + assert_eq "PROMPT restored after transient" "$saved_prompt" "$PROMPT" +} + +test_zmx_segment() { + print "\n--- ZMX session ---" + ZMX_SESSION="test-session" + prompt_impure_preprompt_render "precmd" + assert_eq "ZMX psvar set" "[test-session]" "${psvar[24]}" + unset ZMX_SESSION + prompt_impure_preprompt_render "precmd" + assert_empty "ZMX psvar cleared" "${psvar[24]}" +} + +test_ephemeral_path() { + print "\n--- Ephemeral path ---" + ATELIER_EPHEMERAL=1 + prompt_impure_set_path_separator + if [[ "$prompt_impure_path_segment" == *"${prompt_impure_colors[path:ephemeral]}"* ]]; then + print -P "%F{green}✓%f ephemeral uses red path color" + (( PASS++ )) + else + print -P "%F{red}✗%f ephemeral not using red path color" + (( FAIL++ )) + fi + unset ATELIER_EPHEMERAL + prompt_impure_set_path_separator + if [[ "$prompt_impure_path_segment" == *"${prompt_impure_colors[path]}"* ]]; then + print -P "%F{green}✓%f normal uses blue path color" + (( PASS++ )) + else + print -P "%F{red}✗%f normal not using blue path color" + (( FAIL++ )) + fi +} + +test_jj_async_function() { + print "\n--- Jujutsu async ---" + # The async function should exist + if (( $+functions[prompt_impure_async_jj_status] )); then + print -P "%F{green}✓%f jj async status function exists" + (( PASS++ )) + else + print -P "%F{red}✗%f jj async status function missing" + (( FAIL++ )) + fi + + # Outside a jj repo, should return 1 + (cd /tmp && prompt_impure_async_jj_status) 2>/dev/null + local ret=$? + assert_eq "jj status returns 1 outside jj repo" "1" "$ret" +} + +test_ssh_prompt_color() { + print "\n--- SSH prompt color ---" + # Without SSH, prompt indicator should use prompt:success + if [[ "$PROMPT" == *'prompt:ssh'* ]] && [[ "$PROMPT" == *'13V'*'prompt:ssh'* ]]; then + print -P "%F{green}✓%f PROMPT has SSH-conditional color" + (( PASS++ )) + else + print -P "%F{red}✗%f PROMPT missing SSH-conditional color" + (( FAIL++ )) + fi +} + +# --- Runner --- + +run_test() { + case $1 in + colors) test_colors_initialized ;; + structure) test_prompt_contains_symbol ;; + rprompt) test_rprompt_built ;; + fingerprint) test_fingerprint_stable ;; + transient) test_transient_flag ;; + zmx) test_zmx_segment ;; + ephemeral) test_ephemeral_path ;; + jj) test_jj_async_function ;; + ssh) test_ssh_prompt_color ;; + all) + test_colors_initialized + test_prompt_contains_symbol + test_rprompt_built + test_fingerprint_stable + test_transient_flag + test_zmx_segment + test_ephemeral_path + test_jj_async_function + test_ssh_prompt_color + ;; + *) + print "Unknown test: $1" + print "Available: colors structure rprompt fingerprint transient zmx ephemeral jj ssh all" + exit 1 + ;; + esac +} + +target=${1:-all} +run_test "$target" + +print "\n--- Results ---" +print -P "%F{green}${PASS} passed%f, %F{red}${FAIL} failed%f" +(( FAIL > 0 )) && exit 1 +exit 0 -- 2.51.2