diff --git a/lib/async_prompt.zsh b/lib/async_prompt.zsh index 151e24b8c..868088614 100644 --- a/lib/async_prompt.zsh +++ b/lib/async_prompt.zsh @@ -46,7 +46,7 @@ function _omz_register_handler { function _omz_async_request { setopt localoptions noksharrays unset local -i ret=$? - typeset -gA _OMZ_ASYNC_FDS _OMZ_ASYNC_PIDS _OMZ_ASYNC_OUTPUT + typeset -gA _OMZ_ASYNC_FDS _OMZ_ASYNC_PIDS _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING # executor runs a subshell for all async requests based on key local handler @@ -79,6 +79,7 @@ function _omz_async_request { # Define global variables to store the file descriptor, PID and output _OMZ_ASYNC_FDS[$handler]=-1 _OMZ_ASYNC_PIDS[$handler]=-1 + _OMZ_ASYNC_PENDING[$handler]=1 # Fork a process to fetch the git status and open a pipe to read from it exec {fd}< <( @@ -117,14 +118,23 @@ function _omz_async_callback() { # Get handler name from fd local handler="${(k)_OMZ_ASYNC_FDS[(r)$fd]}" - # Store old output which is supposed to be already printed + # Store old output and pending state before updating local old_output="${_OMZ_ASYNC_OUTPUT[$handler]}" + local was_pending="${_OMZ_ASYNC_PENDING[$handler]}" + + # Mark handler as no longer pending + _OMZ_ASYNC_PENDING[$handler]=0 # Read output from fd IFS= read -r -u $fd -d '' "_OMZ_ASYNC_OUTPUT[$handler]" - # Repaint prompt if output has changed - if [[ "$old_output" != "${_OMZ_ASYNC_OUTPUT[$handler]}" ]]; then + # Repaint prompt if output has changed, or if the git prompt handler was + # pending — even when the output is identical, the prompt needs redrawing + # to clear stale/unbolded styling applied while the handler was in-flight. + # Only the git handler uses pending-state styling, so other handlers skip + # the extra repaint to avoid unnecessary redraws. + if [[ "$old_output" != "${_OMZ_ASYNC_OUTPUT[$handler]}" ]] \ + || { (( was_pending )) && [[ "$handler" == _omz_git_prompt_info ]]; }; then zle .reset-prompt zle -R fi diff --git a/lib/git.zsh b/lib/git.zsh index 8d38f3268..94fa27754 100644 --- a/lib/git.zsh +++ b/lib/git.zsh @@ -36,7 +36,19 @@ function _omz_git_prompt_info() { && upstream=" -> ${upstream}" fi - echo "${ZSH_THEME_GIT_PROMPT_PREFIX}${ref:gs/%/%%}${upstream:gs/%/%%}$(parse_git_dirty)${ZSH_THEME_GIT_PROMPT_SUFFIX}" + local escaped_ref="${ref:gs/%/%%}${upstream:gs/%/%%}" + local dirty="$(parse_git_dirty)" + + # In async context, output ref and dirty indicator separated by Unit Separator + # (U+001F) so the renderer can assemble the prompt and apply pending-state + # styling without needing to decompose a pre-formatted string. + # Check for the specific handler key to avoid false positives when the + # associative array exists but this handler hasn't been registered. + if (( ${+_OMZ_ASYNC_PENDING[_omz_git_prompt_info]} )); then + printf '%s\x1f%s' "$escaped_ref" "$dirty" + else + echo "${ZSH_THEME_GIT_PROMPT_PREFIX}${escaped_ref}${dirty}${ZSH_THEME_GIT_PROMPT_SUFFIX}" + fi } function _omz_git_prompt_status() { @@ -143,21 +155,95 @@ function _omz_git_prompt_status() { # - https://github.com/ohmyzsh/ohmyzsh/issues/12331 # - https://github.com/ohmyzsh/ohmyzsh/issues/12360 # TODO(2024-06-12): @mcornella remove workaround when CentOS 7 reaches EOL + +# Helper functions for async pending-state rendering (used by _omz_render_git_prompt_info). + +# Detect whether bold is the active terminal state at the end of a prompt string. +# Expands zsh prompt escapes (%B, %b) then parses ANSI SGR sequences in order. +# NOTE: Only standard SGR sequences (\e[...m) are parsed. Non-SGR CSI sequences +# (e.g. cursor movement) in the prefix may cause incorrect results. +function _omz_is_bold_at_end() { + local expanded=$(print -Pn -- "$1") + local is_bold=0 remaining="$expanded" + local params p + while [[ "$remaining" == *$'\e['*'m'* ]]; do + remaining="${remaining#*$'\e['}" + params="${remaining%%m*}" + remaining="${remaining#*m}" + # Bare \e[m (empty params) is equivalent to \e[0m (full reset) + if [[ -z "$params" ]]; then + is_bold=0 + else + for p in ${(s/;/)params}; do + case "$p" in + 0) is_bold=0 ;; + 1|01) is_bold=1 ;; + 22) is_bold=0 ;; + esac + done + fi + done + return $(( ! is_bold )) +} + +function _omz_render_git_prompt_info() { + local raw="${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]}" + [[ -z "$raw" ]] && return + + # Backward compat: if output has no Unit Separator, it's the old single-line format + if [[ "$raw" != *$'\x1f'* ]]; then + echo -n "$raw" + return + fi + + # Async output is two fields separated by Unit Separator (U+001F): ref and dirty indicator. + # Assemble the prompt from these parts and the current theme variables. + local ref="${raw%%$'\x1f'*}" + local dirty="${raw#*$'\x1f'}" + + if (( _OMZ_ASYNC_PENDING[_omz_git_prompt_info] )); then + local stale_prefix="${ZSH_THEME_GIT_PROMPT_STALE_PREFIX-}" + local stale_suffix="${ZSH_THEME_GIT_PROMPT_STALE_SUFFIX-}" + + # If user hasn't set custom stale vars, auto-detect from the theme prefix: + # only apply unbold/rebold if the ref text would actually be rendered bold. + # Cache the result keyed on the prefix value to avoid re-parsing SGR on every render. + if (( ! ${+ZSH_THEME_GIT_PROMPT_STALE_PREFIX} && ! ${+ZSH_THEME_GIT_PROMPT_STALE_SUFFIX} )); then + if [[ "$ZSH_THEME_GIT_PROMPT_PREFIX" != "$_OMZ_CACHED_BOLD_PREFIX" ]]; then + typeset -g _OMZ_CACHED_BOLD_PREFIX="$ZSH_THEME_GIT_PROMPT_PREFIX" + if _omz_is_bold_at_end "$ZSH_THEME_GIT_PROMPT_PREFIX"; then + typeset -g _OMZ_CACHED_BOLD_RESULT=1 + else + typeset -g _OMZ_CACHED_BOLD_RESULT=0 + fi + fi + if (( _OMZ_CACHED_BOLD_RESULT )); then + stale_prefix=$'%{\e[22m%}' + stale_suffix=$'%{\e[1m%}' + fi + fi + + echo -n "${ZSH_THEME_GIT_PROMPT_PREFIX}${stale_prefix}${ref}${dirty}${stale_suffix}${ZSH_THEME_GIT_PROMPT_SUFFIX}" + else + echo -n "${ZSH_THEME_GIT_PROMPT_PREFIX}${ref}${dirty}${ZSH_THEME_GIT_PROMPT_SUFFIX}" + fi +} + +# Async prompt functions — used by both the auto-detect and "force" branches below. +# Overridden with synchronous versions if async is disabled. +function git_prompt_info() { + _omz_render_git_prompt_info +} + +function git_prompt_status() { + if [[ -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" ]]; then + echo -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" + fi +} + local _style if zstyle -t ':omz:alpha:lib:git' async-prompt \ || { is-at-least 5.0.6 && zstyle -T ':omz:alpha:lib:git' async-prompt }; then - function git_prompt_info() { - if [[ -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]}" ]]; then - echo -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]}" - fi - } - - function git_prompt_status() { - if [[ -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" ]]; then - echo -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" - fi - } - # Conditionally register the async handler, only if it's needed in $PROMPT # or any of the other prompt variables function _defer_async_git_register() { @@ -182,18 +268,6 @@ if zstyle -t ':omz:alpha:lib:git' async-prompt \ # the async request prompt is run precmd_functions=(_defer_async_git_register $precmd_functions) elif zstyle -s ':omz:alpha:lib:git' async-prompt _style && [[ $_style == "force" ]]; then - function git_prompt_info() { - if [[ -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]}" ]]; then - echo -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]}" - fi - } - - function git_prompt_status() { - if [[ -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" ]]; then - echo -n "${_OMZ_ASYNC_OUTPUT[_omz_git_prompt_status]}" - fi - } - _omz_register_handler _omz_git_prompt_info _omz_register_handler _omz_git_prompt_status else diff --git a/lib/tests/git_pending.test.zsh b/lib/tests/git_pending.test.zsh new file mode 100644 index 000000000..139e8cb6a --- /dev/null +++ b/lib/tests/git_pending.test.zsh @@ -0,0 +1,167 @@ +#!/usr/bin/zsh -df + +# Tests for the git pending branch name (stale/unbolded) feature + +local -i failures=0 +local ZSH=${0:A:h:h:h} + +run_test() { + local description="$1" + local actual="$2" + local expected="$3" + + print -u2 "Test: $description" + + if [[ "$actual" == "$expected" ]]; then + print -u2 "\e[32mSuccess\e[0m" + else + print -u2 "\e[31mError\e[0m: output does not match expected" + print -u2 " expected: ${(q+)expected}" + print -u2 " actual: ${(q+)actual}" + (( failures++ )) + fi + print -u2 "" +} + +# Reset theme variables to a known state between tests +reset_theme_vars() { + unset ZSH_THEME_GIT_PROMPT_PREFIX ZSH_THEME_GIT_PROMPT_SUFFIX + unset ZSH_THEME_GIT_PROMPT_DIRTY ZSH_THEME_GIT_PROMPT_CLEAN + unset ZSH_THEME_GIT_PROMPT_STALE_PREFIX ZSH_THEME_GIT_PROMPT_STALE_SUFFIX + unset ZSH_THEME_GIT_SHOW_UPSTREAM + unset _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + # Force async-prompt mode so we test the _omz_render_git_prompt_info path + zstyle ':omz:alpha:lib:git' async-prompt yes +} + +() { + local description="git_prompt_info - when pending=1 with bold prefix - then ref is wrapped with stale styling" + reset_theme_vars + autoload -Uz is-at-least + + ZSH_THEME_GIT_PROMPT_PREFIX="%B(" + ZSH_THEME_GIT_PROMPT_SUFFIX=")%b" + unset ZSH_THEME_GIT_PROMPT_STALE_PREFIX + unset ZSH_THEME_GIT_PROMPT_STALE_SUFFIX + + source "$ZSH/lib/git.zsh" + + typeset -gA _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + + # Async output format: ref + Unit Separator (U+001F) + dirty + _OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]="main"$'\x1f'" *" + _OMZ_ASYNC_PENDING[_omz_git_prompt_info]=1 + + local actual + actual=$(git_prompt_info) + + local stale_prefix=$'%{\e[22m%}' + local stale_suffix=$'%{\e[1m%}' + local expected="%B(${stale_prefix}main *${stale_suffix})%b" + + run_test "$description" "$actual" "$expected" +} + +() { + local description="git_prompt_info - when pending=1 with non-bold prefix - then no stale styling is applied" + reset_theme_vars + autoload -Uz is-at-least + + ZSH_THEME_GIT_PROMPT_PREFIX="(" + ZSH_THEME_GIT_PROMPT_SUFFIX=")" + unset ZSH_THEME_GIT_PROMPT_STALE_PREFIX + unset ZSH_THEME_GIT_PROMPT_STALE_SUFFIX + + source "$ZSH/lib/git.zsh" + + typeset -gA _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + + # Async output format: ref + Unit Separator (U+001F) + dirty + _OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]="main"$'\x1f'" *" + _OMZ_ASYNC_PENDING[_omz_git_prompt_info]=1 + + local actual + actual=$(git_prompt_info) + + run_test "$description" "$actual" "(main *)" +} + +() { + local description="git_prompt_info - when pending=0 - then formatted output is returned as-is" + reset_theme_vars + autoload -Uz is-at-least + + ZSH_THEME_GIT_PROMPT_PREFIX="%B(" + ZSH_THEME_GIT_PROMPT_SUFFIX=")%b" + unset ZSH_THEME_GIT_PROMPT_STALE_PREFIX + unset ZSH_THEME_GIT_PROMPT_STALE_SUFFIX + + source "$ZSH/lib/git.zsh" + + typeset -gA _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + + # Async output format: ref + Unit Separator (U+001F) + dirty + _OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]="main"$'\x1f'" *" + _OMZ_ASYNC_PENDING[_omz_git_prompt_info]=0 + + local actual + actual=$(git_prompt_info) + + run_test "$description" "$actual" "%B(main *)%b" +} + +() + local description="git_prompt_info - when pending=1 with custom stale vars - then ref uses custom stale prefix/suffix"{ + reset_theme_vars + autoload -Uz is-at-least + + ZSH_THEME_GIT_PROMPT_PREFIX="(" + ZSH_THEME_GIT_PROMPT_SUFFIX=")" + ZSH_THEME_GIT_PROMPT_STALE_PREFIX="%{dim%}" + ZSH_THEME_GIT_PROMPT_STALE_SUFFIX="%{/dim%}" + + source "$ZSH/lib/git.zsh" + + typeset -gA _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + + # Async output format: ref + Unit Separator (U+001F) + dirty (empty dirty here) + _OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]="develop"$'\x1f' + _OMZ_ASYNC_PENDING[_omz_git_prompt_info]=1 + + local actual + actual=$(git_prompt_info) + local expected="(%{dim%}develop%{/dim%})" + + run_test "$description" "$actual" "$expected" +} + +() { + local description="git_prompt_info - when old single-line format (no ref line) - then output is passed through as-is" + reset_theme_vars + autoload -Uz is-at-least + + ZSH_THEME_GIT_PROMPT_PREFIX="%B(" + ZSH_THEME_GIT_PROMPT_SUFFIX=")%b" + + source "$ZSH/lib/git.zsh" + + typeset -gA _OMZ_ASYNC_OUTPUT _OMZ_ASYNC_PENDING + + # Simulate old-format output with no newline (single line, no ref header) + _OMZ_ASYNC_OUTPUT[_omz_git_prompt_info]="%B(main *)%b" + _OMZ_ASYNC_PENDING[_omz_git_prompt_info]=0 + + local actual + actual=$(git_prompt_info) + + run_test "$description" "$actual" "%B(main *)%b" +} + +# Summary +if (( failures > 0 )); then + print -u2 "\e[31m${failures} test(s) failed\e[0m" + return 1 +else + print -u2 "\e[32mAll tests passed\e[0m" + return 0 +fi