diff --git a/bun.lock b/bun.lock index e4998248..2477ba58 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@pierre/trees": "^1.0.0-beta.3", "@tanstack/react-query": "^5.97.0", "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-process": "~2", @@ -559,6 +560,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.0", "", { "os": "win32", "cpu": "x64" }, "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "https://registry.npmmirror.com/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], diff --git a/package.json b/package.json index fbe33ba0..9f8fb5c3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@pierre/trees": "^1.0.0-beta.3", "@tanstack/react-query": "^5.97.0", "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-process": "~2", diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 5ff9fa2b..53c73628 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -8,3 +8,6 @@ # Built sidecar binaries /binaries/ + +# Local dev config override (separate identifier so it doesn't clash with installed app) +tauri.conf.dev.json diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eaad3d97..1aadea01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -106,6 +106,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -474,6 +495,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -659,6 +686,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -689,6 +725,7 @@ dependencies = [ "service", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", @@ -874,6 +911,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1360,6 +1403,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1387,6 +1436,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1444,6 +1499,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1759,6 +1820,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1971,6 +2042,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2319,6 +2401,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2860,6 +2956,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.1" @@ -2929,6 +3035,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "8.2.0" @@ -3036,6 +3151,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3360,6 +3476,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -3764,6 +3891,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -3782,6 +3921,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5181,6 +5329,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -5541,6 +5704,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5896,6 +6073,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6337,6 +6525,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -6467,6 +6725,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -7032,6 +7296,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7103,6 +7385,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -7295,6 +7594,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8cfa5563..029dc57f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-store = "2" tauri-plugin-shell = "2.3.5" +tauri-plugin-clipboard-manager = "2" # App-layer only dependencies serde = { version = "1", features = [ "derive" ] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f2b689c4..4ebeb220 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ "store:default", "shell:allow-open", "updater:default", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", { "identifier": "shell:allow-execute", "allow": [ diff --git a/src-tauri/crates/infra/scripts/default_init.sh b/src-tauri/crates/infra/scripts/default_init_common.sh similarity index 82% rename from src-tauri/crates/infra/scripts/default_init.sh rename to src-tauri/crates/infra/scripts/default_init_common.sh index 78a2b4cc..3332d795 100644 --- a/src-tauri/crates/infra/scripts/default_init.sh +++ b/src-tauri/crates/infra/scripts/default_init_common.sh @@ -42,11 +42,3 @@ CLAUDE_WRAPPER command chmod +x "$_2CODE_BIN/claude" export PATH="$_2CODE_BIN:$PATH" - -unsetopt PROMPT_SP - -# Rebind ^J (LF) so Shift+Enter inserts a newline instead of executing. -# Enter still sends ^M (CR) which remains bound to accept-line. -_2code_insert_newline() { LBUFFER+=$'\n'; } -zle -N _2code_insert_newline -bindkey '^J' _2code_insert_newline diff --git a/src-tauri/crates/infra/scripts/default_init_zsh.sh b/src-tauri/crates/infra/scripts/default_init_zsh.sh new file mode 100644 index 00000000..26298850 --- /dev/null +++ b/src-tauri/crates/infra/scripts/default_init_zsh.sh @@ -0,0 +1,7 @@ +unsetopt PROMPT_SP + +# Rebind ^J (LF) so Shift+Enter inserts a newline instead of executing. +# Enter still sends ^M (CR) which remains bound to accept-line. +_2code_insert_newline() { LBUFFER+=$'\n'; } +zle -N _2code_insert_newline +bindkey '^J' _2code_insert_newline diff --git a/src-tauri/crates/infra/scripts/shellIntegration-bash.sh b/src-tauri/crates/infra/scripts/shellIntegration-bash.sh new file mode 100644 index 00000000..c5729c39 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration-bash.sh @@ -0,0 +1,492 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- + +# Prevent the script recursing when setting up +if [[ -n "${VSCODE_SHELL_INTEGRATION:-}" ]]; then + builtin return +fi + +VSCODE_SHELL_INTEGRATION=1 + +vsc_env_keys=() +vsc_env_values=() +use_associative_array=0 +bash_major_version=${BASH_VERSINFO[0]} + +__vscode_shell_env_reporting="${VSCODE_SHELL_ENV_REPORTING:-}" +unset VSCODE_SHELL_ENV_REPORTING + +envVarsToReport=() +IFS=',' read -ra envVarsToReport <<< "$__vscode_shell_env_reporting" + +if (( BASH_VERSINFO[0] >= 4 )); then + use_associative_array=1 + # Associative arrays are only available in bash 4.0+ + declare -A vsc_aa_env +fi + +# 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 + # execute 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 + +# Prevent AI-executed commands from polluting shell history +if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then + export HISTCONTROL="ignorespace" + builtin unset VSCODE_PREVENT_SHELL_HISTORY +fi + +# Apply EnvironmentVariableCollections if needed +if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then + IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo $ITEM | cut -d "=" -f 1)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + export $VARNAME="$VALUE" + done + builtin unset VSCODE_ENV_REPLACE +fi +if [ -n "${VSCODE_ENV_PREPEND:-}" ]; then + IFS=':' read -ra ADDR <<< "$VSCODE_ENV_PREPEND" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo $ITEM | cut -d "=" -f 1)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + export $VARNAME="$VALUE${!VARNAME}" + done + builtin unset VSCODE_ENV_PREPEND +fi +if [ -n "${VSCODE_ENV_APPEND:-}" ]; then + IFS=':' read -ra ADDR <<< "$VSCODE_ENV_APPEND" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo $ITEM | cut -d "=" -f 1)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" + export $VARNAME="${!VARNAME}$VALUE" + done + builtin unset VSCODE_ENV_APPEND +fi + +# Register Python shell activate hooks +# Prevent multiple activation with guard +if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then + export VSCODE_PYTHON_AUTOACTIVATE_GUARD=1 + if [ -n "${VSCODE_PYTHON_BASH_ACTIVATE:-}" ] && [ "$TERM_PROGRAM" = "vscode" ]; then + # Prevent crashing by negating exit code + if ! builtin eval "$VSCODE_PYTHON_BASH_ACTIVATE"; then + __vsc_activation_status=$? + builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" + fi + fi + # Remove any leftover Python activation env vars. + for var in "${!VSCODE_PYTHON_@}"; do + case "$var" in + VSCODE_PYTHON_*_ACTIVATE) + unset "$var" + ;; + esac + done +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]:-}" +} + +__vsc_escape_value_fast() { + builtin local LC_ALL=C out + out=${1//\\/\\\\} + out=${out//;/\\x3b} + builtin printf '%s\n' "${out}" +} + +# 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() { + # If the input being too large, switch to the faster function + if [ "${#1}" -ge 2000 ]; then + __vsc_escape_value_fast "$1" + builtin return + fi + + # Process text byte by byte, not by codepoint. + builtin local -r LC_ALL=C + builtin local -r str="${1}" + builtin local -ir len="${#str}" + + builtin local -i i + builtin local -i val + builtin local byte + builtin local token + builtin local out='' + + for (( i=0; i < "${#str}"; ++i )); do + # Escape backslashes, semi-colons specially, then special ASCII chars below space (0x20). + byte="${str:$i:1}" + builtin printf -v val '%d' "'$byte" + if (( val < 31 )); then + builtin printf -v token '\\x%02x' "'$byte" + elif (( val == 92 )); then # \ + token="\\\\" + elif (( val == 59 )); 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 +__vsc_regex_environment="^CYGWIN*|MINGW*|MSYS*" +if [[ "$(uname -s)" =~ $__vsc_regex_environment ]]; then + builtin printf '\e]633;P;IsWindows=True\a' + __vsc_is_windows=1 +else + __vsc_is_windows=0 +fi + +# Allow verifying $BASH_COMMAND doesn't have aliases resolved via history when the right HISTCONTROL +# configuration is used +__vsc_regex_histcontrol=".*(erasedups|ignoreboth|ignoredups|ignorespace).*" +if [[ "${HISTCONTROL:-}" =~ $__vsc_regex_histcontrol ]]; then + __vsc_history_verify=0 +else + __vsc_history_verify=1 +fi + +builtin unset __vsc_regex_environment +builtin unset __vsc_regex_histcontrol + +__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="" + +# It's fine this is in the global scope as it getting at it requires access to the shell environment +__vsc_nonce="$VSCODE_NONCE" +unset VSCODE_NONCE + +# Some features should only work in Insiders +__vsc_stable="$VSCODE_STABLE" +unset VSCODE_STABLE + +# Report continuation prompt +if [ "$__vsc_stable" = "0" ]; then + builtin printf "\e]633;P;ContinuationPrompt=$(echo "$PS2" | sed 's/\x1b/\\\\x1b/g')\a" +fi + +if [ -n "$STARSHIP_SESSION_KEY" ]; then + builtin printf '\e]633;P;PromptType=starship\a' +elif [ -n "$POSH_SESSION_ID" ]; then + builtin printf '\e]633;P;PromptType=oh-my-posh\a' +fi + +# Report this shell supports rich command detection +builtin printf '\e]633;P;HasRichCommandDetection=True\a' + +__vsc_report_prompt() { + # Expand the original PS1 similarly to how bash would normally + # See https://stackoverflow.com/a/37137981 for technique + if ((BASH_VERSINFO[0] >= 5 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4))); then + __vsc_prompt=${__vsc_original_PS1@P} + else + __vsc_prompt=${__vsc_original_PS1} + fi + + __vsc_prompt="$(builtin printf "%s" "${__vsc_prompt//[$'\001'$'\002']}")" + builtin printf "\e]633;P;Prompt=%s\a" "$(__vsc_escape_value "${__vsc_prompt}")" +} + +__vsc_prompt_start() { + builtin printf '\e]633;A\a' +} + +__vsc_prompt_end() { + builtin printf '\e]633;B\a' +} + +__vsc_update_cwd() { + if [ "$__vsc_is_windows" = "1" ]; then + __vsc_cwd="$(cygpath -m "$PWD")" + else + __vsc_cwd="$PWD" + fi + builtin printf '\e]633;P;Cwd=%s\a' "$(__vsc_escape_value "$__vsc_cwd")" +} + +__updateEnvCacheAA() { + local key="$1" + local value="$2" + if [ "$use_associative_array" = 1 ]; then + if [[ "${vsc_aa_env[$key]}" != "$value" ]]; then + vsc_aa_env["$key"]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + fi +} + +__updateEnvCache() { + local key="$1" + local value="$2" + + for i in "${!vsc_env_keys[@]}"; do + if [[ "${vsc_env_keys[$i]}" == "$key" ]]; then + if [[ "${vsc_env_values[$i]}" != "$value" ]]; then + vsc_env_values[$i]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + return + fi + done + + vsc_env_keys+=("$key") + vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" +} + +__vsc_update_env() { + if [[ ${#envVarsToReport[@]} -gt 0 ]]; then + builtin printf '\e]633;EnvSingleStart;%s;%s\a' 0 $__vsc_nonce + + if [ "$use_associative_array" = 1 ]; then + if [ ${#vsc_aa_env[@]} -eq 0 ]; then + # Associative array is empty, do not diff, just add + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + vsc_aa_env["$key"]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done + else + # Diff approach for associative array + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + __updateEnvCacheAA "$key" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. + fi + + else + if [[ -z ${vsc_env_keys[@]} ]] && [[ -z ${vsc_env_values[@]} ]]; then + # Non associative arrays are both empty, do not diff, just add + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + vsc_env_keys+=("$key") + vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done + else + # Diff approach for non-associative arrays + for key in "${envVarsToReport[@]}"; do + if [ -n "${!key+x}" ]; then + local value="${!key}" + __updateEnvCache "$key" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. + fi + fi + builtin printf '\e]633;EnvSingleEnd;%s;\a' $__vsc_nonce + fi +} + +__vsc_command_output_start() { + if [[ -z "${__vsc_first_prompt-}" ]]; then + builtin return + fi + builtin printf '\e]633;E;%s;%s\a' "$(__vsc_escape_value "${__vsc_current_command}")" $__vsc_nonce + builtin printf '\e]633;C\a' +} + +__vsc_continuation_start() { + builtin printf '\e]633;F\a' +} + +__vsc_continuation_end() { + builtin printf '\e]633;G\a' +} + +__vsc_command_complete() { + if [[ -z "${__vsc_first_prompt-}" ]]; then + __vsc_update_cwd + builtin return + fi + 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="" + # Report prompt is a work in progress, currently encoding is too slow + if [ "$__vsc_stable" = "0" ]; then + __vsc_report_prompt + fi + __vsc_first_prompt=1 + __vsc_update_prompt + __vsc_update_env +} + +__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" + __vsc_preexec + builtin eval "${__vsc_dbg_trap}" + fi + } + trap '__vsc_preexec_all "$_"' DEBUG + fi +fi + +__vsc_update_prompt + +__vsc_restore_exit_code() { + return "$1" +} + +__vsc_prompt_cmd_original() { + __vsc_status="$?" + builtin local cmd + __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 diff --git a/src-tauri/crates/infra/scripts/shellIntegration-env.zsh b/src-tauri/crates/infra/scripts/shellIntegration-env.zsh new file mode 100644 index 00000000..4478a3e7 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration-env.zsh @@ -0,0 +1,16 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- +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 diff --git a/src-tauri/crates/infra/scripts/shellIntegration-login.zsh b/src-tauri/crates/infra/scripts/shellIntegration-login.zsh new file mode 100644 index 00000000..8edbca36 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration-login.zsh @@ -0,0 +1,15 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- + +# Prevent recursive sourcing +if [[ -n "$VSCODE_LOGIN_INITIALIZED" ]]; then + return +fi +export VSCODE_LOGIN_INITIALIZED=1 + +ZDOTDIR=$USER_ZDOTDIR +if [[ $options[norcs] = off && -o "login" && -f $ZDOTDIR/.zlogin ]]; then + . $ZDOTDIR/.zlogin +fi diff --git a/src-tauri/crates/infra/scripts/shellIntegration-profile.zsh b/src-tauri/crates/infra/scripts/shellIntegration-profile.zsh new file mode 100644 index 00000000..7401c10d --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration-profile.zsh @@ -0,0 +1,25 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- + +# Prevent recursive sourcing +if [[ -n "$VSCODE_PROFILE_INITIALIZED" ]]; then + return +fi +export VSCODE_PROFILE_INITIALIZED=1 + +if [[ $options[norcs] = off && -o "login" ]]; then + if [[ -f $USER_ZDOTDIR/.zprofile ]]; then + VSCODE_ZDOTDIR=$ZDOTDIR + ZDOTDIR=$USER_ZDOTDIR + . $USER_ZDOTDIR/.zprofile + ZDOTDIR=$VSCODE_ZDOTDIR + fi + + # 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 diff --git a/src-tauri/crates/infra/scripts/shellIntegration-rc.zsh b/src-tauri/crates/infra/scripts/shellIntegration-rc.zsh new file mode 100644 index 00000000..c43718e3 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration-rc.zsh @@ -0,0 +1,338 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- +builtin autoload -Uz add-zsh-hook is-at-least + +# 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 + +__vsc_use_aa=0 +__vsc_env_keys=() +__vsc_env_values=() + +# Associative array are only available in zsh 4.3 or later +if is-at-least 4.3; then + __vsc_use_aa=1 + typeset -A vsc_aa_env +fi + +# Apply EnvironmentVariableCollections if needed +if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then + IFS=':' read -rA ADDR <<< "$VSCODE_ENV_REPLACE" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo ${ITEM%%=*})" + export $VARNAME="$(echo -e ${ITEM#*=})" + done + unset VSCODE_ENV_REPLACE +fi +if [ -n "${VSCODE_ENV_PREPEND:-}" ]; then + IFS=':' read -rA ADDR <<< "$VSCODE_ENV_PREPEND" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo ${ITEM%%=*})" + export $VARNAME="$(echo -e ${ITEM#*=})${(P)VARNAME}" + done + unset VSCODE_ENV_PREPEND +fi +if [ -n "${VSCODE_ENV_APPEND:-}" ]; then + IFS=':' read -rA ADDR <<< "$VSCODE_ENV_APPEND" + for ITEM in "${ADDR[@]}"; do + VARNAME="$(echo ${ITEM%%=*})" + export $VARNAME="${(P)VARNAME}$(echo -e ${ITEM#*=})" + done + unset VSCODE_ENV_APPEND +fi + +# Register Python shell activate hooks +# Prevent multiple activation with guard +if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then + export VSCODE_PYTHON_AUTOACTIVATE_GUARD=1 + if [ -n "${VSCODE_PYTHON_ZSH_ACTIVATE:-}" ] && [ "$TERM_PROGRAM" = "vscode" ]; then + # Prevent crashing by negating exit code + if ! builtin eval "$VSCODE_PYTHON_ZSH_ACTIVATE"; then + __vsc_activation_status=$? + builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" + fi + fi + # Remove any leftover Python activation env vars. + unset -m 'VSCODE_PYTHON_*_ACTIVATE' +fi + +# Report prompt type +if [ -n "${P9K_SSH:-}" ] || [ -n "${P9K_TTY:-}" ]; then + builtin printf '\e]633;P;PromptType=p10k\a' + # Force shell integration on for p10k + # typeset -g POWERLEVEL9K_TERM_SHELL_INTEGRATION=true +elif [ -n "${ZSH:-}" ] && [ -n "$ZSH_VERSION" ] && (( ${+functions[omz]} )); then + builtin printf '\e]633;P;PromptType=oh-my-zsh\a' +elif [ -n "${STARSHIP_SESSION_KEY:-}" ]; then + builtin printf '\e]633;P;PromptType=starship\a' +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 + +# Prevent AI-executed commands from polluting shell history +if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then + builtin setopt HIST_IGNORE_SPACE + builtin unset VSCODE_PREVENT_SHELL_HISTORY +fi + +# Agent terminal zsh fixups: disable bang history expansion so ! in double +# quotes does not hang on dquote>, and enable inline # comments so the +# agent can annotate commands. +if [ "${VSCODE_AGENT_ZSH_FIXUPS:-}" = "1" ]; then + builtin setopt NO_BANG_HIST + builtin setopt INTERACTIVE_COMMENTS + builtin unset VSCODE_AGENT_ZSH_FIXUPS +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='' val + + for (( i = 0; i < ${#str}; ++i )); do + # Escape backslashes, semi-colons specially, then special ASCII chars below space (0x20). + byte="${str:$i:1}" + val=$(printf "%d" "'$byte") + if (( val < 31 )); then + # For control characters, use hex encoding + token=$(printf "\\\\x%02x" "'$byte") + elif [ "$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="" + +# It's fine this is in the global scope as it getting at it requires access to the shell environment +__vsc_nonce="$VSCODE_NONCE" +unset VSCODE_NONCE + +__vscode_shell_env_reporting="${VSCODE_SHELL_ENV_REPORTING:-}" +unset VSCODE_SHELL_ENV_REPORTING + +envVarsToReport=() +IFS=',' read -rA envVarsToReport <<< "$__vscode_shell_env_reporting" + +builtin printf "\e]633;P;ContinuationPrompt=%s\a" "$(echo "$PS2" | sed 's/\x1b/\\\\x1b/g')" + +# Report this shell supports rich command detection +builtin printf '\e]633;P;HasRichCommandDetection=True\a' + +__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}")" +} + +__update_env_cache_aa() { + local key="$1" + local value="$2" + if [ $__vsc_use_aa -eq 1 ]; then + if [[ "${vsc_aa_env["$key"]}" != "$value" ]]; then + vsc_aa_env["$key"]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + fi +} + +__update_env_cache() { + local key="$1" + local value="$2" + + for (( i=1; i <= $#__vsc_env_keys; i++ )); do + if [[ "${__vsc_env_keys[$i]}" == "$key" ]]; then + if [[ "${__vsc_env_values[$i]}" != "$value" ]]; then + __vsc_env_values[$i]="$value" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + return + fi + done + + # Key does not exist so add key, value pair + __vsc_env_keys+=("$key") + __vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" +} + +__vsc_update_env() { + if [[ ${#envVarsToReport[@]} -gt 0 ]]; then + builtin printf '\e]633;EnvSingleStart;%s;%s;\a' 0 $__vsc_nonce + if [ $__vsc_use_aa -eq 1 ]; then + if [[ ${#vsc_aa_env[@]} -eq 0 ]]; then + # Associative array is empty, do not diff, just add + for key in "${envVarsToReport[@]}"; do + if [[ -n "$key" && -n "${(P)key+_}" ]]; then + vsc_aa_env["$key"]="${(P)key}" + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "${(P)key}")" "$__vsc_nonce" + fi + done + else + # Diff approach for associative array + for var in "${envVarsToReport[@]}"; do + if [[ -n "$var" && -n "${(P)var+_}" ]]; then + value="${(P)var}" + __update_env_cache_aa "$var" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. + fi + else + # Two arrays approach + if [[ ${#__vsc_env_keys[@]} -eq 0 ]] && [[ ${#__vsc_env_values[@]} -eq 0 ]]; then + # Non-associative arrays are both empty, do not diff, just add + for key in "${envVarsToReport[@]}"; do + if [[ -n "$key" && -n "${(P)key+_}" ]]; then + value="${(P)key}" + __vsc_env_keys+=("$key") + __vsc_env_values+=("$value") + builtin printf '\e]633;EnvSingleEntry;%s;%s;%s\a' "$key" "$(__vsc_escape_value "$value")" "$__vsc_nonce" + fi + done + else + # Diff approach for non-associative arrays + for var in "${envVarsToReport[@]}"; do + if [[ -n "$var" && -n "${(P)var+_}" ]]; then + value="${(P)var}" + __update_env_cache "$var" "$value" + fi + done + # Track missing env vars not needed for now, as we are only tracking pre-defined env var from terminalEnvironment. + fi + fi + + builtin printf '\e]633;EnvSingleEnd;%s;\a' $__vsc_nonce + fi +} + +__vsc_command_output_start() { + builtin printf '\e]633;E;%s;%s\a' "$(__vsc_escape_value "${__vsc_current_command}")" $__vsc_nonce + builtin printf '\e]633;C\a' +} + +__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() { + builtin 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_update_env +} + +__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=$1 + __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 diff --git a/src-tauri/crates/infra/scripts/shellIntegration.fish b/src-tauri/crates/infra/scripts/shellIntegration.fish new file mode 100644 index 00000000..6cff7487 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration.fish @@ -0,0 +1,263 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- +# +# 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 +set --global __vscode_shell_env_reporting $VSCODE_SHELL_ENV_REPORTING +set -e VSCODE_SHELL_ENV_REPORTING + +# Prevent AI-executed commands from polluting shell history +if test "$VSCODE_PREVENT_SHELL_HISTORY" = "1" + set -g fish_private_mode 1 + set -e VSCODE_PREVENT_SHELL_HISTORY +end + +set -g envVarsToReport +if test -n "$__vscode_shell_env_reporting" + set envVarsToReport (string split "," "$__vscode_shell_env_reporting") +end + +# Apply any explicit path prefix (see #99878) +# On fish, '$fish_user_paths' is always prepended to the PATH, for both login and non-login shells, so we need +# to apply the path prefix fix always, not only for login shells (see #232291) +if set -q VSCODE_PATH_PREFIX + set -gx PATH "$VSCODE_PATH_PREFIX$PATH" +end +set -e VSCODE_PATH_PREFIX + +set -g vsc_env_keys +set -g vsc_env_values + +# Tracks if the shell has been initialized, this prevents +set -g vsc_initialized 0 + +set -g __vsc_applied_env_vars 0 +function __vsc_apply_env_vars + if test $__vsc_applied_env_vars -eq 1; + return + end + set -l __vsc_applied_env_vars 1 + # Apply EnvironmentVariableCollections if needed + if test -n "$VSCODE_ENV_REPLACE" + set ITEMS (string split : $VSCODE_ENV_REPLACE) + for B in $ITEMS + set split (string split -m1 = $B) + set -gx "$split[1]" (echo -e "$split[2]") + end + set -e VSCODE_ENV_REPLACE + end + if test -n "$VSCODE_ENV_PREPEND" + set ITEMS (string split : $VSCODE_ENV_PREPEND) + for B in $ITEMS + set split (string split -m1 = $B) + set -gx "$split[1]" (echo -e "$split[2]")"$$split[1]" # avoid -p as it adds a space + end + set -e VSCODE_ENV_PREPEND + end + if test -n "$VSCODE_ENV_APPEND" + set ITEMS (string split : $VSCODE_ENV_APPEND) + for B in $ITEMS + set split (string split -m1 = $B) + set -gx "$split[1]" "$$split[1]"(echo -e "$split[2]") # avoid -a as it adds a space + end + set -e VSCODE_ENV_APPEND + end +end + +# Register Python shell activate hooks +# Prevent multiple activation with guard +if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD + set -gx VSCODE_PYTHON_AUTOACTIVATE_GUARD 1 + if test -n "$VSCODE_PYTHON_FISH_ACTIVATE"; and test "$TERM_PROGRAM" = "vscode" + # Fish does not crash on eval failure, so don't need negation. + eval $VSCODE_PYTHON_FISH_ACTIVATE + set __vsc_activation_status $status + + if test $__vsc_activation_status -ne 0 + builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" + end + end + # Remove any leftover Python activation env vars. + for var in (set -n | string match -r '^VSCODE_PYTHON_.*_ACTIVATE$') + set -eg $var + end +end + +# Handle the shell integration nonce +if set -q VSCODE_NONCE + set -l __vsc_nonce $VSCODE_NONCE + set -e VSCODE_NONCE +end + +# 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 E (__vsc_escape_value "$argv") $__vsc_nonce + __vsc_esc C + + # 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 + if test $vsc_initialized -eq 0; + return + end + __vsc_esc E "" $__vsc_nonce + __vsc_esc C + __vsc_esc D +end + +# Preserve the user's existing prompt, to wrap in our escape sequences. +function __preserve_fish_prompt --on-event fish_prompt + if functions --query fish_prompt + if functions --query __vsc_fish_prompt + # Erase the fallback so it can be set to the user's prompt + functions --erase __vsc_fish_prompt + end + functions --copy fish_prompt __vsc_fish_prompt + functions --erase __preserve_fish_prompt + # Now __vsc_fish_prompt is guaranteed to be defined + __init_vscode_shell_integration + else + if functions --query __vsc_fish_prompt + functions --erase __preserve_fish_prompt + __init_vscode_shell_integration + else + # There is no fish_prompt set, so stick with the default + # Now __vsc_fish_prompt is guaranteed to be defined + function __vsc_fish_prompt + echo -n (whoami)@(prompt_hostname) (prompt_pwd) '~> ' + end + end + end +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 + +if test -n "$__vscode_shell_env_reporting" + function __vsc_update_env --on-event fish_prompt + if test (count $envVarsToReport) -gt 0 + __vsc_esc EnvSingleStart 1 + + for key in $envVarsToReport + if set -q $key + set -l value $$key + __vsc_esc EnvSingleEntry $key (__vsc_escape_value "$value") + end + end + + __vsc_esc EnvSingleEnd + end + 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 + # Applying environment variables is deferred to after config.fish has been + # evaluated + __vsc_apply_env_vars + __vsc_esc A + set -g vsc_initialized 1 +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 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). +function __init_vscode_shell_integration + 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 +end + +# Report prompt type +if set -q POSH_SESSION_ID + __vsc_esc P PromptType=oh-my-posh +end + +# Report this shell supports rich command detection +__vsc_esc P HasRichCommandDetection=True + +__preserve_fish_prompt diff --git a/src-tauri/crates/infra/scripts/shellIntegration.ps1 b/src-tauri/crates/infra/scripts/shellIntegration.ps1 new file mode 100644 index 00000000..72a329b8 --- /dev/null +++ b/src-tauri/crates/infra/scripts/shellIntegration.ps1 @@ -0,0 +1,290 @@ +# --------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# --------------------------------------------------------------------------------------------- + +# Prevent installing more than once per session +if ((Test-Path variable:global:__VSCodeState) -and $null -ne $Global:__VSCodeState.OriginalPrompt) { + return; +} + +# Disable shell integration when the language mode is restricted +if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") { + return; +} + +$Global:__VSCodeState = @{ + OriginalPrompt = $function:Prompt + LastHistoryId = -1 + IsInExecution = $false + EnvVarsToReport = @() + Nonce = $null + IsStable = $null + IsA11yMode = $null + IsWindows10 = $false +} + +# Store the nonce in a regular variable and unset the environment variable. It's by design that +# anything that can execute PowerShell code can read the nonce, as it's basically impossible to hide +# in PowerShell. The most important thing is getting it out of the environment. +$Global:__VSCodeState.Nonce = $env:VSCODE_NONCE +$env:VSCODE_NONCE = $null + +$Global:__VSCodeState.IsStable = $env:VSCODE_STABLE +$env:VSCODE_STABLE = $null + +$Global:__VSCodeState.IsA11yMode = $env:VSCODE_A11Y_MODE +$env:VSCODE_A11Y_MODE = $null + +$__vscode_shell_env_reporting = $env:VSCODE_SHELL_ENV_REPORTING +$env:VSCODE_SHELL_ENV_REPORTING = $null +if ($__vscode_shell_env_reporting) { + $Global:__VSCodeState.EnvVarsToReport = $__vscode_shell_env_reporting.Split(',') +} +Remove-Variable -Name __vscode_shell_env_reporting -ErrorAction SilentlyContinue + +$osVersion = [System.Environment]::OSVersion.Version +$Global:__VSCodeState.IsWindows10 = $IsWindows -and $osVersion.Major -eq 10 -and $osVersion.Minor -eq 0 -and $osVersion.Build -lt 22000 +Remove-Variable -Name osVersion -ErrorAction SilentlyContinue + +if ($env:VSCODE_ENV_REPLACE) { + $Split = $env:VSCODE_ENV_REPLACE.Split(":") + foreach ($Item in $Split) { + $Inner = $Item.Split('=', 2) + [Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':')) + } + $env:VSCODE_ENV_REPLACE = $null +} +if ($env:VSCODE_ENV_PREPEND) { + $Split = $env:VSCODE_ENV_PREPEND.Split(":") + foreach ($Item in $Split) { + $Inner = $Item.Split('=', 2) + [Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':') + [Environment]::GetEnvironmentVariable($Inner[0])) + } + $env:VSCODE_ENV_PREPEND = $null +} +if ($env:VSCODE_ENV_APPEND) { + $Split = $env:VSCODE_ENV_APPEND.Split(":") + foreach ($Item in $Split) { + $Inner = $Item.Split('=', 2) + [Environment]::SetEnvironmentVariable($Inner[0], [Environment]::GetEnvironmentVariable($Inner[0]) + $Inner[1].Replace('\x3a', ':')) + } + $env:VSCODE_ENV_APPEND = $null +} + +# Register Python shell activate hooks +# Prevent multiple activation with guard +if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { + $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD = '1' + if ($env:VSCODE_PYTHON_PWSH_ACTIVATE -and $env:TERM_PROGRAM -eq 'vscode') { + $activateScript = $env:VSCODE_PYTHON_PWSH_ACTIVATE + + try { + Invoke-Expression $activateScript + $Global:__VSCodeState.OriginalPrompt = $function:Prompt + } + catch { + $activationError = $_ + Write-Host "`e[0m`e[7m * `e[0;103m VS Code Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" + } + } + # Remove any leftover Python activation env vars. + Get-ChildItem Env:VSCODE_PYTHON_*_ACTIVATE | Remove-Item -ErrorAction SilentlyContinue +} + +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, "[$([char]0x00)-$([char]0x1f)\\\n;]", { param($match) + # Encode the (ascii) matches as `\x` + -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 + $Result = "" + # Skip finishing the command if the first command has not yet started or an execution has not + # yet begun + if ($Global:__VSCodeState.LastHistoryId -ne -1 -and ($Global:__VSCodeState.HasPSReadLine -eq $false -or $Global:__VSCodeState.IsInExecution -eq $true)) { + $Global:__VSCodeState.IsInExecution = $false + if ($LastHistoryEntry.Id -eq $Global:__VSCodeState.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;D`a" + } + else { + # Command finished exit code + # OSC 633 ; D [; ] 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 ; = ST + $Result += if ($pwd.Provider.Name -eq 'FileSystem') { "$([char]0x1b)]633;P;Cwd=$(__VSCode-Escape-Value $pwd.ProviderPath)`a" } + + # Send current environment variables as JSON + # OSC 633 ; EnvJson ; ; + if ($Global:__VSCodeState.EnvVarsToReport.Count -gt 0) { + $envMap = @{} + foreach ($varName in $Global:__VSCodeState.EnvVarsToReport) { + if (Test-Path "env:$varName") { + $envMap[$varName] = (Get-Item "env:$varName").Value + } + } + $envJson = $envMap | ConvertTo-Json -Compress + $Result += "$([char]0x1b)]633;EnvJson;$(__VSCode-Escape-Value $envJson);$($Global:__VSCodeState.Nonce)`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 + $OriginalPrompt += $Global:__VSCodeState.OriginalPrompt.Invoke() + $Result += $OriginalPrompt + + # Prompt + # OSC 633 ; = ST + if ($Global:__VSCodeState.IsStable -eq "0") { + $Result += "$([char]0x1b)]633;P;Prompt=$(__VSCode-Escape-Value $OriginalPrompt)`a" + } + + # Write command started + $Result += "$([char]0x1b)]633;B`a" + $Global:__VSCodeState.LastHistoryId = $LastHistoryEntry.Id + return $Result +} + +# Report prompt type +if ($env:STARSHIP_SESSION_KEY) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=starship`a") +} +elseif ($env:POSH_SESSION_ID) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=oh-my-posh`a") +} +elseif ((Test-Path variable:global:GitPromptSettings) -and $Global:GitPromptSettings) { + [Console]::Write("$([char]0x1b)]633;P;PromptType=posh-git`a") +} + +if ($Global:__VSCodeState.IsA11yMode -eq "1") { + # Check if the loaded PSReadLine already supports EnableScreenReaderMode + $hasScreenReaderParam = (Get-Module -Name PSReadLine) -and (Get-Command Set-PSReadLineOption).Parameters.ContainsKey('EnableScreenReaderMode') + + if (-not $hasScreenReaderParam -and $PSVersionTable.PSVersion -ge "7.0") { + # The loaded PSReadLine lacks EnableScreenReaderMode (only available in 2.4.4-beta4+). + # PowerShell 7.0+ skips autoloading PSReadLine when the OS reports a screen reader active. + # When only VS Code's accessibility mode is enabled (no OS screen reader), + # it's still loaded and must be removed to load our bundled copy. + # Skip this on Windows PowerShell 5.1 where removing the built-in PSReadLine 2.0.0 + # and replacing it can cause input handling issues (e.g. repeated Enter key presses). + if (Get-Module -Name PSReadLine) { + Remove-Module PSReadLine -Force + } + + # Import VS Code's bundled PSReadLine 2.4.3 which has EnableScreenReaderMode + $specialPsrlPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'psreadline' + if (Test-Path $specialPsrlPath) { + Import-Module $specialPsrlPath + } + + $hasScreenReaderParam = (Get-Module -Name PSReadLine) -and (Get-Command Set-PSReadLineOption).Parameters.ContainsKey('EnableScreenReaderMode') + } + + if ($hasScreenReaderParam) { + Set-PSReadLineOption -EnableScreenReaderMode + } +} + +# Only send the command executed sequence when PSReadLine is loaded, if not shell integration should +# still work thanks to the command line sequence +$Global:__VSCodeState.HasPSReadLine = $false +if (Get-Module -Name PSReadLine) { + $Global:__VSCodeState.HasPSReadLine = $true + [Console]::Write("$([char]0x1b)]633;P;HasRichCommandDetection=True`a") + + $Global:__VSCodeState.OriginalPSConsoleHostReadLine = $function:PSConsoleHostReadLine + function Global:PSConsoleHostReadLine { + $CommandLine = $Global:__VSCodeState.OriginalPSConsoleHostReadLine.Invoke() + $Global:__VSCodeState.IsInExecution = $true + + # Command line + # OSC 633 ; E [; [; ]] ST + $Result = "$([char]0x1b)]633;E;" + $Result += $(__VSCode-Escape-Value $CommandLine) + # Only send the nonce if the OS is not Windows 10 as it seems to echo to the terminal + # sometimes + if ($Global:__VSCodeState.IsWindows10 -eq $false) { + $Result += ";$($Global:__VSCodeState.Nonce)" + } + $Result += "`a" + + # Command executed + # OSC 633 ; C ST + $Result += "$([char]0x1b)]633;C`a" + + # Write command executed sequence directly to Console to avoid the new line from Write-Host + [Console]::Write($Result) + + $CommandLine + } + + # Set ContinuationPrompt property + $Global:__VSCodeState.ContinuationPrompt = (Get-PSReadLineOption).ContinuationPrompt + if ($Global:__VSCodeState.ContinuationPrompt) { + [Console]::Write("$([char]0x1b)]633;P;ContinuationPrompt=$(__VSCode-Escape-Value $Global:__VSCodeState.ContinuationPrompt)`a") + } +} + +# Set IsWindows property +if ($PSVersionTable.PSVersion -lt "6.0") { + # Windows PowerShell is only available on Windows + [Console]::Write("$([char]0x1b)]633;P;IsWindows=$true`a") +} +else { + [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' +} + +if ($Global:__VSCodeState.HasPSReadLine) { + Set-MappedKeyHandlers + + # Prevent AI-executed commands from polluting shell history + if ($env:VSCODE_PREVENT_SHELL_HISTORY -eq "1") { + Set-PSReadLineOption -AddToHistoryHandler { + param([string]$line) + return $false + } + $env:VSCODE_PREVENT_SHELL_HISTORY = $null + } +} diff --git a/src-tauri/crates/infra/src/config.rs b/src-tauri/crates/infra/src/config.rs index 40b184fd..64d5e33e 100644 --- a/src-tauri/crates/infra/src/config.rs +++ b/src-tauri/crates/infra/src/config.rs @@ -4,6 +4,8 @@ use std::process::Command; use model::error::AppError; pub use model::project::ProjectConfig; +use crate::no_window::silent_command; + pub fn load_project_config( project_folder: &str, ) -> Result { @@ -69,11 +71,11 @@ pub fn execute_scripts(scripts: &[String], cwd: &Path) { fn script_command() -> Command { if cfg!(windows) { - let mut command = Command::new("powershell.exe"); + let mut command = silent_command("powershell.exe"); command.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"]); command } else { - let mut command = Command::new("sh"); + let mut command = silent_command("sh"); command.arg("-c"); command } diff --git a/src-tauri/crates/infra/src/git.rs b/src-tauri/crates/infra/src/git.rs index 4624c114..ce1b3935 100644 --- a/src-tauri/crates/infra/src/git.rs +++ b/src-tauri/crates/infra/src/git.rs @@ -3,7 +3,8 @@ use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Component, Path}; -use std::process::Command; + +use crate::no_window::silent_command; use model::error::AppError; use model::filesystem::FileTreeGitStatusEntry; @@ -40,7 +41,7 @@ pub fn github_avatar_url(folder: &str) -> Option { } fn github_avatar_url_from_api(owner: &str) -> Option { - let output = Command::new("gh") + let output = silent_command("gh") .args(["api", &format!("users/{owner}"), "--jq", ".avatar_url"]) .output(); @@ -58,7 +59,7 @@ fn github_avatar_url_from_api(owner: &str) -> Option { } pub fn remote_url(folder: &str) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["remote", "get-url", "origin"]) .current_dir(folder) .output()?; @@ -91,7 +92,7 @@ pub fn remote_url(folder: &str) -> Result, AppError> { } pub fn init(dir: &Path) -> Result<(), AppError> { - let output = Command::new("git").arg("init").current_dir(dir).output()?; + let output = silent_command("git").arg("init").current_dir(dir).output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -101,7 +102,7 @@ pub fn init(dir: &Path) -> Result<(), AppError> { } pub fn branch(folder: &str) -> Result { - let sym_output = Command::new("git") + let sym_output = silent_command("git") .args(["symbolic-ref", "--short", "HEAD"]) .current_dir(folder) .output()?; @@ -111,7 +112,7 @@ pub fn branch(folder: &str) -> Result { .to_string()); } - let output = Command::new("git") + let output = silent_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(folder) .output()?; @@ -122,7 +123,7 @@ pub fn branch(folder: &str) -> Result { } pub fn status(folder: &str) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args([ "status", "--porcelain=v1", @@ -153,7 +154,7 @@ pub fn diff(folder: &str) -> Result { let (_tmp_dir, tmp_index) = create_temp_index_from_repo(folder)?; // Stage all changes into the temporary index without mutating the real one. - let add_output = Command::new("git") + let add_output = silent_command("git") .args(["add", "-A"]) .current_dir(folder) .env("GIT_INDEX_FILE", &tmp_index) @@ -168,7 +169,7 @@ pub fn diff(folder: &str) -> Result { } // Diff the temporary index (everything staged) against HEAD - let diff_output = Command::new("git") + let diff_output = silent_command("git") .args([ "diff", "--no-color", @@ -203,7 +204,7 @@ pub fn diff(folder: &str) -> Result { pub fn diff_stats(folder: &str) -> Result { let (_tmp_dir, tmp_index) = create_temp_index_from_repo(folder)?; - let add_output = Command::new("git") + let add_output = silent_command("git") .args(["add", "-A"]) .current_dir(folder) .env("GIT_INDEX_FILE", &tmp_index) @@ -217,7 +218,7 @@ pub fn diff_stats(folder: &str) -> Result { )); } - let diff_output = Command::new("git") + let diff_output = silent_command("git") .args([ "diff", "--no-color", @@ -286,7 +287,7 @@ fn create_temp_index_from_repo( fn resolve_git_index_path( folder: &str, ) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["rev-parse", "--git-path", "index"]) .current_dir(folder) .output()?; @@ -311,7 +312,7 @@ fn resolve_git_index_path( } pub fn log(folder: &str, limit: u32) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args([ "log", &format!("-{limit}"), @@ -337,7 +338,7 @@ pub fn log(folder: &str, limit: u32) -> Result, AppError> { pub fn show(folder: &str, commit_hash: &str) -> Result { validate_commit_hash(commit_hash)?; - let output = Command::new("git") + let output = silent_command("git") .args([ "show", "--no-color", @@ -439,7 +440,7 @@ pub fn commit( // Stage the selected paths first so untracked files and deletions can be // committed, then use `--only` so unrelated staged files stay out. - let add_output = Command::new("git") + let add_output = silent_command("git") .arg("add") .arg("-A") .arg("--") @@ -454,7 +455,7 @@ pub fn commit( ))); } - let mut commit_command = Command::new("git"); + let mut commit_command = silent_command("git"); commit_command .arg("commit") .arg("--only") @@ -478,7 +479,7 @@ pub fn commit( ))); } - let rev_parse = Command::new("git") + let rev_parse = silent_command("git") .args(["rev-parse", "HEAD"]) .current_dir(folder) .output()?; @@ -501,7 +502,7 @@ pub fn discard_changes(folder: &str, paths: &[String]) -> Result<(), AppError> { partition_paths_by_tracking(folder, &paths)?; if !tracked_paths.is_empty() { - let restore_output = Command::new("git") + let restore_output = silent_command("git") .args(["restore", "--source=HEAD", "--staged", "--worktree", "--"]) .args(&tracked_paths) .current_dir(folder) @@ -516,7 +517,7 @@ pub fn discard_changes(folder: &str, paths: &[String]) -> Result<(), AppError> { } if !untracked_paths.is_empty() { - let clean_output = Command::new("git") + let clean_output = silent_command("git") .args(["clean", "-f", "--"]) .args(&untracked_paths) .current_dir(folder) @@ -534,7 +535,7 @@ pub fn discard_changes(folder: &str, paths: &[String]) -> Result<(), AppError> { } pub fn ahead_count(folder: &str) -> u32 { - let output = Command::new("git") + let output = silent_command("git") .args(["rev-list", "--count", "@{u}..HEAD"]) .current_dir(folder) .output(); @@ -554,7 +555,7 @@ pub fn branch_unique_commits( ) -> Result, AppError> { let branch_ref = format!("refs/heads/{branch_name}"); let other_refs = refs_except_branch(folder, &branch_ref)?; - let mut command = Command::new("git"); + let mut command = silent_command("git"); command .args(["rev-list", "--reverse", &branch_ref]) .current_dir(folder); @@ -587,7 +588,7 @@ pub fn commit_diff_stats( return Ok(GitDiffStats::default()); } - let output = Command::new("git") + let output = silent_command("git") .args([ "show", "--no-color", @@ -612,7 +613,7 @@ pub fn commit_diff_stats( } pub fn push(folder: &str) -> Result<(), AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["push"]) .current_dir(folder) .output()?; @@ -639,7 +640,7 @@ pub fn pull_request_status( return Ok(None); }; - let output = Command::new("gh") + let output = silent_command("gh") .args([ "pr", "list", @@ -684,7 +685,7 @@ pub fn worktree_add( branch_name: &str, worktree_path: &str, ) -> Result<(), AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["worktree", "add", "-b", branch_name, worktree_path]) .current_dir(project_folder) .output()?; @@ -706,13 +707,13 @@ pub fn worktree_add( // Try to delete the conflicting branch and retry once. if stderr.contains("cannot lock ref") { if let Some(conflicting) = extract_conflicting_ref(&stderr) { - let _ = Command::new("git") + let _ = silent_command("git") .args(["branch", "-D", &conflicting]) .current_dir(project_folder) .output(); // Retry - let retry = Command::new("git") + let retry = silent_command("git") .args(["worktree", "add", "-b", branch_name, worktree_path]) .current_dir(project_folder) .output()?; @@ -732,7 +733,7 @@ pub fn worktree_add( } pub fn worktree_remove(project_folder: &str, worktree_path: &str) { - let output = Command::new("git") + let output = silent_command("git") .args(["worktree", "remove", worktree_path, "--force"]) .current_dir(project_folder) .output(); @@ -750,7 +751,7 @@ pub fn worktree_remove(project_folder: &str, worktree_path: &str) { } pub fn branch_delete(project_folder: &str, branch_name: &str) { - let output = Command::new("git") + let output = silent_command("git") .args(["branch", "-D", branch_name]) .current_dir(project_folder) .output(); @@ -771,7 +772,7 @@ fn refs_except_branch( folder: &str, branch_ref: &str, ) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["for-each-ref", "--format=%(refname)"]) .args(["refs/heads", "refs/remotes", "refs/tags"]) .current_dir(folder) @@ -1112,7 +1113,7 @@ fn partition_paths_by_tracking( folder: &str, paths: &[String], ) -> Result<(Vec, Vec), AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["ls-files", "-z", "--"]) .args(paths) .current_dir(folder) @@ -1234,7 +1235,7 @@ fn read_git_blob_to_cache( } } - let output = Command::new("git") + let output = silent_command("git") .args(["cat-file", "blob", spec]) .current_dir(folder) .output()?; @@ -1261,7 +1262,7 @@ fn get_git_blob_size( folder: &str, spec: &str, ) -> Result, AppError> { - let output = Command::new("git") + let output = silent_command("git") .args(["cat-file", "-s", spec]) .current_dir(folder) .output()?; @@ -1424,7 +1425,6 @@ fn normalize_host(host: &str) -> String { #[cfg(test)] mod tests { use super::*; - use std::process::Command; #[test] fn parse_github_owner_and_repo_with_https_url() { @@ -1802,12 +1802,12 @@ mod tests { let modified = vec![4_u8, 5, 6, 7]; std::fs::write(dir.join("image.bin"), &initial).unwrap(); - Command::new("git") + silent_command("git") .args(["add", "image.bin"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "add image"]) .current_dir(&dir) .output() @@ -1839,30 +1839,30 @@ mod tests { let after = vec![5_u8, 6, 7, 8]; std::fs::write(dir.join("image.bin"), &before).unwrap(); - Command::new("git") + silent_command("git") .args(["add", "image.bin"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "add image"]) .current_dir(&dir) .output() .unwrap(); std::fs::write(dir.join("image.bin"), &after).unwrap(); - Command::new("git") + silent_command("git") .args(["add", "image.bin"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "update image"]) .current_dir(&dir) .output() .unwrap(); - let head = Command::new("git") + let head = silent_command("git") .args(["rev-parse", "HEAD"]) .current_dir(&dir) .output() @@ -1897,12 +1897,12 @@ mod tests { let oversized = vec![0_u8; MAX_BINARY_PREVIEW_BYTES + 1]; std::fs::write(dir.join("large.bin"), oversized).unwrap(); - Command::new("git") + silent_command("git") .args(["add", "large.bin"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "add large image"]) .current_dir(&dir) .output() @@ -1924,17 +1924,17 @@ mod tests { let dir = std::env::temp_dir() .join(format!("git-infra-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); - Command::new("git") + silent_command("git") .args(["init"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["config", "user.email", "test@test.com"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["config", "user.name", "Test"]) .current_dir(&dir) .output() @@ -1949,12 +1949,12 @@ mod tests { msg: &str, ) { std::fs::write(dir.join(filename), content).unwrap(); - Command::new("git") + silent_command("git") .args(["add", filename]) .current_dir(dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", msg]) .current_dir(dir) .output() @@ -1962,7 +1962,7 @@ mod tests { } fn force_color_output(dir: &std::path::Path) { - Command::new("git") + silent_command("git") .args(["config", "color.ui", "always"]) .current_dir(dir) .output() @@ -1970,7 +1970,7 @@ mod tests { } fn force_mnemonic_prefixes(dir: &std::path::Path) { - Command::new("git") + silent_command("git") .args(["config", "diff.mnemonicPrefix", "true"]) .current_dir(dir) .output() @@ -2052,7 +2052,7 @@ mod tests { let dir = create_temp_git_repo(); add_commit(&dir, "hello.txt", "hello world", "Add hello"); - let log_output = Command::new("git") + let log_output = silent_command("git") .args(["log", "-1", "--format=%H"]) .current_dir(&dir) .output() @@ -2074,7 +2074,7 @@ mod tests { force_color_output(&dir); add_commit(&dir, "hello.txt", "hello world", "Add hello"); - let log_output = Command::new("git") + let log_output = silent_command("git") .args(["log", "-1", "--format=%H"]) .current_dir(&dir) .output() @@ -2098,7 +2098,7 @@ mod tests { force_mnemonic_prefixes(&dir); add_commit(&dir, "hello.txt", "hello world", "Add hello"); - let log_output = Command::new("git") + let log_output = silent_command("git") .args(["log", "-1", "--format=%H"]) .current_dir(&dir) .output() @@ -2213,7 +2213,7 @@ mod tests { add_commit(&dir, "a.txt", "hello", "Init"); std::fs::write(dir.join("a.txt"), "hello world").unwrap(); - Command::new("git") + silent_command("git") .args(["add", "a.txt"]) .current_dir(&dir) .output() @@ -2236,7 +2236,7 @@ mod tests { add_commit(&dir, "b.txt", "foo", "Add b"); std::fs::write(dir.join("a.txt"), "hello world").unwrap(); - Command::new("git") + silent_command("git") .args(["add", "a.txt"]) .current_dir(&dir) .output() @@ -2282,24 +2282,24 @@ mod tests { "tracked", ) .unwrap(); - Command::new("git") + silent_command("git") .args(["add", "build/entitlements.mac.plist"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "Add tracked entitlements"]) .current_dir(&dir) .output() .unwrap(); std::fs::write(dir.join(".gitignore"), "build/\n").unwrap(); - Command::new("git") + silent_command("git") .args(["add", ".gitignore"]) .current_dir(&dir) .output() .unwrap(); - Command::new("git") + silent_command("git") .args(["commit", "-m", "Ignore build output"]) .current_dir(&dir) .output() @@ -2319,7 +2319,7 @@ mod tests { fn branch_unique_commits_counts_no_upstream_branch_commits() { let dir = create_temp_git_repo(); add_commit(&dir, "base.txt", "base", "Init"); - Command::new("git") + silent_command("git") .args(["checkout", "-b", "feature/delete-risk"]) .current_dir(&dir) .output() @@ -2346,13 +2346,13 @@ mod tests { fn branch_unique_commits_ignores_commits_kept_by_another_ref() { let dir = create_temp_git_repo(); add_commit(&dir, "base.txt", "base", "Init"); - Command::new("git") + silent_command("git") .args(["checkout", "-b", "feature/delete-risk"]) .current_dir(&dir) .output() .unwrap(); add_commit(&dir, "feature-a.txt", "a", "Feature A"); - Command::new("git") + silent_command("git") .args(["branch", "backup/delete-risk"]) .current_dir(&dir) .output() @@ -2401,7 +2401,7 @@ mod tests { "rename me" ); - let status = Command::new("git") + let status = silent_command("git") .args(["status", "--short"]) .current_dir(&dir) .output() diff --git a/src-tauri/crates/infra/src/lib.rs b/src-tauri/crates/infra/src/lib.rs index 8f6f0119..3629248f 100644 --- a/src-tauri/crates/infra/src/lib.rs +++ b/src-tauri/crates/infra/src/lib.rs @@ -3,6 +3,7 @@ pub mod db; pub mod filesystem; pub mod git; pub mod logger; +pub mod no_window; pub mod pty; pub mod shell_init; pub mod slug; diff --git a/src-tauri/crates/infra/src/no_window.rs b/src-tauri/crates/infra/src/no_window.rs new file mode 100644 index 00000000..1ea0a452 --- /dev/null +++ b/src-tauri/crates/infra/src/no_window.rs @@ -0,0 +1,13 @@ +use std::process::Command; + +/// Create a `Command` that won't open a console window on Windows. +/// On non-Windows platforms this is identical to `Command::new`. +pub fn silent_command(program: &str) -> Command { + let mut command = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + command +} diff --git a/src-tauri/crates/infra/src/pty.rs b/src-tauri/crates/infra/src/pty.rs index b79ddd57..c8a0d740 100644 --- a/src-tauri/crates/infra/src/pty.rs +++ b/src-tauri/crates/infra/src/pty.rs @@ -8,6 +8,8 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use model::error::AppError; +use crate::shell_init::ShellInjection; + pub struct PtySession { pub master: Box, pub writer: Box, @@ -49,7 +51,7 @@ pub fn create_session( cwd: &str, rows: u16, cols: u16, - init_dir: Option<&Path>, + injection: &ShellInjection, helper_url: Option<&str>, helper_bin: Option<&str>, ) -> Result, AppError> { @@ -64,8 +66,13 @@ pub fn create_session( }) .map_err(|e| AppError::PtyError(e.to_string()))?; - let mut cmd = command_builder(shell); + // Build the command with shell-specific args based on injection type + let mut cmd = build_injected_command(shell, injection); + + // Common env vars for all shells cmd.env("TERM", "xterm-256color"); + cmd.env("TERM_PROGRAM", "vscode"); // Makes VS Code's shell integration scripts work + cmd.env("VSCODE_INJECTION", "1"); // Tells scripts they were injected (not manually installed) // Inject helper env vars for CLI sidecar communication if let Some(url) = helper_url { @@ -76,12 +83,13 @@ pub fn create_session( } cmd.env("_2CODE_SESSION_ID", session_id); - // Inject shell init via ZDOTDIR - if let Some(dir) = init_dir { - if let Ok(original) = std::env::var("ZDOTDIR") { - cmd.env("_2CODE_ORIG_ZDOTDIR", &original); + // Apply shell-specific env vars + match injection { + ShellInjection::Zsh { zdotdir, user_zdotdir } => { + cmd.env("ZDOTDIR", zdotdir.to_string_lossy().as_ref()); + cmd.env("USER_ZDOTDIR", user_zdotdir.as_str()); } - cmd.env("ZDOTDIR", dir.to_string_lossy().as_ref()); + _ => {} } if !cwd.is_empty() { @@ -120,17 +128,111 @@ pub fn create_session( Ok(reader) } -fn command_builder(shell: &str) -> CommandBuilder { - let mut parts = shell.split_whitespace(); - if let Some(program) = parts.next() { - let mut command = CommandBuilder::new(program); - for arg in parts { - command.arg(arg); +/// Parse a shell command string into (program, args), handling paths with spaces. +/// e.g. `"C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe -NoLogo -NoProfile"` +/// → `("C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", ["-NoLogo", "-NoProfile"])` +fn parse_shell_command(shell: &str) -> (String, Vec) { + let shell = shell.trim(); + let parts: Vec<&str> = shell.split_whitespace().collect(); + if parts.is_empty() { + return (shell.to_string(), vec![]); + } + let first = parts[0]; + let looks_like_path = + first.len() >= 2 && first.as_bytes()[1] == b':' // C: D: etc + || first.contains('/') || first.contains('\\'); + + if !looks_like_path || parts.len() == 1 { + return (first.to_string(), parts[1..].iter().map(|s| s.to_string()).collect()); + } + + // Reconstruct the path by joining tokens until we hit one ending with a + // known extension or the first arg starting with '-'. + let mut end_idx = 1; + while end_idx < parts.len() { + let candidate = parts[..=end_idx].join(" "); + if candidate.to_lowercase().ends_with(".exe") + || candidate.to_lowercase().ends_with(".bat") + || candidate.to_lowercase().ends_with(".cmd") + || candidate.to_lowercase().ends_with(".ps1") + || candidate.to_lowercase().ends_with(".sh") + || Path::new(&candidate).exists() + { + end_idx += 1; + break; } - return command; + end_idx += 1; + if end_idx > 6 { break; } + } + if end_idx > parts.len() { + end_idx = parts.iter().position(|p| p.starts_with('-')).unwrap_or(parts.len()); + if end_idx == 0 { end_idx = 1; } } + let program = parts[..end_idx].join(" "); + let args = parts[end_idx..].iter().map(|s| s.to_string()).collect(); + (program, args) +} - CommandBuilder::new(shell) +/// Build a CommandBuilder with the right executable and args for the given injection type. +fn build_injected_command(shell: &str, injection: &ShellInjection) -> CommandBuilder { + // Parse the shell command, handling paths with spaces like + // "C:\Program Files\PowerShell\7-preview\pwsh.exe -NoLogo -NoProfile" + let (program, existing_args) = parse_shell_command(shell); + + match injection { + ShellInjection::Bash { init_file } => { + // bash --init-file /path/to/shellIntegration-bash.sh + let mut cmd = CommandBuilder::new(program); + cmd.arg("--init-file"); + cmd.arg(init_file.to_string_lossy().as_ref()); + cmd + } + ShellInjection::Zsh { .. } => { + // zsh -i (ZDOTDIR is set via env var, scripts are in the dir) + let mut cmd = CommandBuilder::new(program); + cmd.arg("-i"); + cmd + } + ShellInjection::Fish { init_script } => { + // fish --init-command 'source "/path/to/shellIntegration.fish"' + let mut cmd = CommandBuilder::new(program); + for arg in &existing_args { + cmd.arg(arg.as_str()); + } + cmd.arg("--init-command"); + cmd.arg(format!( + "source \"{}\"", + init_script.to_string_lossy() + )); + cmd + } + ShellInjection::Pwsh { init_script } => { + // pwsh -noexit -command '. "/path/to/shellIntegration.ps1"' + let mut cmd = CommandBuilder::new(program); + // Keep existing args like -NoLogo but filter out conflicting ones + for arg in &existing_args { + let lower = arg.to_lowercase(); + if lower != "-noexit" && lower != "-command" && lower != "-c" { + cmd.arg(arg.as_str()); + } + } + cmd.arg("-noexit"); + cmd.arg("-command"); + cmd.arg(format!( + ". \"{}\"", + init_script.to_string_lossy() + )); + cmd + } + ShellInjection::None => { + // Unknown shell — just run as-is with original args + let mut cmd = CommandBuilder::new(program); + for arg in &existing_args { + cmd.arg(arg.as_str()); + } + cmd + } + } } pub fn write_to_pty( @@ -267,4 +369,34 @@ mod tests { let tracker = create_thread_tracker(); join_all_read_threads(&tracker); // should not panic } + + #[test] + fn parse_shell_simple() { + let (prog, args) = parse_shell_command("bash"); + assert_eq!(prog, "bash"); + assert!(args.is_empty()); + } + + #[test] + fn parse_shell_with_args() { + let (prog, args) = parse_shell_command("powershell.exe -NoLogo -NoProfile"); + assert_eq!(prog, "powershell.exe"); + assert_eq!(args, vec!["-NoLogo".to_string(), "-NoProfile".to_string()]); + } + + #[test] + fn parse_shell_path_with_spaces() { + let (prog, args) = parse_shell_command( + r"C:\Program Files\PowerShell\7-preview\pwsh.exe -NoLogo -NoProfile", + ); + assert_eq!(prog, r"C:\Program Files\PowerShell\7-preview\pwsh.exe"); + assert_eq!(args, vec!["-NoLogo".to_string(), "-NoProfile".to_string()]); + } + + #[test] + fn parse_shell_git_bash() { + let (prog, args) = parse_shell_command(r"C:\Program Files\Git\bin\bash.exe"); + assert_eq!(prog, r"C:\Program Files\Git\bin\bash.exe"); + assert!(args.is_empty()); + } } diff --git a/src-tauri/crates/infra/src/shell_init.rs b/src-tauri/crates/infra/src/shell_init.rs index 6824bbcf..0bcffb33 100644 --- a/src-tauri/crates/infra/src/shell_init.rs +++ b/src-tauri/crates/infra/src/shell_init.rs @@ -1,124 +1,256 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use model::error::AppError; -const DEFAULT_INIT: &str = include_str!("../scripts/default_init.sh"); +// 2code's own init scripts. +// `common` is POSIX-sh compatible — works in bash and zsh (notify hook, claude wrapper, PATH). +// `zsh` is zsh-only (zle keybindings, unsetopt). +const DEFAULT_INIT_COMMON: &str = include_str!("../scripts/default_init_common.sh"); +const DEFAULT_INIT_ZSH: &str = include_str!("../scripts/default_init_zsh.sh"); -/// Create a temp directory with `.zshenv` for shell init injection. -/// Returns the path to the temp directory (to be set as ZDOTDIR). -pub fn prepare_init_dir( - session_id: &str, - project_init_scripts: &[String], -) -> Result { - let dir = std::env::temp_dir().join(format!("2code-init-{session_id}")); - std::fs::create_dir_all(&dir)?; +// VS Code shell integration scripts (MIT licensed, from microsoft/vscode) +const VSC_BASH: &str = include_str!("../scripts/shellIntegration-bash.sh"); +const VSC_ZSH_RC: &str = include_str!("../scripts/shellIntegration-rc.zsh"); +const VSC_ZSH_ENV: &str = include_str!("../scripts/shellIntegration-env.zsh"); +const VSC_ZSH_PROFILE: &str = include_str!("../scripts/shellIntegration-profile.zsh"); +const VSC_ZSH_LOGIN: &str = include_str!("../scripts/shellIntegration-login.zsh"); +const VSC_FISH: &str = include_str!("../scripts/shellIntegration.fish"); +const VSC_PWSH: &str = include_str!("../scripts/shellIntegration.ps1"); - let project_init = project_init_scripts.join("\n"); - let zshenv = build_zshenv(DEFAULT_INIT, &project_init); - std::fs::write(dir.join(".zshenv"), zshenv)?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellType { + Zsh, + Bash, + Fish, + Pwsh, + Unknown, +} + +/// Detect shell type from the shell command string. +pub fn detect_shell_type(shell_cmd: &str) -> ShellType { + // Take the basename of the first token (the executable) + let exe = shell_cmd + .split_whitespace() + .next() + .unwrap_or(shell_cmd); + let basename = Path::new(exe) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(exe) + .to_lowercase(); + + match basename.as_str() { + "zsh" => ShellType::Zsh, + "bash" | "sh" => ShellType::Bash, + "fish" => ShellType::Fish, + "pwsh" | "powershell" => ShellType::Pwsh, + _ => ShellType::Unknown, + } +} + +/// Result of preparing shell injection — tells the PTY layer what args/env to set. +#[derive(Debug)] +pub enum ShellInjection { + /// Zsh: set ZDOTDIR to this dir (contains .zshrc, .zshenv, .zprofile, .zlogin) + Zsh { + zdotdir: PathBuf, + user_zdotdir: String, + }, + /// Bash: pass `--init-file ` to bash + Bash { + init_file: PathBuf, + }, + /// Fish: pass `--init-command 'source ""'` + Fish { + init_script: PathBuf, + }, + /// Pwsh: pass `-noexit -command '. ""'` + Pwsh { + init_script: PathBuf, + }, + /// Unknown shell — no injection, just run as-is + None, +} + +/// Prepare shell integration injection for the given shell type. +/// This writes the necessary scripts to a temp directory and returns +/// an injection descriptor telling the PTY layer what to do. +pub fn prepare_shell_injection( + session_id: &str, + shell_type: ShellType, + project_init_scripts: &[String], +) -> Result { + let dir = std::env::temp_dir().join(format!("2code-init-{session_id}")); + std::fs::create_dir_all(&dir)?; + + match shell_type { + ShellType::Zsh => prepare_zsh(&dir, project_init_scripts), + ShellType::Bash => prepare_bash(&dir, project_init_scripts), + ShellType::Fish => prepare_fish(&dir, project_init_scripts), + ShellType::Pwsh => prepare_pwsh(&dir, project_init_scripts), + ShellType::Unknown => Ok(ShellInjection::None), + } +} + +/// Zsh: VS Code's approach — set ZDOTDIR to a temp dir containing the integration scripts. +/// The .zshenv sources the user's real .zshenv, .zshrc sources the user's real .zshrc, +/// and both inject shell integration hooks. +fn prepare_zsh(dir: &Path, project_init_scripts: &[String]) -> Result { + // Write VS Code's zsh scripts into the ZDOTDIR + std::fs::write(dir.join(".zshenv"), VSC_ZSH_ENV)?; + std::fs::write(dir.join(".zprofile"), VSC_ZSH_PROFILE)?; + std::fs::write(dir.join(".zlogin"), VSC_ZSH_LOGIN)?; + + // For .zshrc: append 2code's own init (common: claude wrapper + PATH; zsh: keybindings) + // and project scripts after VS Code's shell integration. + let project_init = project_init_scripts.join("\n"); + let zshrc = format!( + "{vsc_rc}\n\n# === 2code common init ===\n{common}\n\n# === 2code zsh init ===\n{zsh_only}\n\n# === 2code project init ===\n{project_init}\n", + vsc_rc = VSC_ZSH_RC, + common = DEFAULT_INIT_COMMON.trim_end(), + zsh_only = DEFAULT_INIT_ZSH.trim_end(), + project_init = project_init.trim_end(), + ); + std::fs::write(dir.join(".zshrc"), zshrc)?; + + let user_zdotdir = std::env::var("ZDOTDIR") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_else(|_| "~".to_string()); + + Ok(ShellInjection::Zsh { + zdotdir: dir.to_path_buf(), + user_zdotdir, + }) +} + +/// Bash: VS Code's approach — use `--init-file` to point to the integration script. +/// The script itself sources ~/.bashrc when VSCODE_INJECTION=1. +fn prepare_bash(dir: &Path, project_init_scripts: &[String]) -> Result { + // Write VS Code's bash script, then append 2code's common init + project init. + // (zsh-only parts like zle keybindings are not included.) + let project_init = project_init_scripts.join("\n"); + let script = format!( + "{vsc_bash}\n\n# === 2code common init ===\n{common}\n\n# === 2code project init ===\n{project_init}\n", + vsc_bash = VSC_BASH, + common = DEFAULT_INIT_COMMON.trim_end(), + project_init = project_init.trim_end(), + ); - Ok(dir) + let init_file = dir.join("shellIntegration-bash.sh"); + std::fs::write(&init_file, script)?; + + Ok(ShellInjection::Bash { init_file }) } -fn build_zshenv(default_init: &str, project_init: &str) -> String { - format!( - r#"# 2code shell init — this file self-cleans after first prompt -# Save init dir for cleanup, restore ZDOTDIR immediately -_2code_init_dir="$ZDOTDIR" -if [[ -n "$_2CODE_ORIG_ZDOTDIR" ]]; then - export ZDOTDIR="$_2CODE_ORIG_ZDOTDIR" - unset _2CODE_ORIG_ZDOTDIR -else - unset ZDOTDIR -fi - -# Source user's .zshenv (ZDOTDIR is already correct) -[[ -f "${{ZDOTDIR:-$HOME}}/.zshenv" ]] && source "${{ZDOTDIR:-$HOME}}/.zshenv" - -# Register one-shot init hook (runs after .zshrc, before first prompt) -_2code_init() {{ - add-zsh-hook -d precmd _2code_init - unfunction _2code_init 2>/dev/null - - # === 2code default init === -{default_init} - - # === 2code project init === -{project_init} - - # Cleanup - command rm -rf "$_2code_init_dir" - unset _2code_init_dir -}} -autoload -Uz add-zsh-hook -add-zsh-hook precmd _2code_init -"#, - default_init = default_init.trim_end(), - project_init = project_init.trim_end(), - ) +/// Fish: VS Code's approach — use `--init-command 'source ""'`. +fn prepare_fish(dir: &Path, project_init_scripts: &[String]) -> Result { + let init_script = dir.join("shellIntegration.fish"); + let project_init = project_init_scripts.join("\n"); + let script = format!( + "{vsc}\n\n# === 2code project init ===\n{project_init}\n", + vsc = VSC_FISH, + project_init = project_init.trim_end(), + ); + std::fs::write(&init_script, script)?; + Ok(ShellInjection::Fish { init_script }) +} + +/// Pwsh: VS Code's approach — use `-noexit -command '. ""'`. +fn prepare_pwsh(dir: &Path, project_init_scripts: &[String]) -> Result { + let init_script = dir.join("shellIntegration.ps1"); + let project_init = project_init_scripts.join("\n"); + let script = format!( + "{vsc}\n\n# === 2code project init ===\n{project_init}\n", + vsc = VSC_PWSH, + project_init = project_init.trim_end(), + ); + std::fs::write(&init_script, script)?; + Ok(ShellInjection::Pwsh { init_script }) } #[cfg(test)] mod tests { - use super::*; - - #[test] - fn prepare_init_dir_creates_zshenv() { - let dir = prepare_init_dir("test-session-1", &[]).unwrap(); - assert!(dir.exists()); - assert!(dir.join(".zshenv").exists()); - - // Cleanup - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn zshenv_contains_default_init() { - let dir = prepare_init_dir("test-session-2", &[]).unwrap(); - let content = std::fs::read_to_string(dir.join(".zshenv")).unwrap(); - - assert!(content.contains("2code default init")); - assert!(content.contains(DEFAULT_INIT.trim_end())); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn zshenv_contains_project_init() { - let scripts = - vec!["echo HELLO".to_string(), "export FOO=bar".to_string()]; - let dir = prepare_init_dir("test-session-3", &scripts).unwrap(); - let content = std::fs::read_to_string(dir.join(".zshenv")).unwrap(); - - assert!(content.contains("echo HELLO")); - assert!(content.contains("export FOO=bar")); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn zshenv_empty_project_init() { - let zshenv = build_zshenv("# default", ""); - assert!(zshenv.contains("# default")); - assert!(zshenv.contains("2code project init")); - } - - #[test] - fn zshenv_restores_zdotdir() { - let zshenv = build_zshenv("", ""); - assert!(zshenv.contains("_2CODE_ORIG_ZDOTDIR")); - assert!(zshenv.contains("unset ZDOTDIR")); - } - - #[test] - fn zshenv_sources_user_zshenv() { - let zshenv = build_zshenv("", ""); - assert!(zshenv.contains(r#"source "${ZDOTDIR:-$HOME}/.zshenv""#)); - } - - #[test] - fn zshenv_self_cleans() { - let zshenv = build_zshenv("", ""); - assert!(zshenv.contains(r#"command rm -rf "$_2code_init_dir""#)); - } + use super::*; + + #[test] + fn detect_zsh() { + assert_eq!(detect_shell_type("/bin/zsh"), ShellType::Zsh); + assert_eq!(detect_shell_type("/usr/bin/zsh"), ShellType::Zsh); + } + + #[test] + fn detect_bash() { + assert_eq!(detect_shell_type("/bin/bash"), ShellType::Bash); + assert_eq!(detect_shell_type("bash"), ShellType::Bash); + } + + #[test] + fn detect_fish() { + assert_eq!(detect_shell_type("/usr/bin/fish"), ShellType::Fish); + } + + #[test] + fn detect_pwsh() { + assert_eq!(detect_shell_type("pwsh"), ShellType::Pwsh); + assert_eq!(detect_shell_type("powershell.exe -NoLogo"), ShellType::Pwsh); + } + + #[test] + fn detect_unknown() { + assert_eq!(detect_shell_type("nushell"), ShellType::Unknown); + } + + #[test] + fn prepare_bash_creates_init_file() { + let inj = prepare_shell_injection("test-bash-1", ShellType::Bash, &[]).unwrap(); + match inj { + ShellInjection::Bash { init_file } => { + assert!(init_file.exists()); + let content = std::fs::read_to_string(&init_file).unwrap(); + assert!(content.contains("VSCODE_SHELL_INTEGRATION")); + assert!(content.contains("2code common init")); + assert!(content.contains("_2CODE_HOME")); + // zsh-only stuff must NOT leak into bash + assert!(!content.contains("bindkey")); + assert!(!content.contains("unsetopt")); + // cleanup + std::fs::remove_dir_all(init_file.parent().unwrap()).ok(); + } + _ => panic!("Expected Bash injection"), + } + } + + #[test] + fn prepare_zsh_creates_all_dotfiles() { + let inj = prepare_shell_injection("test-zsh-1", ShellType::Zsh, &["echo HELLO".to_string()]).unwrap(); + match inj { + ShellInjection::Zsh { zdotdir, .. } => { + assert!(zdotdir.join(".zshenv").exists()); + assert!(zdotdir.join(".zshrc").exists()); + assert!(zdotdir.join(".zprofile").exists()); + assert!(zdotdir.join(".zlogin").exists()); + let rc = std::fs::read_to_string(zdotdir.join(".zshrc")).unwrap(); + assert!(rc.contains("VSCODE_SHELL_INTEGRATION")); + assert!(rc.contains("echo HELLO")); + assert!(rc.contains("2code common init")); + assert!(rc.contains("2code zsh init")); + assert!(rc.contains("_2CODE_HOME")); // common + assert!(rc.contains("bindkey '^J'")); // zsh-only + std::fs::remove_dir_all(&zdotdir).ok(); + } + _ => panic!("Expected Zsh injection"), + } + } + + #[test] + fn prepare_fish_creates_script() { + let inj = prepare_shell_injection("test-fish-1", ShellType::Fish, &[]).unwrap(); + match inj { + ShellInjection::Fish { init_script } => { + assert!(init_script.exists()); + std::fs::remove_dir_all(init_script.parent().unwrap()).ok(); + } + _ => panic!("Expected Fish injection"), + } + } } diff --git a/src-tauri/crates/service/src/pty.rs b/src-tauri/crates/service/src/pty.rs index 39a1b039..211d2f89 100644 --- a/src-tauri/crates/service/src/pty.rs +++ b/src-tauri/crates/service/src/pty.rs @@ -268,12 +268,20 @@ pub fn create_session( // 2. Generate session ID (needed for init dir name) let session_id = uuid::Uuid::new_v4().to_string(); - // 3. Prepare shell init directory (graceful degradation on failure) - let init_dir = - infra::shell_init::prepare_init_dir(&session_id, &project_init_scripts); - if let Err(ref e) = init_dir { - tracing::warn!(target: "pty", "Failed to prepare init dir: {e}"); - } + // 3. Detect shell type and prepare injection + let shell_type = infra::shell_init::detect_shell_type(&config.shell); + let injection = infra::shell_init::prepare_shell_injection( + &session_id, + shell_type, + &project_init_scripts, + ); + let injection = match injection { + Ok(inj) => inj, + Err(e) => { + tracing::warn!(target: "pty", "Failed to prepare shell injection: {e}"); + infra::shell_init::ShellInjection::None + } + }; // 4. Create PTY session let reader = session::create_session( @@ -283,7 +291,7 @@ pub fn create_session( &config.cwd, config.rows, config.cols, - init_dir.as_deref().ok(), + &injection, ctx.helper_url.as_deref(), ctx.helper_bin.as_deref(), )?; diff --git a/src-tauri/src/handler/shell.rs b/src-tauri/src/handler/shell.rs index 5e01120c..7d324ef9 100644 --- a/src-tauri/src/handler/shell.rs +++ b/src-tauri/src/handler/shell.rs @@ -54,8 +54,34 @@ fn push_existing_shell( } #[cfg(windows)] -fn command_exists(_command: &str) -> bool { - true +fn find_pwsh_path() -> Option { + let candidates = [ + r"C:\Program Files\PowerShell\7\pwsh.exe", + r"C:\Program Files\PowerShell\7-preview\pwsh.exe", + r"C:\Program Files (x86)\PowerShell\7\pwsh.exe", + ]; + for path in &candidates { + if Path::new(path).exists() { + return Some(path.to_string()); + } + } + // Fallback: check PATH via `where` + let output = std::process::Command::new("where") + .arg("pwsh") + .output() + .ok()?; + if output.status.success() { + let first = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !first.is_empty() { + return Some(first); + } + } + None } #[cfg(target_os = "linux")] @@ -73,7 +99,11 @@ fn default_shell_command() -> String { #[cfg(windows)] fn default_shell_command() -> String { - "powershell.exe -NoLogo -NoProfile".to_string() + if let Some(path) = find_pwsh_path() { + format!("{} -NoLogo -NoProfile", path) + } else { + "powershell.exe -NoLogo -NoProfile".to_string() + } } #[cfg(all(not(target_os = "linux"), not(target_os = "macos"), not(windows)))] @@ -117,13 +147,33 @@ fn load_unix_shells(default_command: &str) -> Vec { fn load_windows_shells(default_command: &str) -> Vec { let mut shells = Vec::new(); let mut seen = HashSet::new(); + // PowerShell 7 (pwsh) — preferred over 5.1 + if let Some(pwsh_path) = find_pwsh_path() { + push_shell(&mut shells, &mut seen, format!("{} -NoLogo -NoProfile", pwsh_path), default_command); + } + // Windows PowerShell 5.1 push_shell( &mut shells, &mut seen, "powershell.exe -NoLogo -NoProfile", default_command, ); + // cmd push_shell(&mut shells, &mut seen, "cmd.exe", default_command); + // Git Bash + for path in &[ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ] { + if Path::new(path).exists() { + push_shell(&mut shells, &mut seen, *path, default_command); + } + } + // WSL + let wsl = r"C:\Windows\System32\wsl.exe"; + if Path::new(wsl).exists() { + push_shell(&mut shells, &mut seen, wsl, default_command); + } shells } diff --git a/src-tauri/src/handler/topbar.rs b/src-tauri/src/handler/topbar.rs index 833801aa..44e6f49b 100644 --- a/src-tauri/src/handler/topbar.rs +++ b/src-tauri/src/handler/topbar.rs @@ -1,7 +1,7 @@ #[cfg(target_os = "macos")] use std::path::PathBuf; -use std::process::Command; +use infra::no_window::silent_command; use model::error::AppError; use model::topbar::TopbarApp; @@ -135,7 +135,7 @@ fn open_topbar_app_macos(app_id: &str, path: &str) -> Result<(), AppError> { AppError::NotFound(format!("Top bar app not found: {app_id}")) })?; - let status = Command::new("open") + let status = silent_command("open") .arg("-a") .arg(&app_path) .arg(path) @@ -188,7 +188,7 @@ fn open_topbar_app_windows(app_id: &str, path: &str) -> Result<(), AppError> { AppError::NotFound(format!("Top bar app not found: {app_id}")) })?; - let status = Command::new("powershell.exe") + let status = silent_command("powershell.exe") .args([ "-NoProfile", "-ExecutionPolicy", diff --git a/src-tauri/src/handler/updater.rs b/src-tauri/src/handler/updater.rs index bf689894..73181968 100644 --- a/src-tauri/src/handler/updater.rs +++ b/src-tauri/src/handler/updater.rs @@ -1,6 +1,6 @@ -use std::process::Command; use std::sync::Mutex; +use infra::no_window::silent_command; use model::error::AppError; use serde::{Deserialize, Serialize}; use tauri::{ipc::Channel, AppHandle, State}; @@ -62,7 +62,7 @@ fn update_metadata(update: &Update) -> UpdateMetadata { } fn gh_auth_token() -> Option { - let output = Command::new("gh") + let output = silent_command("gh") .args(["auth", "token"]) .env("GH_PROMPT_DISABLED", "1") .output() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bd28626..e4f701ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_clipboard_manager::init()) .manage(sessions) .manage(read_threads) .manage(flush_senders) diff --git a/src/features/projects/components/ProjectTemplatesEditor.tsx b/src/features/projects/components/ProjectTemplatesEditor.tsx index b2afc09d..d603c4ba 100644 --- a/src/features/projects/components/ProjectTemplatesEditor.tsx +++ b/src/features/projects/components/ProjectTemplatesEditor.tsx @@ -27,14 +27,17 @@ export function ProjectTemplatesEditor({ onChange, }: ProjectTemplatesEditorProps) { const [editingId, setEditingId] = useState(null); - const [draft, setDraft] = useState(null); + const [draft, setDraft] = useState( + createEmptyProjectTerminalTemplateDraft, + ); + const [isOpen, setIsOpen] = useState(false); - const isOpen = draft !== null; const isEditing = editingId !== null; function openCreate() { setEditingId(null); setDraft(createEmptyProjectTerminalTemplateDraft()); + setIsOpen(true); } function openEdit(id: string) { @@ -42,15 +45,15 @@ export function ProjectTemplatesEditor({ if (!t) return; setEditingId(id); setDraft({ ...t }); + setIsOpen(true); } function closeDialog() { + setIsOpen(false); setEditingId(null); - setDraft(null); } function handleCommit() { - if (!draft) return; if (editingId) { onChange(templateDrafts.map((t) => (t.id === editingId ? draft : t))); } else { @@ -160,18 +163,16 @@ export function ProjectTemplatesEditor({ )} - {draft ? ( - - ) : null} + ); } diff --git a/src/features/settings/GlobalTerminalTemplatesSettings.tsx b/src/features/settings/GlobalTerminalTemplatesSettings.tsx index 31e759c8..5348627c 100644 --- a/src/features/settings/GlobalTerminalTemplatesSettings.tsx +++ b/src/features/settings/GlobalTerminalTemplatesSettings.tsx @@ -31,14 +31,17 @@ export function GlobalTerminalTemplatesSettings() { }, }); const [editingTemplateId, setEditingTemplateId] = useState(null); - const [draft, setDraft] = useState(null); + const [draft, setDraft] = useState( + createEmptyGlobalTerminalTemplateDraft, + ); + const [isOpen, setIsOpen] = useState(false); - const isOpen = draft !== null; const isEditing = editingTemplateId !== null; function openCreateDialog() { setEditingTemplateId(null); setDraft(createEmptyGlobalTerminalTemplateDraft()); + setIsOpen(true); } function openEditDialog(templateId: string) { @@ -46,15 +49,15 @@ export function GlobalTerminalTemplatesSettings() { if (!template) return; setEditingTemplateId(template.id); setDraft(toGlobalTerminalTemplateDraft(template)); + setIsOpen(true); } function closeDialog() { + setIsOpen(false); setEditingTemplateId(null); - setDraft(null); } async function handleSave() { - if (!draft) return; const [normalizedTemplate] = normalizeGlobalTerminalTemplates([draft]); if (!normalizedTemplate) return; @@ -79,6 +82,12 @@ export function GlobalTerminalTemplatesSettings() { closeDialog(); } + async function removeTemplate(templateId: string) { + await replaceTemplates.mutateAsync( + templates.filter((template) => template.id !== templateId), + ); + } + return ( <> @@ -147,7 +156,7 @@ export function GlobalTerminalTemplatesSettings() { size="sm" colorPalette="red" aria-label={m.deleteTerminalTemplate()} - onClick={() => openEditDialog(template.id)} + onClick={() => void removeTemplate(template.id)} disabled={replaceTemplates.isPending} > @@ -159,18 +168,16 @@ export function GlobalTerminalTemplatesSettings() { )} - {draft ? ( - - ) : null} + ); } diff --git a/src/features/terminal/Terminal.tsx b/src/features/terminal/Terminal.tsx index 6bc31a0f..8241b668 100644 --- a/src/features/terminal/Terminal.tsx +++ b/src/features/terminal/Terminal.tsx @@ -1,5 +1,9 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event"; +import { + readText as readClipboardText, + writeText as writeClipboardText, +} from "@tauri-apps/plugin-clipboard-manager"; import { open } from "@tauri-apps/plugin-shell"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; @@ -203,6 +207,14 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { const action = getTerminalShortcutAction(event); if (!action) return true; + // Ctrl+C with no selection: pass through so xterm sends ^C (SIGINT). + if ( + action.type === "copy-selection-or-interrupt" + && !term.hasSelection() + ) { + return true; + } + event.preventDefault(); event.stopPropagation(); @@ -226,6 +238,25 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { return false; } + if (action.type === "copy-selection-or-interrupt") { + const selection = term.getSelection(); + if (selection) { + void writeClipboardText(selection).catch(() => {}); + } + return false; + } + + if (action.type === "paste-clipboard") { + void readClipboardText() + .then((text) => { + if (text) { + void writeToPty({ sessionId, data: text }); + } + }) + .catch(() => {}); + return false; + } + void writeToPty({ sessionId, data: action.sequence }); return false; }); diff --git a/src/features/terminal/keybindings.test.ts b/src/features/terminal/keybindings.test.ts index 175d1889..e78c1137 100644 --- a/src/features/terminal/keybindings.test.ts +++ b/src/features/terminal/keybindings.test.ts @@ -133,4 +133,37 @@ describe("getTerminalShortcutAction", () => { getTerminalShortcutAction(makeEvent({ ctrlKey: true, key: "l" })), ).toEqual({ type: "clear-screen" }); }); + + it("maps Ctrl+C to copy-or-interrupt on Windows", () => { + expect( + getTerminalShortcutAction( + makeEvent({ ctrlKey: true, key: "c" }), + "Win32", + ), + ).toEqual({ type: "copy-selection-or-interrupt" }); + }); + + it("maps Ctrl+V to paste on Windows", () => { + expect( + getTerminalShortcutAction( + makeEvent({ ctrlKey: true, key: "v" }), + "Win32", + ), + ).toEqual({ type: "paste-clipboard" }); + }); + + it("does not map Ctrl+C/V on macOS (Cmd+C/V is system-handled)", () => { + expect( + getTerminalShortcutAction( + makeEvent({ ctrlKey: true, key: "c" }), + "MacIntel", + ), + ).toBeNull(); + expect( + getTerminalShortcutAction( + makeEvent({ ctrlKey: true, key: "v" }), + "MacIntel", + ), + ).toBeNull(); + }); }); diff --git a/src/features/terminal/keybindings.ts b/src/features/terminal/keybindings.ts index 45c370c5..92e0d3e8 100644 --- a/src/features/terminal/keybindings.ts +++ b/src/features/terminal/keybindings.ts @@ -12,7 +12,9 @@ export type TerminalShortcutAction = | { type: "write-sequence"; sequence: string } | { type: "increase-font-size" } | { type: "decrease-font-size" } - | { type: "clear-screen" }; + | { type: "clear-screen" } + | { type: "copy-selection-or-interrupt" } + | { type: "paste-clipboard" }; function isMacPlatform(platform: string) { return platform.toUpperCase().includes("MAC"); @@ -85,6 +87,23 @@ export function getTerminalShortcutAction( return { type: "clear-screen" }; } + // Windows/Linux clipboard shortcuts. Ctrl+C copies only when a selection + // exists — without a selection it must pass through as SIGINT (^C). + if ( + !isMacPlatform(platform) + && event.ctrlKey + && !event.metaKey + && !event.altKey + && !event.shiftKey + ) { + if (event.key.toLowerCase() === "c") { + return { type: "copy-selection-or-interrupt" }; + } + if (event.key.toLowerCase() === "v") { + return { type: "paste-clipboard" }; + } + } + return null; }