Terminal: Add shell integration

Change-Id: Ic1e226b56f0103e5a6e7764073ab7ab241b67baa
Reviewed-by: Cristian Adam <cristian.adam@qt.io>
This commit is contained in:
Marcus Tillmanns
2023-03-10 13:55:17 +01:00
parent 3507229a00
commit bd52e53dbf
22 changed files with 1129 additions and 15 deletions

View File

@@ -783,7 +783,6 @@ SQLite (https://www.sqlite.org) is in the Public Domain.
### libvterm
An abstract C99 library which implements a VT220 or xterm-like terminal emulator.
It doesn't use any particular graphics toolkit or output system, instead it invokes callback
function pointers that its embedding program should provide it to draw on its behalf.
@@ -813,3 +812,32 @@ SQLite (https://www.sqlite.org) is in the Public Domain.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
### terminal/shellintegrations
The Terminal plugin uses scripts to integrate with the shell. The scripts are
located in the Qt Creator source tree in src/plugins/terminal/shellintegrations.
https://github.com/microsoft/vscode/tree/main/src/vs/workbench/contrib/terminal/browser/media
MIT License
Copyright (c) 2015 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1046,5 +1046,18 @@
\include license-mit.qdocinc
\li \b terminal/shellintegrations
The Terminal plugin uses scripts to integrate with the shell. The scripts are
located in the Qt Creator source tree in src/plugins/terminal/shellintegrations.
\list
\li \l https://github.com/microsoft/vscode/tree/main/src/vs/workbench/contrib/terminal/browser/media
\endlist
Distributed under the MIT license.
\include license-mit.qdocinc
\endlist
*/

View File

@@ -5,7 +5,6 @@
#include "savefile.h"
#include "algorithm.h"
#include "hostosinfo.h"
#include "qtcassert.h"
#include "utilstr.h"

View File

@@ -121,7 +121,6 @@ public:
QString *selectedFilter = nullptr,
QFileDialog::Options options = {});
#endif
};
// for actually finding out if e.g. directories are writable on Windows

View File

