Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down
75 changes: 69 additions & 6 deletions cli/src/main/package/functions
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
159 changes: 158 additions & 1 deletion cli/src/test/functions-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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 <uname -s value> <IDE_HOME value> <directory to cd into> : 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 "<none>" 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 "<none>"; 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 <description> <expected title> <uname -s value> <IDE_HOME value> <directory to cd into>
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 <uname -s value> <code evaluated before sourcing> <how often to source> : 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<SOURCE_TIMES; i++ )); do
source "${FUNCTIONS_FILE}" >/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 <description> <uname -s value> <pre-source code> <source times> <expected PS1> <expected PROMPT_COMMAND> <expected hook count>
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."
Expand Down
14 changes: 14 additions & 0 deletions documentation/advanced-tooling-windows.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading