diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7c28a1e05..24ce935a0 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,6 +9,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2176[#2176]: Support 7z archive extraction * https://github.com/devonfw/IDEasy/issues/2100[#2100]: Fix Python not available for Mac x64 * https://github.com/devonfw/IDEasy/issues/2134[#2134]: Add auto-completion for icd command +* https://github.com/devonfw/IDEasy/issues/2226[#2226]: Show IDEasy project, workspace and repository in the Git Bash window title The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/48?closed=1[milestone 2026.08.001]. diff --git a/cli/src/main/package/functions b/cli/src/main/package/functions index 163ae536d..5d9b5b79c 100644 --- a/cli/src/main/package/functions +++ b/cli/src/main/package/functions @@ -317,19 +317,32 @@ _icd_list_projects() compgen -W "${names[*]}" -- "${project_prefix}" } +# sets _ide_path_segment_result to the first path segment of the path $1 below the directory $2, or to the +# empty string if $1 is not below $2. Used to determine the current project, workspace and repository. +# Callers should declare _ide_path_segment_result as local. The result is passed via this variable instead of +# being echoed, so that no subshell is needed - _ide_title calls this for every prompt. +_ide_path_segment() +{ + local path="$1" base="$2" relative_path + _ide_path_segment_result= + if [[ "${path}" != "${base}"/* ]]; then + return + fi + relative_path="${path#${base}/}" + _ide_path_segment_result="${relative_path%%/*}" +} + # echoes the path of the project given as $1 or, if empty, the project inferred from PWD or IDE_HOME (same logic as icd()) _icd_project_dir() { - local project="$1" + local project="$1" _ide_path_segment_result if [ -n "${project}" ]; then echo "${IDE_ROOT}/${project}" return fi - local current_dir - current_dir="$(pwd)" - if [[ "${current_dir}" == "${IDE_ROOT}"/* ]]; then - local relative_path="${current_dir#${IDE_ROOT}/}" - echo "${IDE_ROOT}/${relative_path%%/*}" + _ide_path_segment "$(pwd)" "${IDE_ROOT}" + if [ -n "${_ide_path_segment_result}" ]; then + echo "${IDE_ROOT}/${_ide_path_segment_result}" elif [ -n "${IDE_HOME}" ]; then echo "${IDE_HOME}" fi @@ -394,6 +407,56 @@ _icd_completion() COMPREPLY=( $(compgen -W "-p --project -w --workspace -r --repository -h --help" -- "${cur}") ) } +# writes the terminal title for the current directory as "ideasy", "ideasy>«project»", +# "ideasy>«project»>«workspace»" or "ideasy>«project»>«workspace»>«repository»", so that terminal tabs and +# windows can be told apart. +# The workspace and repository are derived from PWD and not from the WORKSPACE variable, since the latter +# always defaults to "main" even outside of any workspace or IDEasy project. Like "icd -r", hidden folders are +# not considered to be repositories. For a nested repository such as "backend/core" the top-level folder is +# shown to keep the title short. +_ide_title() +{ + local title="ideasy" workspace repository _ide_path_segment_result + if [ -n "${IDE_HOME:-}" ]; then + title="${title}>${IDE_HOME##*/}" + _ide_path_segment "${PWD}" "${IDE_HOME}/workspaces" + workspace="${_ide_path_segment_result}" + if [ -n "${workspace}" ]; then + title="${title}>${workspace}" + _ide_path_segment "${PWD}" "${IDE_HOME}/workspaces/${workspace}" + repository="${_ide_path_segment_result}" + if [ -n "${repository}" ] && [[ "${repository}" != .* ]]; then + title="${title}>${repository}" + fi + fi + fi + printf '\033]0;%s\007' "${title}" +} + +# Git Bash rewrites the window title on every prompt through an escape sequence in PS1 (see +# /etc/profile.d/git-prompt.sh), so a title cannot be set permanently. Remove that sequence and write our own +# title instead. This is limited to Git Bash on purpose: on other systems the prompt is configured by the user +# or the distribution and must not be touched, and zsh has no PROMPT_COMMAND (it uses precmd). +if _ide_is_msys && [ -n "${BASH_VERSION:-}" ]; then + case "${PS1:-}" in + '\[\033]0;'*'\007\]'*) + PS1="${PS1#*'\007\]'}" + ;; + esac + # the joined form matches both a plain string and an array valued PROMPT_COMMAND (bash 5.1 and above) + case "${PROMPT_COMMAND[*]:-}" in + *_ide_title*) + ;; + *) + if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then + PROMPT_COMMAND=(_ide_title "${PROMPT_COMMAND[@]}") + else + PROMPT_COMMAND="_ide_title${PROMPT_COMMAND:+; ${PROMPT_COMMAND}}" + fi + ;; + esac +fi + complete -F _ide_completion ide complete -F _icd_completion icd ide diff --git a/cli/src/test/functions-test.sh b/cli/src/test/functions-test.sh index 2a671016b..2a868d8fe 100644 --- a/cli/src/test/functions-test.sh +++ b/cli/src/test/functions-test.sh @@ -78,7 +78,8 @@ echo echo "Testing icd -r repository navigation in ${FUNCTIONS_FILE}" ICD_ROOT="$(mktemp -d)" -trap 'rm -rf "${STUB_DIR}" "${ICD_ROOT}"' EXIT +TITLE_ROOT="$(mktemp -d)" +trap 'rm -rf "${STUB_DIR}" "${ICD_ROOT}" "${TITLE_ROOT}"' EXIT # "proj" has a repo directly in main ("cli") and a nested repo in "dev" ("backend/core", with "backend" itself # also being a valid top-level repo folder in "dev"). @@ -176,6 +177,162 @@ for icd_shell in "${ICD_SHELLS[@]}"; do -p proj-empty -r done +echo +echo "Testing terminal title in ${FUNCTIONS_FILE}" + +mkdir -p "${TITLE_ROOT}/devonfw/workspaces/main/IDEasy/cli" +mkdir -p "${TITLE_ROOT}/devonfw/workspaces/main/.vscode" +mkdir -p "${TITLE_ROOT}/devonfw/workspaces/feature-x/foo" +mkdir -p "${TITLE_ROOT}/devonfw/workspaces/dev/backend/core" +mkdir -p "${TITLE_ROOT}/devonfw/conf" +mkdir -p "${TITLE_ROOT}/outside" + +# the window title sequence Git Bash bakes into PS1, followed by its regular colored prompt +GIT_BASH_PS1='\[\033]0;$TITLEPREFIX:$PWD\007\]\n\[\033[32m\]\u@\h \[\033[33m\]\w\[\033[0m\]\n$ ' +# the same prompt with the title sequence removed +GIT_BASH_PS1_STRIPPED='\n\[\033[32m\]\u@\h \[\033[33m\]\w\[\033[0m\]\n$ ' + +# runTitle : sources the functions file in a fresh +# shell simulating the given environment and prints the title text emitted by _ide_title without its escape +# sequence, or "" if the functions file did not define _ide_title. +runTitle() { + FAKE_UNAME_S="$1" FAKE_IDE_HOME="$2" FAKE_PWD="$3" GIT_BASH_PS1="${GIT_BASH_PS1}" \ + FUNCTIONS_FILE="${FUNCTIONS_FILE}" STUB_DIR="${STUB_DIR}" TITLE_ROOT="${TITLE_ROOT}" \ + bash --noprofile --norc -c ' + export PATH="${STUB_DIR}:${PATH}" + export IDE_ROOT="${TITLE_ROOT}" + uname() { if [ "$1" = "-s" ]; then echo "${FAKE_UNAME_S}"; else command uname "$@"; fi; } + cygpath() { echo "${@: -1}"; } + PS1="${GIT_BASH_PS1}" + source "${FUNCTIONS_FILE}" >/dev/null 2>&1 + declare -F _ide_title >/dev/null || { echo ""; exit 0; } + # "ide" resets IDE_HOME while sourcing, so the simulated project is set afterwards + IDE_HOME="${FAKE_IDE_HOME}" + cd "${FAKE_PWD}" || exit 99 + title="$(_ide_title | tr -d $'"'"'\033\007'"'"')" + echo "${title#]0;}" + ' +} + +# checkTitle +checkTitle() { + local description="$1" expected="$2" uname_s="$3" ide_home="$4" dir="$5" + total=$((total + 1)) + local actual + actual="$(runTitle "${uname_s}" "${ide_home}" "${dir}")" + if [ "${actual}" = "${expected}" ]; then + doSuccess "PASSED: ${description} (title '${actual}')" + else + doError "FAILED: ${description} - expected title '${expected}' but was '${actual}'" + failed=$((failed + 1)) + fi +} + +checkTitle "outside any IDEasy project only shows the tool name" \ + "ideasy" "MINGW64_NT-10.0-26200" "" "${TITLE_ROOT}/outside" +checkTitle "project root shows project without workspace" \ + "ideasy>devonfw" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw" +checkTitle "directory inside the project but outside any workspace shows no workspace" \ + "ideasy>devonfw" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/conf" +checkTitle "the workspaces directory itself shows no workspace" \ + "ideasy>devonfw" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces" +checkTitle "the workspace root shows no repository" \ + "ideasy>devonfw>main" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/main" +checkTitle "the repository root shows workspace and repository" \ + "ideasy>devonfw>main>IDEasy" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/main/IDEasy" +checkTitle "directory deep inside a repository still shows that repository" \ + "ideasy>devonfw>main>IDEasy" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/main/IDEasy/cli" +checkTitle "a non-default workspace is reflected in the title" \ + "ideasy>devonfw>feature-x>foo" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/feature-x/foo" +checkTitle "a nested repository shows its top-level folder" \ + "ideasy>devonfw>dev>backend" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/dev/backend/core" +checkTitle "a hidden folder in the workspace is not a repository" \ + "ideasy>devonfw>main" "MINGW64_NT-10.0-26200" "${TITLE_ROOT}/devonfw" "${TITLE_ROOT}/devonfw/workspaces/main/.vscode" + +# runPrompt : sources the functions +# file in a fresh shell simulating the given environment and prints the resulting PS1, the joined +# PROMPT_COMMAND and how often the title hook occurs in it. +runPrompt() { + FAKE_UNAME_S="$1" PRE_SOURCE="$2" SOURCE_TIMES="$3" GIT_BASH_PS1="${GIT_BASH_PS1}" \ + FUNCTIONS_FILE="${FUNCTIONS_FILE}" STUB_DIR="${STUB_DIR}" TITLE_ROOT="${TITLE_ROOT}" \ + bash --noprofile --norc -c ' + export PATH="${STUB_DIR}:${PATH}" + export IDE_ROOT="${TITLE_ROOT}" + uname() { if [ "$1" = "-s" ]; then echo "${FAKE_UNAME_S}"; else command uname "$@"; fi; } + cygpath() { echo "${@: -1}"; } + PS1="${GIT_BASH_PS1}" + eval "${PRE_SOURCE}" + for (( i=0; i/dev/null 2>&1 + done + printf "PS1=%s\n" "${PS1}" + printf "PROMPT_COMMAND=%s\n" "${PROMPT_COMMAND[*]:-}" + printf "HOOKS=%s\n" "$(printf "%s" "${PROMPT_COMMAND[*]:-}" | grep -c "_ide_title")" + ' +} + +# checkPrompt +checkPrompt() { + local description="$1" uname_s="$2" pre_source="$3" source_times="$4" + local expected_ps1="$5" expected_pc="$6" expected_hooks="$7" + total=$((total + 1)) + local result actual_ps1 actual_pc actual_hooks ok=true + result="$(runPrompt "${uname_s}" "${pre_source}" "${source_times}")" + actual_ps1="$(printf '%s\n' "${result}" | sed -n 's/^PS1=//p')" + actual_pc="$(printf '%s\n' "${result}" | sed -n 's/^PROMPT_COMMAND=//p')" + actual_hooks="$(printf '%s\n' "${result}" | sed -n 's/^HOOKS=//p')" + [ "${actual_ps1}" = "${expected_ps1}" ] || ok=false + [ "${actual_pc}" = "${expected_pc}" ] || ok=false + [ "${actual_hooks}" = "${expected_hooks}" ] || ok=false + if [ "${ok}" = true ]; then + doSuccess "PASSED: ${description}" + else + doError "FAILED: ${description} - PS1='${actual_ps1}' (expected '${expected_ps1}'), PROMPT_COMMAND='${actual_pc}' (expected '${expected_pc}'), hooks=${actual_hooks} (expected ${expected_hooks})" + failed=$((failed + 1)) + fi +} + +checkPrompt "Git Bash: title sequence is removed from PS1 and the hook is installed" \ + "MINGW64_NT-10.0-26200" "" 1 "${GIT_BASH_PS1_STRIPPED}" "_ide_title" 1 +checkPrompt "Git Bash: sourcing twice does not duplicate the hook nor strip PS1 twice" \ + "MINGW64_NT-10.0-26200" "" 2 "${GIT_BASH_PS1_STRIPPED}" "_ide_title" 1 +checkPrompt "Git Bash: an existing PROMPT_COMMAND string is preserved" \ + "MINGW64_NT-10.0-26200" 'PROMPT_COMMAND="my_hook"' 1 "${GIT_BASH_PS1_STRIPPED}" "_ide_title; my_hook" 1 +checkPrompt "Git Bash: an array-valued PROMPT_COMMAND is preserved as array" \ + "MINGW64_NT-10.0-26200" 'PROMPT_COMMAND=(my_hook other_hook)' 1 "${GIT_BASH_PS1_STRIPPED}" "_ide_title my_hook other_hook" 1 +checkPrompt "Git Bash: a custom prompt without the title sequence is left untouched" \ + "MINGW64_NT-10.0-26200" 'PS1="custom> "' 1 "custom> " "_ide_title" 1 + +# the title must never interfere with prompts on other operating systems +checkPrompt "macOS leaves PS1 and PROMPT_COMMAND untouched" \ + "Darwin" "" 1 "${GIT_BASH_PS1}" "" 0 +checkPrompt "Linux leaves PS1 and PROMPT_COMMAND untouched" \ + "Linux" "" 1 "${GIT_BASH_PS1}" "" 0 +checkPrompt "Cygwin leaves PS1 and PROMPT_COMMAND untouched" \ + "CYGWIN_NT-10.0-26200" "" 1 "${GIT_BASH_PS1}" "" 0 + +# zsh has no PROMPT_COMMAND (it uses precmd), so the hook must not be installed there +if command -v zsh >/dev/null 2>&1; then + total=$((total + 1)) + zsh_result="$(FAKE_UNAME_S="MINGW64_NT-10.0-26200" FUNCTIONS_FILE="${FUNCTIONS_FILE}" \ + STUB_DIR="${STUB_DIR}" TITLE_ROOT="${TITLE_ROOT}" zsh -c ' + export PATH="${STUB_DIR}:${PATH}" + export IDE_ROOT="${TITLE_ROOT}" + uname() { if [ "$1" = "-s" ]; then echo "${FAKE_UNAME_S}"; else command uname "$@"; fi; } + cygpath() { echo "${@: -1}"; } + source "${FUNCTIONS_FILE}" >/dev/null 2>&1 + echo "PROMPT_COMMAND=${PROMPT_COMMAND:-}" + ' 2>/dev/null)" + if [ "${zsh_result}" = "PROMPT_COMMAND=" ]; then + doSuccess "PASSED: zsh on MSYS does not install the PROMPT_COMMAND hook" + else + doError "FAILED: zsh on MSYS must not install the PROMPT_COMMAND hook - got '${zsh_result}'" + failed=$((failed + 1)) + fi +else + echo "zsh not found on PATH - skipping the zsh check for the terminal title" +fi + echo if [ "${failed}" = 0 ]; then doSuccess "All ${total} tests passed." diff --git a/documentation/advanced-tooling-windows.adoc b/documentation/advanced-tooling-windows.adoc index ffce8e9d2..5879c23ad 100644 --- a/documentation/advanced-tooling-windows.adoc +++ b/documentation/advanced-tooling-windows.adoc @@ -56,6 +56,20 @@ image::images/git-installer-windows-terminal-profile.png["Integrate git-bash int You can even open SSH sessions and WSL distributions from the context menu of `Windows Terminal` in a tab. +==== Window Title + +By default git-bash overwrites the window title with `MINGW64:«current-directory»` on every prompt, so all your tabs and windows look alike in the taskbar and in `Alt`+`Tab`. +IDEasy therefore replaces this with a title that tells you which project, workspace and repository the shell is initialized for: + +* `ideasy>«project»>«workspace»>«repository»` when you are inside a repository (e.g. `ideasy>devonfw>main>IDEasy`) +* `ideasy>«project»>«workspace»` when you are inside a workspace but outside any repository +* `ideasy>«project»` when you are inside an IDEasy project but outside any workspace +* `ideasy` when you are outside of any IDEasy project + +Workspace and repository are derived from your current directory, so the title also follows a plain `cd` and not only `ide` or `icd`. +As with `icd -r`, hidden folders are not considered to be repositories, and for a nested repository such as `backend/core` the top-level folder is shown to keep the title short. +This only applies to git-bash on Windows - on macOS and Linux your prompt configuration is left untouched. + == Windows Helpers === Handle passwords