@@ -355,7 +355,9 @@ public:
bool startResult
= m_ptyProcess->startProcess(program,
arguments,
HostOsInfo::isWindowsHost()
? QStringList{m_setup.m_nativeArguments} << arguments
: arguments,
m_setup.m_workingDirectory.path(),
m_setup.m_environment.toProcessEnvironment().toStringList(),
m_setup.m_ptyData->size().width(),

View File

@@ -7,6 +7,7 @@ add_qtc_plugin(Terminal
glyphcache.cpp glyphcache.h
keys.cpp keys.h
scrollback.cpp scrollback.h
shellintegration.cpp shellintegration.h
shellmodel.cpp shellmodel.h
terminal.qrc
terminalpane.cpp terminalpane.h

View File

@@ -0,0 +1,143 @@
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0
#include "shellintegration.h"
#include <utils/environment.h>
#include <utils/filepath.h>
#include <utils/stringutils.h>
#include <QLoggingCategory>
Q_LOGGING_CATEGORY(integrationLog, "qtc.terminal.shellintegration", QtWarningMsg)
using namespace Utils;
namespace Terminal {
struct FileToCopy
{
FilePath source;
QString destName;
};
// clang-format off
struct
{
struct
{
FilePath rcFile{":/terminal/shellintegrations/shellintegration-bash.sh"};
} bash;
struct
{
QList<FileToCopy> files{
{":/terminal/shellintegrations/shellintegration-env.zsh", ".zshenv"},
{":/terminal/shellintegrations/shellintegration-login.zsh", ".zlogin"},
{":/terminal/shellintegrations/shellintegration-profile.zsh", ".zprofile"},
{":/terminal/shellintegrations/shellintegration-rc.zsh", ".zshrc"}
};
} zsh;
struct
{
FilePath script{":/terminal/shellintegrations/shellintegration.ps1"};
} pwsh;
} filesToCopy;
// clang-format on
bool ShellIntegration::canIntegrate(const Utils::CommandLine &cmdLine)
{
if (cmdLine.executable().needsDevice())
return false; // TODO: Allow integration for remote shells
if (!cmdLine.arguments().isEmpty())
return false;
if (cmdLine.executable().baseName() == "bash")
return true;
if (cmdLine.executable().baseName() == "zsh")
return true;
if (cmdLine.executable().baseName() == "pwsh"
|| cmdLine.executable().baseName() == "powershell") {
return true;
}
return false;
}
void ShellIntegration::onOsc(int cmd, const VTermStringFragment &fragment)
{
QString d = QString::fromLocal8Bit(fragment.str, fragment.len);
const auto [command, data] = Utils::splitAtFirst(d, ';');
if (cmd == 1337) {
const auto [key, value] = Utils::splitAtFirst(command, '=');
if (key == QStringView(u"CurrentDir"))
emit currentDirChanged(FilePath::fromUserInput(value.toString()).path());
} else if (cmd == 7) {
emit currentDirChanged(FilePath::fromUserInput(d).path());
} else if (cmd == 133) {
qCDebug(integrationLog) << "OSC 133:" << data;
} else if (cmd == 633 && command.length() == 1) {
if (command[0] == 'E') {
CommandLine cmdLine = CommandLine::fromUserInput(data.toString());
emit commandChanged(cmdLine);
} else if (command[0] == 'D') {
emit commandChanged({});
} else if (command[0] == 'P') {
const auto [key, value] = Utils::splitAtFirst(data, '=');
if (key == QStringView(u"Cwd"))
emit currentDirChanged(value.toString());
}
}
}
void ShellIntegration::prepareProcess(Utils::QtcProcess &process)
{
Environment env = process.environment().hasChanges() ? process.environment()
: Environment::systemEnvironment();
CommandLine cmd = process.commandLine();
if (!canIntegrate(cmd))
return;
env.set("VSCODE_INJECTION", "1");
if (cmd.executable().baseName() == "bash") {
const FilePath rcPath = filesToCopy.bash.rcFile;
const FilePath tmpRc = FilePath::fromUserInput(
m_tempDir.filePath(filesToCopy.bash.rcFile.fileName()));
rcPath.copyFile(tmpRc);
cmd.addArgs({"--init-file", tmpRc.nativePath()});
} else if (cmd.executable().baseName() == "zsh") {
for (const FileToCopy &file : filesToCopy.zsh.files) {
const auto copyResult = file.source.copyFile(
FilePath::fromUserInput(m_tempDir.filePath(file.destName)));
QTC_ASSERT_EXPECTED(copyResult, return);
}
const Utils::FilePath originalZdotDir = FilePath::fromUserInput(
env.value_or("ZDOTDIR", QDir::homePath()));
env.set("ZDOTDIR", m_tempDir.path());
env.set("USER_ZDOTDIR", originalZdotDir.nativePath());
} else if (cmd.executable().baseName() == "pwsh"
|| cmd.executable().baseName() == "powershell") {
const FilePath rcPath = filesToCopy.pwsh.script;
const FilePath tmpRc = FilePath::fromUserInput(
m_tempDir.filePath(filesToCopy.pwsh.script.fileName()));
rcPath.copyFile(tmpRc);
cmd.addArgs(QString("-noexit -command try { . \"%1\" } catch {}{1}").arg(tmpRc.nativePath()),
CommandLine::Raw);
}
process.setCommand(cmd);
process.setEnvironment(env);
}
} // namespace Terminal

View File

@@ -0,0 +1,34 @@
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH
// Qt-GPL-exception-1.0
#pragma once
#include <utils/commandline.h>
#include <utils/qtcprocess.h>
#include <vterm.h>
#include <QTemporaryDir>
namespace Terminal {
class ShellIntegration : public QObject
{
Q_OBJECT
public:
static bool canIntegrate(const Utils::CommandLine &cmdLine);
void onOsc(int cmd, const VTermStringFragment &fragment);
void prepareProcess(Utils::QtcProcess &process);
signals:
void commandChanged(const Utils::CommandLine &command);
void currentDirChanged(const QString &dir);
private:
QTemporaryDir m_tempDir;
};
} // namespace Terminal

View File

@@ -0,0 +1,252 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
# Prevent the script recursing when setting up
if [[ -n "$VSCODE_SHELL_INTEGRATION" ]]; then
builtin return
fi
VSCODE_SHELL_INTEGRATION=1
# Run relevant rc/profile only if shell integration has been injected, not when run manually
if [ "$VSCODE_INJECTION" == "1" ]; then
if [ -z "$VSCODE_SHELL_LOGIN" ]; then
if [ -r ~/.bashrc ]; then
. ~/.bashrc
fi
else
# Imitate -l because --init-file doesn't support it:
# run the first of these files that exists
if [ -r /etc/profile ]; then
. /etc/profile
fi
# exceute the first that exists
if [ -r ~/.bash_profile ]; then
. ~/.bash_profile
elif [ -r ~/.bash_login ]; then
. ~/.bash_login
elif [ -r ~/.profile ]; then
. ~/.profile
fi
builtin unset VSCODE_SHELL_LOGIN
# Apply any explicit path prefix (see #99878)
if [ -n "$VSCODE_PATH_PREFIX" ]; then
export PATH=$VSCODE_PATH_PREFIX$PATH
builtin unset VSCODE_PATH_PREFIX
fi
fi
builtin unset VSCODE_INJECTION
fi
if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
builtin return
fi
__vsc_get_trap() {
# 'trap -p DEBUG' outputs a shell command like `trap -- '…shellcode…' DEBUG`.
# The terms are quoted literals, but are not guaranteed to be on a single line.
# (Consider a trap like $'echo foo\necho \'bar\'').
# To parse, we splice those terms into an expression capturing them into an array.
# This preserves the quoting of those terms: when we `eval` that expression, they are preserved exactly.
# This is different than simply exploding the string, which would split everything on IFS, oblivious to quoting.
builtin local -a terms
builtin eval "terms=( $(trap -p "${1:-DEBUG}") )"
# |________________________|
# |
# \-------------------*--------------------/
# terms=( trap -- '…arbitrary shellcode…' DEBUG )
# |____||__| |_____________________| |_____|
# | | | |
# 0 1 2 3
# |
# \--------*----/
builtin printf '%s' "${terms[2]:-}"
}
# The property (P) and command (E) codes embed values which require escaping.
# Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex.
__vsc_escape_value() {
# Process text byte by byte, not by codepoint.
builtin local LC_ALL=C str="${1}" i byte token out=''
for (( i=0; i < "${#str}"; ++i )); do
byte="${str:$i:1}"
# Escape backslashes and semi-colons
if [ "$byte" = "\\" ]; then
token="\\\\"
elif [ "$byte" = ";" ]; then
token="\\x3b"
else
token="$byte"
fi
out+="$token"
done
builtin printf '%s\n' "${out}"
}
# Send the IsWindows property if the environment looks like Windows
if [[ "$(uname -s)" =~ ^CYGWIN*|MINGW*|MSYS* ]]; then
builtin printf '\e]633;P;IsWindows=True\a'
fi
# Allow verifying $BASH_COMMAND doesn't have aliases resolved via history when the right HISTCONTROL
# configuration is used
if [[ "$HISTCONTROL" =~ .*(erasedups|ignoreboth|ignoredups).* ]]; then
__vsc_history_verify=0
else
__vsc_history_verify=1
fi
__vsc_initialized=0
__vsc_original_PS1="$PS1"
__vsc_original_PS2="$PS2"
__vsc_custom_PS1=""
__vsc_custom_PS2=""
__vsc_in_command_execution="1"
__vsc_current_command=""
__vsc_prompt_start() {
builtin printf '\e]633;A\a'
}
__vsc_prompt_end() {
builtin printf '\e]633;B\a'
}
__vsc_update_cwd() {
builtin printf '\e]633;P;Cwd=%s\a' "$(__vsc_escape_value "$PWD")"
}
__vsc_command_output_start() {
builtin printf '\e]633;C\a'
builtin printf '\e]633;E;%s\a' "$(__vsc_escape_value "${__vsc_current_command}")"
}
__vsc_continuation_start() {
builtin printf '\e]633;F\a'
}
__vsc_continuation_end() {
builtin printf '\e]633;G\a'
}
__vsc_command_complete() {
if [ "$__vsc_current_command" = "" ]; then
builtin printf '\e]633;D\a'
else
builtin printf '\e]633;D;%s\a' "$__vsc_status"
fi
__vsc_update_cwd
}
__vsc_update_prompt() {
# in command execution
if [ "$__vsc_in_command_execution" = "1" ]; then
# Wrap the prompt if it is not yet wrapped, if the PS1 changed this this was last set it
# means the user re-exported the PS1 so we should re-wrap it
if [[ "$__vsc_custom_PS1" == "" || "$__vsc_custom_PS1" != "$PS1" ]]; then
__vsc_original_PS1=$PS1
__vsc_custom_PS1="\[$(__vsc_prompt_start)\]$__vsc_original_PS1\[$(__vsc_prompt_end)\]"
PS1="$__vsc_custom_PS1"
fi
if [[ "$__vsc_custom_PS2" == "" || "$__vsc_custom_PS2" != "$PS2" ]]; then
__vsc_original_PS2=$PS2
__vsc_custom_PS2="\[$(__vsc_continuation_start)\]$__vsc_original_PS2\[$(__vsc_continuation_end)\]"
PS2="$__vsc_custom_PS2"
fi
__vsc_in_command_execution="0"
fi
}
__vsc_precmd() {
__vsc_command_complete "$__vsc_status"
__vsc_current_command=""
__vsc_update_prompt
}
__vsc_preexec() {
__vsc_initialized=1
if [[ ! "$BASH_COMMAND" =~ ^__vsc_prompt* ]]; then
# Use history if it's available to verify the command as BASH_COMMAND comes in with aliases
# resolved
if [ "$__vsc_history_verify" = "1" ]; then
__vsc_current_command="$(builtin history 1 | sed 's/ *[0-9]* *//')"
else
__vsc_current_command=$BASH_COMMAND
fi
else
__vsc_current_command=""
fi
__vsc_command_output_start
}
# Debug trapping/preexec inspired by starship (ISC)
if [[ -n "${bash_preexec_imported:-}" ]]; then
__vsc_preexec_only() {
if [ "$__vsc_in_command_execution" = "0" ]; then
__vsc_in_command_execution="1"
__vsc_preexec
fi
}
precmd_functions+=(__vsc_prompt_cmd)
preexec_functions+=(__vsc_preexec_only)
else
__vsc_dbg_trap="$(__vsc_get_trap DEBUG)"
if [[ -z "$__vsc_dbg_trap" ]]; then
__vsc_preexec_only() {
if [ "$__vsc_in_command_execution" = "0" ]; then
__vsc_in_command_execution="1"
__vsc_preexec
fi
}
trap '__vsc_preexec_only "$_"' DEBUG
elif [[ "$__vsc_dbg_trap" != '__vsc_preexec "$_"' && "$__vsc_dbg_trap" != '__vsc_preexec_all "$_"' ]]; then
__vsc_preexec_all() {
if [ "$__vsc_in_command_execution" = "0" ]; then
__vsc_in_command_execution="1"
builtin eval "${__vsc_dbg_trap}"
__vsc_preexec
fi
}
trap '__vsc_preexec_all "$_"' DEBUG
fi
fi
__vsc_update_prompt
__vsc_restore_exit_code() {
return "$1"
}
__vsc_prompt_cmd_original() {
__vsc_status="$?"
__vsc_restore_exit_code "${__vsc_status}"
# Evaluate the original PROMPT_COMMAND similarly to how bash would normally
# See https://unix.stackexchange.com/a/672843 for technique
for cmd in "${__vsc_original_prompt_command[@]}"; do
eval "${cmd:-}"
done
__vsc_precmd
}
__vsc_prompt_cmd() {
__vsc_status="$?"
__vsc_precmd
}
# PROMPT_COMMAND arrays and strings seem to be handled the same (handling only the first entry of
# the array?)
__vsc_original_prompt_command=$PROMPT_COMMAND
if [[ -z "${bash_preexec_imported:-}" ]]; then
if [[ -n "$__vsc_original_prompt_command" && "$__vsc_original_prompt_command" != "__vsc_prompt_cmd" ]]; then
PROMPT_COMMAND=__vsc_prompt_cmd_original
else
PROMPT_COMMAND=__vsc_prompt_cmd
fi
fi

View File

@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
if [[ -f $USER_ZDOTDIR/.zshenv ]]; then
VSCODE_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$USER_ZDOTDIR
# prevent recursion
if [[ $USER_ZDOTDIR != $VSCODE_ZDOTDIR ]]; then
. $USER_ZDOTDIR/.zshenv
fi
USER_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$VSCODE_ZDOTDIR
fi

View File

@@ -0,0 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
ZDOTDIR=$USER_ZDOTDIR
if [[ $options[norcs] = off && -o "login" && -f $ZDOTDIR/.zlogin ]]; then
. $ZDOTDIR/.zlogin
fi

View File

@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
if [[ $options[norcs] = off && -o "login" && -f $USER_ZDOTDIR/.zprofile ]]; then
VSCODE_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$USER_ZDOTDIR
. $USER_ZDOTDIR/.zprofile
ZDOTDIR=$VSCODE_ZDOTDIR
# Apply any explicit path prefix (see #99878)
if (( ${+VSCODE_PATH_PREFIX} )); then
export PATH=$VSCODE_PATH_PREFIX$PATH
fi
builtin unset VSCODE_PATH_PREFIX
fi

View File

@@ -0,0 +1,160 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
builtin autoload -Uz add-zsh-hook
# Prevent the script recursing when setting up
if [ -n "$VSCODE_SHELL_INTEGRATION" ]; then
ZDOTDIR=$USER_ZDOTDIR
builtin return
fi
# This variable allows the shell to both detect that VS Code's shell integration is enabled as well
# as disable it by unsetting the variable.
VSCODE_SHELL_INTEGRATION=1
# By default, zsh will set the $HISTFILE to the $ZDOTDIR location automatically. In the case of the
# shell integration being injected, this means that the terminal will use a different history file
# to other terminals. To fix this issue, set $HISTFILE back to the default location before ~/.zshrc
# is called as that may depend upon the value.
if [[ "$VSCODE_INJECTION" == "1" ]]; then
HISTFILE=$USER_ZDOTDIR/.zsh_history
fi
# Only fix up ZDOTDIR if shell integration was injected (not manually installed) and has not been called yet
if [[ "$VSCODE_INJECTION" == "1" ]]; then
if [[ $options[norcs] = off && -f $USER_ZDOTDIR/.zshrc ]]; then
VSCODE_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$USER_ZDOTDIR
# A user's custom HISTFILE location might be set when their .zshrc file is sourced below
. $USER_ZDOTDIR/.zshrc
fi
fi
# Shell integration was disabled by the shell, exit without warning assuming either the shell has
# explicitly disabled shell integration as it's incompatible or it implements the protocol.
if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
builtin return
fi
# The property (P) and command (E) codes embed values which require escaping.
# Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex.
__vsc_escape_value() {
builtin emulate -L zsh
# Process text byte by byte, not by codepoint.
builtin local LC_ALL=C str="$1" i byte token out=''
for (( i = 0; i < ${#str}; ++i )); do
byte="${str:$i:1}"
# Escape backslashes and semi-colons
if [ "$byte" = "\\" ]; then
token="\\\\"
elif [ "$byte" = ";" ]; then
token="\\x3b"
else
token="$byte"
fi
out+="$token"
done
builtin print -r "$out"
}
__vsc_in_command_execution="1"
__vsc_current_command=""
__vsc_prompt_start() {
builtin printf '\e]633;A\a'
}
__vsc_prompt_end() {
builtin printf '\e]633;B\a'
}
__vsc_update_cwd() {
builtin printf '\e]633;P;Cwd=%s\a' "$(__vsc_escape_value "${PWD}")"
}
__vsc_command_output_start() {
builtin printf '\e]633;C\a'
builtin printf '\e]633;E;%s\a' "${__vsc_current_command}"
}
__vsc_continuation_start() {
builtin printf '\e]633;F\a'
}
__vsc_continuation_end() {
builtin printf '\e]633;G\a'
}
__vsc_right_prompt_start() {
builtin printf '\e]633;H\a'
}
__vsc_right_prompt_end() {
builtin printf '\e]633;I\a'
}
__vsc_command_complete() {
if [[ "$__vsc_current_command" == "" ]]; then
builtin printf '\e]633;D\a'
else
builtin printf '\e]633;D;%s\a' "$__vsc_status"
fi
__vsc_update_cwd
}
if [[ -o NOUNSET ]]; then
if [ -z "${RPROMPT-}" ]; then
RPROMPT=""
fi
fi
__vsc_update_prompt() {
__vsc_prior_prompt="$PS1"
__vsc_prior_prompt2="$PS2"
__vsc_in_command_execution=""
PS1="%{$(__vsc_prompt_start)%}$PS1%{$(__vsc_prompt_end)%}"
PS2="%{$(__vsc_continuation_start)%}$PS2%{$(__vsc_continuation_end)%}"
if [ -n "$RPROMPT" ]; then
__vsc_prior_rprompt="$RPROMPT"
RPROMPT="%{$(__vsc_right_prompt_start)%}$RPROMPT%{$(__vsc_right_prompt_end)%}"
fi
}
__vsc_precmd() {
local __vsc_status="$?"
if [ -z "${__vsc_in_command_execution-}" ]; then
# not in command execution
__vsc_command_output_start
fi
__vsc_command_complete "$__vsc_status"
__vsc_current_command=""
# in command execution
if [ -n "$__vsc_in_command_execution" ]; then
# non null
__vsc_update_prompt
fi
}
__vsc_preexec() {
PS1="$__vsc_prior_prompt"
PS2="$__vsc_prior_prompt2"
if [ -n "$RPROMPT" ]; then
RPROMPT="$__vsc_prior_rprompt"
fi
__vsc_in_command_execution="1"
__vsc_current_command=$2
__vsc_command_output_start
}
add-zsh-hook precmd __vsc_precmd
add-zsh-hook preexec __vsc_preexec
if [[ $options[login] = off && $USER_ZDOTDIR != $VSCODE_ZDOTDIR ]]; then
ZDOTDIR=$USER_ZDOTDIR
fi

View File

@@ -0,0 +1,122 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
#
# Visual Studio Code terminal integration for fish
#
# Manual installation:
#
# (1) Add the following to the end of `$__fish_config_dir/config.fish`:
#
# string match -q "$TERM_PROGRAM" "vscode"
# and . (code --locate-shell-integration-path fish)
#
# (2) Restart fish.
# Don't run in scripts, other terminals, or more than once per session.
status is-interactive
and string match --quiet "$TERM_PROGRAM" "vscode"
and ! set --query VSCODE_SHELL_INTEGRATION
or exit
set --global VSCODE_SHELL_INTEGRATION 1
# Apply any explicit path prefix (see #99878)
if status --is-login; and set -q VSCODE_PATH_PREFIX
fish_add_path -p $VSCODE_PATH_PREFIX
end
set -e VSCODE_PATH_PREFIX
# Helper function
function __vsc_esc -d "Emit escape sequences for VS Code shell integration"
builtin printf "\e]633;%s\a" (string join ";" $argv)
end
# Sent right before executing an interactive command.
# Marks the beginning of command output.
function __vsc_cmd_executed --on-event fish_preexec
__vsc_esc C
__vsc_esc E (__vsc_escape_value "$argv")
# Creates a marker to indicate a command was run.
set --global _vsc_has_cmd
end
# Escape a value for use in the 'P' ("Property") or 'E' ("Command Line") sequences.
# Backslashes are doubled and non-alphanumeric characters are hex encoded.
function __vsc_escape_value
# Escape backslashes and semi-colons
echo $argv \
| string replace --all '\\' '\\\\' \
| string replace --all ';' '\\x3b' \
;
end
# Sent right after an interactive command has finished executing.
# Marks the end of command output.
function __vsc_cmd_finished --on-event fish_postexec
__vsc_esc D $status
end
# Sent when a command line is cleared or reset, but no command was run.
# Marks the cleared line with neither success nor failure.
function __vsc_cmd_clear --on-event fish_cancel
__vsc_esc D
end
# Sent whenever a new fish prompt is about to be displayed.
# Updates the current working directory.
function __vsc_update_cwd --on-event fish_prompt
__vsc_esc P Cwd=(__vsc_escape_value "$PWD")
# If a command marker exists, remove it.
# Otherwise, the commandline is empty and no command was run.
if set --query _vsc_has_cmd
set --erase _vsc_has_cmd
else
__vsc_cmd_clear
end
end
# Sent at the start of the prompt.
# Marks the beginning of the prompt (and, implicitly, a new line).
function __vsc_fish_prompt_start
__vsc_esc A
end
# Sent at the end of the prompt.
# Marks the beginning of the user's command input.
function __vsc_fish_cmd_start
__vsc_esc B
end
function __vsc_fish_has_mode_prompt -d "Returns true if fish_mode_prompt is defined and not empty"
functions fish_mode_prompt | string match -rvq '^ *(#|function |end$|$)'
end
# Preserve the user's existing prompt, to wrap in our escape sequences.
functions --copy fish_prompt __vsc_fish_prompt
# Preserve and wrap fish_mode_prompt (which appears to the left of the regular
# prompt), but only if it's not defined as an empty function (which is the
# officially documented way to disable that feature).
if __vsc_fish_has_mode_prompt
functions --copy fish_mode_prompt __vsc_fish_mode_prompt
function fish_mode_prompt
__vsc_fish_prompt_start
__vsc_fish_mode_prompt
end
function fish_prompt
__vsc_fish_prompt
__vsc_fish_cmd_start
end
else
# No fish_mode_prompt, so put everything in fish_prompt.
function fish_prompt
__vsc_fish_prompt_start
__vsc_fish_prompt
__vsc_fish_cmd_start
end
end

View File

@@ -0,0 +1,158 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
# Prevent installing more than once per session
if (Test-Path variable:global:__VSCodeOriginalPrompt) {
return;
}
# Disable shell integration when the language mode is restricted
if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") {
return;
}
$Global:__VSCodeOriginalPrompt = $function:Prompt
$Global:__LastHistoryId = -1
function Global:__VSCode-Escape-Value([string]$value) {
# NOTE: In PowerShell v6.1+, this can be written `$value -replace '…', { … }` instead of `[regex]::Replace`.
# Replace any non-alphanumeric characters.
[regex]::Replace($value, '[\\\n;]', { param($match)
# Encode the (ascii) matches as `\x<hex>`
-Join (
[System.Text.Encoding]::UTF8.GetBytes($match.Value) | ForEach-Object { '\x{0:x2}' -f $_ }
)
})
}
function Global:Prompt() {
$FakeCode = [int]!$global:?
# NOTE: We disable strict mode for the scope of this function because it unhelpfully throws an
# error when $LastHistoryEntry is null, and is not otherwise useful.
Set-StrictMode -Off
$LastHistoryEntry = Get-History -Count 1
# Skip finishing the command if the first command has not yet started
if ($Global:__LastHistoryId -ne -1) {
if ($LastHistoryEntry.Id -eq $Global:__LastHistoryId) {
# Don't provide a command line or exit code if there was no history entry (eg. ctrl+c, enter on no command)
$Result = "$([char]0x1b)]633;E`a"
$Result += "$([char]0x1b)]633;D`a"
} else {
# Command finished command line
# OSC 633 ; A ; <CommandLine?> ST
$Result = "$([char]0x1b)]633;E;"
# Sanitize the command line to ensure it can get transferred to the terminal and can be parsed
# correctly. This isn't entirely safe but good for most cases, it's important for the Pt parameter
# to only be composed of _printable_ characters as per the spec.
if ($LastHistoryEntry.CommandLine) {
$CommandLine = $LastHistoryEntry.CommandLine
} else {
$CommandLine = ""
}
$Result += $(__VSCode-Escape-Value $CommandLine)
$Result += "`a"
# Command finished exit code
# OSC 633 ; D [; <ExitCode>] ST
$Result += "$([char]0x1b)]633;D;$FakeCode`a"
}
}
# Prompt started
# OSC 633 ; A ST
$Result += "$([char]0x1b)]633;A`a"
# Current working directory
# OSC 633 ; <Property>=<Value> ST
$Result += if($pwd.Provider.Name -eq 'FileSystem'){"$([char]0x1b)]633;P;Cwd=$(__VSCode-Escape-Value $pwd.ProviderPath)`a"}
# Before running the original prompt, put $? back to what it was:
if ($FakeCode -ne 0) {
Write-Error "failure" -ea ignore
}
# Run the original prompt
$Result += $Global:__VSCodeOriginalPrompt.Invoke()
# Write command started
$Result += "$([char]0x1b)]633;B`a"
$Global:__LastHistoryId = $LastHistoryEntry.Id
return $Result
}
# Only send the command executed sequence when PSReadLine is loaded, if not shell integration should
# still work thanks to the command line sequence
if (Get-Module -Name PSReadLine) {
$__VSCodeOriginalPSConsoleHostReadLine = $function:PSConsoleHostReadLine
function Global:PSConsoleHostReadLine {
$tmp = $__VSCodeOriginalPSConsoleHostReadLine.Invoke()
# Write command executed sequence directly to Console to avoid the new line from Write-Host
[Console]::Write("$([char]0x1b)]633;C`a")
$tmp
}
}
# Set IsWindows property
[Console]::Write("$([char]0x1b)]633;P;IsWindows=$($IsWindows)`a")
# Set always on key handlers which map to default VS Code keybindings
function Set-MappedKeyHandler {
param ([string[]] $Chord, [string[]]$Sequence)
try {
$Handler = Get-PSReadLineKeyHandler -Chord $Chord | Select-Object -First 1
} catch [System.Management.Automation.ParameterBindingException] {
# PowerShell 5.1 ships with PSReadLine 2.0.0 which does not have -Chord,
# so we check what's bound and filter it.
$Handler = Get-PSReadLineKeyHandler -Bound | Where-Object -FilterScript { $_.Key -eq $Chord } | Select-Object -First 1
}
if ($Handler) {
Set-PSReadLineKeyHandler -Chord $Sequence -Function $Handler.Function
}
}
function Set-MappedKeyHandlers {
Set-MappedKeyHandler -Chord Ctrl+Spacebar -Sequence 'F12,a'
Set-MappedKeyHandler -Chord Alt+Spacebar -Sequence 'F12,b'
Set-MappedKeyHandler -Chord Shift+Enter -Sequence 'F12,c'
Set-MappedKeyHandler -Chord Shift+End -Sequence 'F12,d'
# Conditionally enable suggestions
if ($env:VSCODE_SUGGEST -eq '1') {
Remove-Item Env:VSCODE_SUGGEST
# VS Code send completions request (may override Ctrl+Spacebar)
Set-PSReadLineKeyHandler -Chord 'F12,e' -ScriptBlock {
Send-Completions
}
# Suggest trigger characters
Set-PSReadLineKeyHandler -Chord "-" -ScriptBlock {
[Microsoft.PowerShell.PSConsoleReadLine]::Insert("-")
Send-Completions
}
}
}
function Send-Completions {
$commandLine = ""
$cursorIndex = 0
# TODO: Since fuzzy matching exists, should completions be provided only for character after the
# last space and then filter on the client side? That would let you trigger ctrl+space
# anywhere on a word and have full completions available
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$cursorIndex)
$completionPrefix = $commandLine
# Get completions
$result = "`e]633;Completions"
if ($completionPrefix.Length -gt 0) {
# Get and send completions
$completions = TabExpansion2 -inputScript $completionPrefix -cursorColumn $cursorIndex
if ($null -ne $completions.CompletionMatches) {
$result += ";$($completions.ReplacementIndex);$($completions.ReplacementLength);$($cursorIndex);"
$result += $completions.CompletionMatches | ConvertTo-Json -Compress
}
}
$result += "`a"
Write-Host -NoNewLine $result
}
# Register key handlers if PSReadLine is available
if (Get-Module -Name PSReadLine) {
Set-MappedKeyHandlers
}

View File

@@ -2,5 +2,12 @@
<qresource prefix="/terminal">
<file>images/settingscategory_terminal.png</file>
<file>images/settingscategory_terminal@2x.png</file>
<file>shellintegrations/shellintegration-bash.sh</file>
<file>shellintegrations/shellintegration-env.zsh</file>
<file>shellintegrations/shellintegration-login.zsh</file>
<file>shellintegrations/shellintegration-profile.zsh</file>
<file>shellintegrations/shellintegration-rc.zsh</file>
<file>shellintegrations/shellintegration.fish</file>
<file>shellintegrations/shellintegration.ps1</file>
</qresource>
</RCC>

View File

@@ -188,13 +188,29 @@ void TerminalPane::setupTerminalWidget(TerminalWidget *terminal)
auto setTabText = [this](TerminalWidget * terminal) {
auto index = m_tabWidget->indexOf(terminal);
m_tabWidget->setTabText(index, terminal->shellName());
const FilePath cwd = terminal->cwd();
const QString exe = terminal->currentCommand().isEmpty() ? terminal->shellName()
: terminal->currentCommand().executable().fileName();
if (cwd.isEmpty())
m_tabWidget->setTabText(index, exe);
else
m_tabWidget->setTabText(index, exe + " - " + cwd.fileName());
};
connect(terminal, &TerminalWidget::started, [setTabText, terminal](qint64 /*pid*/) {
setTabText(terminal);
});
connect(terminal, &TerminalWidget::cwdChanged, [setTabText, terminal]() {
setTabText(terminal);
});
connect(terminal, &TerminalWidget::commandChanged, [setTabText, terminal]() {
setTabText(terminal);
});
if (!terminal->shellName().isEmpty())
setTabText(terminal);

View File

@@ -23,10 +23,13 @@ QColor toQColor(const VTermColor &c)
struct TerminalSurfacePrivate
{
TerminalSurfacePrivate(TerminalSurface *surface, const QSize &initialGridSize)
TerminalSurfacePrivate(TerminalSurface *surface,
const QSize &initialGridSize,
ShellIntegration *shellIntegration)
: m_vterm(vterm_new(initialGridSize.height(), initialGridSize.width()), vterm_free)
, m_vtermScreen(vterm_obtain_screen(m_vterm.get()))
, m_scrollback(std::make_unique<Internal::Scrollback>(5000))
, m_shellIntegration(shellIntegration)
, q(surface)
{}
@@ -75,7 +78,15 @@ struct TerminalSurfacePrivate
vterm_screen_set_damage_merge(m_vtermScreen, VTERM_DAMAGE_SCROLL);
vterm_screen_enable_altscreen(m_vtermScreen, true);
memset(&m_vtermStateFallbacks, 0, sizeof(m_vtermStateFallbacks));
m_vtermStateFallbacks.osc = [](int cmd, VTermStringFragment fragment, void *user) {
auto p = static_cast<TerminalSurfacePrivate *>(user);
return p->osc(cmd, fragment);
};
VTermState *vts = vterm_obtain_state(m_vterm.get());
vterm_state_set_unrecognised_fallbacks(vts, &m_vtermStateFallbacks, this);
vterm_state_set_bold_highbright(vts, true);
vterm_screen_reset(m_vtermScreen, 1);
@@ -196,6 +207,14 @@ struct TerminalSurfacePrivate
return 1;
}
int osc(int cmd, const VTermStringFragment &fragment)
{
if (m_shellIntegration)
m_shellIntegration->onOsc(cmd, fragment);
return 1;
}
int setTerminalProperties(VTermProp prop, VTermValue *val)
{
switch (prop) {
@@ -274,19 +293,23 @@ struct TerminalSurfacePrivate
std::unique_ptr<VTerm, void (*)(VTerm *)> m_vterm;
VTermScreen *m_vtermScreen;
VTermScreenCallbacks m_vtermScreenCallbacks;
VTermStateFallbacks m_vtermStateFallbacks;
QColor m_defaultBgColor;
Cursor m_cursor;
QString m_currentCommand;
bool m_altscreen{false};
std::unique_ptr<Internal::Scrollback> m_scrollback;
ShellIntegration *m_shellIntegration{nullptr};
TerminalSurface *q;
};
TerminalSurface::TerminalSurface(QSize initialGridSize)
: d(std::make_unique<TerminalSurfacePrivate>(this, initialGridSize))
TerminalSurface::TerminalSurface(QSize initialGridSize, ShellIntegration *shellIntegration)
: d(std::make_unique<TerminalSurfacePrivate>(this, initialGridSize, shellIntegration))
{
d->init();
}
@@ -478,6 +501,11 @@ QColor TerminalSurface::defaultBgColor() const
return toQColor(d->defaultBgColor());
}
ShellIntegration *TerminalSurface::shellIntegration() const
{
return d->m_shellIntegration;
}
CellIterator TerminalSurface::begin() const
{
auto res = CellIterator(this, {0, 0});

View File

@@ -4,6 +4,7 @@
#pragma once
#include "celliterator.h"
#include "shellintegration.h"
#include <QKeyEvent>
#include <QSize>
@@ -47,7 +48,7 @@ class TerminalSurface : public QObject
Q_OBJECT;
public:
TerminalSurface(QSize initialGridSize);
TerminalSurface(QSize initialGridSize, ShellIntegration *shellIntegration);
~TerminalSurface();
public:
@@ -95,6 +96,8 @@ public:
QColor defaultBgColor() const;
ShellIntegration *shellIntegration() const;
signals:
void writeToPty(const QByteArray &data);
void invalidated(QRect grid);

View File

@@ -12,6 +12,7 @@
#include <utils/algorithm.h>
#include <utils/environment.h>
#include <utils/fileutils.h>
#include <utils/hostosinfo.h>
#include <utils/processinterface.h>
#include <utils/stringutils.h>
@@ -132,6 +133,10 @@ void TerminalWidget::setupPty()
m_process->setWorkingDirectory(*m_openParameters.workingDirectory);
m_process->setEnvironment(env);
if (m_surface->shellIntegration()) {
m_surface->shellIntegration()->prepareProcess(*m_process.get());
}
connect(m_process.get(), &QtcProcess::readyReadStandardOutput, this, [this]() {
onReadyRead(false);
});
@@ -242,7 +247,8 @@ void TerminalWidget::setupActions()
connect(&m_zoomInAction, &QAction::triggered, this, &TerminalWidget::zoomIn);
connect(&m_zoomOutAction, &QAction::triggered, this, &TerminalWidget::zoomOut);
addActions({&m_copyAction, &m_pasteAction, &m_clearSelectionAction, &m_zoomInAction, &m_zoomOutAction});
addActions(
{&m_copyAction, &m_pasteAction, &m_clearSelectionAction, &m_zoomInAction, &m_zoomOutAction});
}
void TerminalWidget::writeToPty(const QByteArray &data)
@@ -253,7 +259,8 @@ void TerminalWidget::writeToPty(const QByteArray &data)
void TerminalWidget::setupSurface()
{
m_surface = std::make_unique<Internal::TerminalSurface>(QSize{80, 60});
m_shellIntegration.reset(new ShellIntegration());
m_surface = std::make_unique<Internal::TerminalSurface>(QSize{80, 60}, m_shellIntegration.get());
connect(m_surface.get(),
&Internal::TerminalSurface::writeToPty,
@@ -299,6 +306,22 @@ void TerminalWidget::setupSurface()
connect(m_surface.get(), &Internal::TerminalSurface::unscroll, this, [this] {
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
});
if (m_shellIntegration) {
connect(m_shellIntegration.get(),
&ShellIntegration::commandChanged,
this,
[this](const CommandLine &command) {
m_currentCommand = command;
emit commandChanged(m_currentCommand);
});
connect(m_shellIntegration.get(),
&ShellIntegration::currentDirChanged,
this,
[this](const QString &currentDir) {
m_cwd = FilePath::fromUserInput(currentDir);
emit cwdChanged(m_cwd);
});
}
}
void TerminalWidget::configBlinkTimer()
@@ -470,6 +493,16 @@ QString TerminalWidget::shellName() const
return m_shellName;
}
FilePath TerminalWidget::cwd() const
{
return m_cwd;
}
CommandLine TerminalWidget::currentCommand() const
{
return m_currentCommand;
}
QPoint TerminalWidget::viewportToGlobal(QPoint p) const
{
int y = p.y() - topMargin();

View File

@@ -72,8 +72,13 @@ public:
QString shellName() const;
Utils::FilePath cwd() const;
Utils::CommandLine currentCommand() const;
signals:
void started(qint64 pid);
void cwdChanged(const Utils::FilePath &cwd);
void commandChanged(const Utils::CommandLine &cmd);
protected:
void paintEvent(QPaintEvent *event) override;
@@ -158,6 +163,7 @@ protected:
private:
std::unique_ptr<Utils::QtcProcess> m_process;
std::unique_ptr<Internal::TerminalSurface> m_surface;
std::unique_ptr<ShellIntegration> m_shellIntegration;
QString m_shellName;
@@ -201,6 +207,9 @@ private:
Internal::Cursor m_cursor;
QTimer m_cursorBlinkTimer;
bool m_cursorBlinkState{true};
Utils::FilePath m_cwd;
Utils::CommandLine m_currentCommand;
};
} // namespace Terminal

View File

@@ -0,0 +1,70 @@
#!/bin/bash
echo "Testing integration response, best start this from a terminal that has no builtin integration"
echo "e.g. 'sh'"
echo
echo -e "\033[1m ⎆ Current dir should have changed to '/Some/Dir/Here'\033[0m"
printf "\033]7;file:///Some/Dir/Here\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ Current dir should have changed to '/Some/Other/Dir/Here'\033[0m"
printf "\033]1337;CurrentDir=/Some/Other/Dir/Here\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ Current dir should have changed to '/VSCode/dir/with space'\033[0m"
printf "\033]633P;Cwd=/VSCode/dir/with space\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'test'\033[0m"
printf "\033]633E;test with arguments\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'test with space'\033[0m"
printf "\033]633E;'test with space'\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'test with space v2'\033[0m"
printf "\033]633E;\"test with space v2\"\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'test with space v3'\033[0m"
printf "\033]633E;\"./test/test with space v3\" -argument\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'cat'\033[0m"
printf "\033]633E;cat /dev/random | base64 -argument\033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo
echo -e "\033[1m ⎆ The current process should have changed to 'cat me'\033[0m"
printf "\033]633E;cat\\ me args \033\\"
read -p " ⎆ Press enter to continue " -n1 -s
echo
echo