From 9d12e9d4205be7622dc0a4e5178258fc6452fd11 Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:46:42 +0200 Subject: [PATCH] feat: windows support (#1010) * wip: windows support * feat: windows support * feat: add windows target to CI * chore: generate completions & manpage * Update src/util.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: cleanup & doc * chore: generate completions & manpage * chore: generate dist * fix: reduplicate default test * chore: regate tmux * chore: remove useless test-utils feature * fix(windows): ignore dirs in default_command * docs: update shell docs for windows * chore: generate completions & manpage * chore(justfile): do not ignore failed tests * fix: upload correct junit after profile change * fix: always execute exit commands * fix: windows-specific ctrl-c handling * chore: misc docs & other updates Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: generate completions & manpage * chore: include license in MSI installer --------- Co-authored-by: Skim bot Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Your Name --- .config/nextest.toml | 11 +- .envrc | 2 +- .githooks/pre-commit | 2 +- .github/CONTRIBUTING.md | 49 +++++++- .github/workflows/release.yml | 34 ++--- .github/workflows/test.yml | 9 +- .gitignore | 5 +- AGENTS.md | 14 +-- Cargo.toml | 12 +- README.md | 31 ++++- Vagrantfile | 121 ++++++++++++++++++ dist-workspace.toml | 10 +- flake.nix | 64 +++++----- justfile | 2 +- man/man1/sk.1 | 11 +- src/bin/main.rs | 5 +- src/helper/item_reader.rs | 6 +- src/lib.rs | 20 +++ src/manpage.rs | 4 +- src/options.rs | 5 +- src/skim.rs | 3 +- src/tui/app.rs | 12 +- src/tui/backend.rs | 63 +++++----- src/tui/mod.rs | 2 + src/tui/preview.rs | 16 ++- src/tui/util.rs | 19 ++- src/tui/windows.rs | 59 +++++++++ src/util.rs | 21 +++- test.dockerfile | 2 +- tests/ansi.rs | 5 + tests/common/insta.rs | 4 +- tests/common/mod.rs | 18 ++- tests/defaults.rs | 10 ++ tests/highlighting.rs | 1 + tests/history.rs | 1 + tests/listen.rs | 3 + tests/options.rs | 70 +++++++++++ tests/preview.rs | 5 + tests/tmux.rs | 1 + wix/main.wxs | 228 ++++++++++++++++++++++++++++++++++ 40 files changed, 803 insertions(+), 157 deletions(-) create mode 100644 Vagrantfile create mode 100644 src/tui/windows.rs create mode 100644 wix/main.wxs diff --git a/.config/nextest.toml b/.config/nextest.toml index 618400c4..a5cbbd12 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -18,15 +18,18 @@ command = [ [profile.default] fail-fast = false -retries = 9 +retries = 2 [[profile.default.scripts]] platform = "cfg(unix)" setup = ["stop-tmux", "start-tmux"] [profile.default.junit] path = "junit.xml" +[profile.ci] +retries = 9 + # Valgrind profile for memory leak detection -# Usage: cargo nextest run --profile valgrind --features test-utils +# Usage: cargo nextest run --profile valgrind # # Note: Valgrind can detect memory leaks but does NOT detect dangling threads. # For thread leak detection, use ThreadSanitizer instead (see below). @@ -42,9 +45,9 @@ run-wrapper = "valgrind" # ThreadSanitizer profile for detecting data races and thread issues # Usage: # 1. First build with sanitizer (rebuilds stdlib and all deps): -# RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests --features test-utils -Zbuild-std --target x86_64-unknown-linux-gnu +# RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu # 2. Then run tests: -# TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --features test-utils --target x86_64-unknown-linux-gnu +# TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --target x86_64-unknown-linux-gnu # # Note: ThreadSanitizer can detect: # - Data races (concurrent unsynchronized access to memory) diff --git a/.envrc b/.envrc index 3550a30f..7a0eceea 100644 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -use flake +use flake .#full diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 06aecba8..0fbf744c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ set -xeuo pipefail cargo fmt --check --all -cargo clippy --all-targets --features test-utils -- -Dwarnings +cargo clippy --all-targets -- -Dwarnings cargo check --no-default-features diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8febde09..ed8c79b3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,20 +1,63 @@ # Contributor Guide +## Development environment + +A [Nix flake](../flake.nix) is provided with opt-in package groups. The default shell contains only the base build tools (`rustup`, `just`); richer environments are available as named shells: + +| Shell | Extra packages | +|---|---| +| `nix develop` | `rustup`, `just` | +| `nix develop .#tests` | + nextest, cargo-insta, cargo-llvm-cov, tmux | +| `nix develop .#utils` | + hyperfine, cargo-edit, cargo-public-api, git-cliff | +| `nix develop .#gungraun` | + valgrind, libclang, binutils | +| `nix develop .#bench` | + uv, matplotlib, requests (for `bench.py`) | +| `nix develop .#vagrant` | + vagrant, rsync (for Windows testing) | +| `nix develop .#full` | everything above | + ## Running tests All tests can be run by using [cargo-nextest](https://nexte.st/), which can be installed using `cargo install cargo-nextest` of following the instructions on the website. You will need `tmux` to run some integration tests. -You can then run `cargo nextest run --release --features test-utils`, which should automatically build a release binary, run the unit tests and the integration tests. +You can then run `cargo nextest run --release`, which should automatically build a release binary, run the unit tests and the integration tests. -Most integration tests use [cargo insta](https://insta.rs). If you need to add some tests or re-review them, you will need to install it, and run tests with `cargo insta test --features test-utils --tests --review`, which will let you review snapshots. +Most integration tests use [cargo insta](https://insta.rs). If you need to add some tests or re-review them, you will need to install it, and run tests with `cargo insta test --tests --review`, which will let you review snapshots. -Note: you can run the tests without `--release`, but expect more flaky tests since the timings will be looser. I would advise testing manually any debug test failure if you have doubts. However, the tests won't run without the `test-utils` feature, used to create test backends. +Note: you can run the tests without `--release`, but expect more flaky tests since the timings will be looser. I would advise testing manually any debug test failure if you have doubts. Note2: A dockerfile is available if you want to run the tests inside docker. There is little to no cache, so the test will need to rebuild most of the application after each change. To use it, build the image with `docker build -f test.dockerfile . -t skim-test` then run it using `docker run --rm -it skim-test`. +## Windows testing + +A [Vagrantfile](../Vagrantfile) is provided to spin up a headless Windows Server 2022 Core VM via KVM/libvirt, letting you test Windows compatibility without a GUI. + +**Host prerequisites (NixOS):** + +```nix +virtualisation.libvirtd.enable = true; +users.users..extraGroups = [ "libvirtd" ]; # log out/in after applying +``` + +**Usage:** + +```sh +nix develop .#vagrant +vagrant up # first boot: ~15-20 min, downloads box + provisions +vagrant ssh # connect to the VM +vagrant halt # stop the VM +vagrant destroy # delete the VM +``` + +Inside the VM the project root is synced to `C:\vagrant`. Re-sync after local changes with `vagrant rsync`. To build: + +```powershell +cd C:\vagrant +cargo build +cargo test +``` + ## Submitting code To avoid using up CI minutes uselessly, make sure that : diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c91c617..5c1e91b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive @@ -64,9 +64,9 @@ jobs: # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh" - name: Cache dist - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: cargo-dist-cache path: ~/.cargo/bin/dist @@ -82,7 +82,7 @@ jobs: cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json @@ -121,7 +121,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive @@ -136,7 +136,7 @@ jobs: run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ @@ -163,7 +163,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | @@ -180,19 +180,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ @@ -210,7 +210,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: artifacts-build-global path: | @@ -230,19 +230,19 @@ jobs: outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: artifacts-* path: target/distrib/ @@ -255,14 +255,14 @@ jobs: cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: pattern: artifacts-* path: artifacts @@ -310,7 +310,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: recursive diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1d48416..1627d7d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - build: [linux, macos] + build: [linux, macos, windows] include: - build: linux os: ubuntu-latest @@ -29,6 +29,9 @@ jobs: - build: macos os: macos-latest target: x86_64-apple-darwin + - build: windows + os: windows-latest + target: x86_64-pc-windows-msvc steps: - name: "[linux] Install dependencies" run: | @@ -61,7 +64,7 @@ jobs: run: cargo test --doc - name: Run tests # Do not use `--all-targets` to avoid running benches - run: cargo llvm-cov nextest --release --features test-utils --bins --lib --examples --tests --codecov --output-path codecov.json + run: cargo llvm-cov nextest --release --profile ci --bins --lib --examples --tests --codecov --output-path codecov.json env: LC_ALL: en_US.UTF-8 TERM: xterm-256color @@ -77,7 +80,7 @@ jobs: with: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} - files: target/nextest/default/junit.xml + files: target/nextest/ci/junit.xml fail_ci_if_error: false continue-on-error: true diff --git a/.gitignore b/.gitignore index c547c784..05184e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ flamegraph.svg cachegrind.out.* /scripts/data/ -__pycache__/ \ No newline at end of file +__pycache__/ + +# Vagrant +.vagrant/ diff --git a/AGENTS.md b/AGENTS.md index 4b792906..6b87e976 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,13 +3,13 @@ ## Build/Test/Lint Commands - Build: `cargo build [--release]` - Run: `cargo run [--release]` -- Test (all): `cargo nextest --features test-utils` -- Test (single): `cargo nextest test_name --features test-utils` -- Integration/E2E tests: `cargo nextest --tests --features test-utils` (will need tmux under the hood) -- Memory leak detection: `cargo nextest run --profile valgrind --features test-utils` -- Thread leak/race detection: - 1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests --features test-utils -Zbuild-std --target x86_64-unknown-linux-gnu` - 2. Run: `TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --features test-utils --target x86_64-unknown-linux-gnu` +- Test (all): `cargo nextest` +- Test (single): `cargo nextest test_name` +- Integration/E2E tests: `cargo nextest --tests` (will need tmux under the hood) +- Memory leak detection: `cargo nextest run --profile valgrind` +- Thread leak/race detection: + 1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu` + 2. Run: `TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --target x86_64-unknown-linux-gnu` - Lint: `cargo clippy` - Format: `cargo fmt` (check only: `cargo fmt --check`) diff --git a/Cargo.toml b/Cargo.toml index 00d8be06..9abc4f10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,12 @@ license = "MIT" edition = "2024" rust-version = "1.91" +[package.metadata.wix] +upgrade-guid = "6DDAED06-EBE2-41C4-94F5-CB03F2A4B92E" +path-guid = "05D1A327-19E0-4C82-B5EC-6B28E40C691B" +license = false +eula = false + [profile.release] lto = true codegen-units = 1 @@ -76,15 +82,13 @@ derive_more = { version = "=2.1.1", features = ["debug", "eq"] } portable-pty = "=0.9.0" tui-term = "=0.3.2" kanal = "=0.1.1" -mimalloc = { version = "0.1.48", features = ["v3"] } gungraun = { version = "0.17.2", optional = true } +mimalloc = { version = "0.1.48", features = ["v3"] } [features] default = ["cli"] # Everyting needed to use skim as a cli (argument parsing, shell integrations...) cli = ["dep:clap", "dep:clap_complete", "dep:shlex", "dep:env_logger", "dep:clap_mangen"] -# Enable test utilities (e.g., Tui::new_for_test) -test-utils = [] # Enable gungraun (Valgrind-based) benchmarks gungraun = ["dep:gungraun"] @@ -93,7 +97,7 @@ criterion = { version = "0.8.2", features = ["async_tokio"] } insta = "1.46" [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("test-utils", "gungraun"))', 'cfg(coverage, coverage_nightly)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] } [[bench]] name = "read_and_match" diff --git a/README.md b/README.md index 0b2ef2c2..8d8f0dc2 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Skim provides a single executable called `sk`. Think of it as a smarter alternat * [Some files are not shown in Vim plugin](#some-files-are-not-shown-in-vim-plugin) - [Differences from fzf](#differences-from-fzf) - [How to contribute](#how-to-contribute) + * [Windows compatibility testing](#windows-compatibility-testing) - [Troubleshooting](#troubleshooting) * [No line feed issues with nix, FreeBSD, termux](#no-line-feed-issues-with-nix-freebsd-termux) @@ -199,8 +200,6 @@ To enable these features, source the `key-bindings.{shell}` file and set up comp You can generate shell completions for your preferred shell using the `--shell` flag with one of the supported shells: `bash`, `zsh`, `fish`, `powershell`, or `elvish`: -> **Note:** While PowerShell completions are supported, Windows is not supported for now. - #### Option 1: Source directly in your current shell session ```sh @@ -688,6 +687,34 @@ The goal is to keep `skim` as feature-full as `fzf` is, but the command flags mi [Create new issues](https://github.com/skim-rs/skim/issues/new) if you encounter any bugs or have any ideas. Pull requests are warmly welcomed. +## Windows compatibility testing + +A `Vagrantfile` is included to spin up a headless Windows Server 2022 Core VM for testing +Windows compatibility without needing a GUI. It requires [VirtualBox](https://www.virtualbox.org/) +and [Vagrant](https://www.vagrantup.com/) on your host (`vagrant` is included in the Nix dev +shell via `flake.nix`). + +```sh +vagrant up # First boot: downloads the box and provisions (~15–20 min) +ssh -p 2222 vagrant@localhost # Password: vagrant +``` + +Inside the VM, the project root is mounted at `C:\vagrant`: + +```powershell +cd C:\vagrant +cargo build +cargo test +``` + +Subsequent boots are fast — provisioning only runs once: + +```sh +vagrant halt # Stop the VM +vagrant up # Resume +vagrant destroy # Delete the VM entirely +``` + # Troubleshooting To troubleshoot what's happening, you can set the environment variable `SKIM_LOG` or the flag `--log-level` to either `debug` or even `trace`, and set the environment variable `SKIM_LOG_FILE` or the flag `--log-file` to a path. You can then read those logs during or after the execution to better understand what's happening. Don't hesitate to add those logs to an issue if you need help. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 00000000..5aa8db72 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,121 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : +# +# Windows development VM for testing skim on Windows without a GUI. +# +# Prerequisites (host, NixOS): +# virtualisation.libvirtd.enable = true; +# users.users..extraGroups = [ "libvirtd" ]; # then log out/in +# +# Usage: +# vagrant up # First boot: downloads box, provisions (~15-20 min) +# vagrant up --provision # Re-run provisioning on existing VM +# vagrant ssh # SSH in via vagrant +# vagrant ssh-config # Show IP/key if you prefer a raw ssh command +# vagrant halt # Stop the VM +# vagrant destroy # Delete the VM +# +# Inside the VM: +# cd C:\vagrant # Project root (synced from host, see note below) +# cargo build # Build skim +# cargo test # Run tests +# +# Note: The first `vagrant up` requires internet access on the VM to install +# packages via Chocolatey. + +Vagrant.configure("2") do |config| + # Windows Server 2022 Core — minimal footprint, no desktop GUI. + # Box source: https://app.vagrantup.com/gusztavvargadr/boxes/windows-server-2022-standard-core + config.vm.box = "gusztavvargadr/windows-server-2022-standard-core" + + # Vagrant manages the VM via WinRM (the Windows default). + config.vm.communicator = "winrm" + config.winrm.username = "vagrant" + config.winrm.password = "vagrant" + config.winrm.timeout = 600 # provisioning can take a while on first boot + + config.vm.provider "libvirt" do |lv| + lv.driver = "kvm" + lv.memory = 2048 + lv.cpus = 2 + end + + # rsync is used for the synced folder because libvirt has no native + # shared-folder support for Windows guests. rsync must be present on the + # guest, so the folder is disabled on boot and synced via a post-provision + # trigger (after Chocolatey installs rsync below). + # Re-sync manually at any time with: vagrant rsync + # rsync is used because libvirt has no native shared-folder support for + # Windows guests. On a brand-new VM the very first `vagrant up` will fail + # the rsync step (rsync not yet installed on the guest); run + # `vagrant provision && vagrant rsync` to recover, or just + # `vagrant destroy && vagrant up` after the box is cached locally. + # cwRsync (the Windows rsync from Chocolatey) uses Cygwin paths, so the + # guest path must use /cygdrive/c/... rather than a bare /vagrant. + config.vm.synced_folder ".", "/cygdrive/c/vagrant", type: "rsync", + rsync__exclude: [".git/", "target/", ".jj/"], + rsync__args: ["--verbose", "--archive", "--delete", "--copy-links", "--no-owner", "--no-group"] + + # --------------------------------------------------------------------------- + # Provisioning: configure OpenSSH + install Rust toolchain via Chocolatey. + # The box ships with Win32-OpenSSH already present, so we only configure it. + # Runs once on `vagrant up`; re-run with `vagrant provision`. + # --------------------------------------------------------------------------- + config.vm.provision "shell", privileged: true, inline: <<-'POWERSHELL' + $ErrorActionPreference = "Stop" + + # --- Chocolatey ------------------------------------------------------------- + Write-Host "==> Installing Chocolatey..." + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ( + (New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1') + ) + } + + # --- OpenSSH Server --------------------------------------------------------- + # The box ships with Win32-OpenSSH binaries at C:\Program Files\OpenSSH-Win64. + # Re-run install-sshd.ps1 to register the service (idempotent; safe to + # re-run if the service is already present). + Write-Host "==> Registering and starting sshd..." + & "C:\Program Files\OpenSSH-Win64\install-sshd.ps1" + Set-Service -Name sshd -StartupType Automatic + Start-Service -Name sshd + + # Use PowerShell as the default shell for SSH sessions. + $regPath = "HKLM:\SOFTWARE\OpenSSH" + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + Set-ItemProperty -Path $regPath -Name DefaultShell ` + -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + + # Allow inbound SSH through the Windows firewall. + $rule = Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue + if (-not $rule) { + New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" ` + -DisplayName "OpenSSH Server (sshd)" ` + -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 + } + + # --- Rust, Git, rsync ------------------------------------------------------- + Write-Host "==> Installing Rust, Git, rsync, and MinGW..." + choco install -y rust git rsync mingw + + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + # Add MinGW bin to the persistent system PATH so dlltool.exe is found in + # SSH sessions (which don't run the Chocolatey shim refresh). + $mingwBin = "C:\ProgramData\mingw64\mingw64\bin" + $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + if ($machinePath -notlike "*$mingwBin*") { + [System.Environment]::SetEnvironmentVariable("Path", "$machinePath;$mingwBin", "Machine") + } + + Write-Host "" + Write-Host "==> Provisioning complete." + Write-Host " SSH into the VM: vagrant ssh-config (then ssh to the reported IP)" + Write-Host " Build skim: cd C:\vagrant && cargo build" + POWERSHELL +end diff --git a/dist-workspace.toml b/dist-workspace.toml index 7c327943..41c17bdd 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -4,18 +4,22 @@ members = ["cargo:."] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.30.3" +cargo-dist-version = "0.30.4" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell"] +installers = ["shell", "powershell", "msi"] # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "aarch64-pc-windows-msvc", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] # Path that installers should place binaries in install-path = "CARGO_HOME" # Whether to install an updater program install-updater = false +# Extra static files to include in each App (path relative to this Cargo.toml's dir) include = ["./man/", "./shell/"] +# Plan jobs to run in CI plan-jobs = ["./test"] +# Publish jobs to run in CI publish-jobs = ["./publish"] +# Whether to publish prereleases to package managers publish-prereleases = true diff --git a/flake.nix b/flake.nix index a2a874a3..1b6ed40c 100644 --- a/flake.nix +++ b/flake.nix @@ -7,37 +7,43 @@ inherit (inputs.nixpkgs) lib; systems = lib.systems.flakeExposed; eachSystem = lib.genAttrs systems; - pkgsFor = inputs.nixpkgs.legacyPackages; + pkgsFor = system: import inputs.nixpkgs { + inherit system; + config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "vagrant" ]; + }; in { - devShells = eachSystem (system: { - default = pkgsFor.${system}.mkShellNoCC { - packages = with pkgsFor.${system}; [ - cargo-nextest - cargo-insta - cargo-llvm-cov - cargo-edit - cargo-public-api - git-cliff - libclang - binutils - tmux - rustup - just - hyperfine - uv - valgrind - python313Packages.matplotlib - python313Packages.requests - ]; - shellHook = let - pkgs = pkgsFor.${system}; - in '' - export LIBCLANG_PATH="${pkgs.libclang.lib}/lib" - export LD_LIBRARY_PATH="${pkgs.valgrind.out}/lib:$LD_LIBRARY_PATH" - ''; - }; + devShells = eachSystem (system: let + pkgs = pkgsFor system; + + # --- package groups ------------------------------------------------------- + base = with pkgs; [ rustup just ]; + tests = with pkgs; [ cargo-nextest cargo-insta cargo-llvm-cov tmux ]; + utils = with pkgs; [ hyperfine cargo-edit cargo-public-api git-cliff cargo-dist ]; + gungraun = with pkgs; [ valgrind libclang binutils ]; + bench = with pkgs; [ uv python313Packages.matplotlib python313Packages.requests ]; + vagrantDeps = with pkgs; [ vagrant rsync ]; + + # --- shell hooks (only groups that need env vars) ------------------------- + gungraunHook = '' + export LIBCLANG_PATH="${pkgs.libclang.lib}/lib" + export LD_LIBRARY_PATH="${pkgs.valgrind.out}/lib:$LD_LIBRARY_PATH" + ''; + vagrantHook = '' + export VAGRANT_LIBVIRT_OVMF_CODE="${pkgs.OVMF.fd}/FV/OVMF_CODE.fd" + ''; + + mkShell = packages: shellHook: pkgs.mkShellNoCC { inherit packages shellHook; }; + in { + default = mkShell base ""; + tests = mkShell (base ++ tests) ""; + utils = mkShell (base ++ utils) ""; + gungraun = mkShell (base ++ gungraun) gungraunHook; + bench = mkShell (base ++ bench) ""; + vagrant = mkShell (base ++ vagrantDeps) vagrantHook; + full = mkShell (base ++ tests ++ utils ++ gungraun ++ bench ++ vagrantDeps) + (gungraunHook + vagrantHook); }); - formatter = eachSystem (system: pkgsFor.${system}.nixfmt); + formatter = eachSystem (system: (pkgsFor system).nixfmt); }; } diff --git a/justfile b/justfile index d631c26a..797b65ea 100644 --- a/justfile +++ b/justfile @@ -28,5 +28,5 @@ auto-release: test target="": cargo test --doc - -cargo nextest run --features test-utils {{ target }} + cargo nextest run {{ target }} tmux kill-session -t skim_e2e diff --git a/man/man1/sk.1 b/man/man1/sk.1 index 6da83f62..2e589437 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -235,7 +235,7 @@ Disable mouse \fB\-c\fR, \fB\-\-cmd\fR \fI\fR Command to invoke dynamically in interactive mode -Will be invoked using sh \-c +Will be invoked using sh \-c on unix\-like systems and cmd /c on Windows .TP \fB\-i\fR, \fB\-\-interactive\fR Start skim in interactive mode @@ -450,6 +450,7 @@ Format: `sk \-\-tmux [,SIZE[%]][,SIZE[%]]` Depending on the direction, the order and behavior of the sizes varies: Default: center,50% +Ignored on Windows .SH HISTORY .TP \fB\-\-history\fR \fI\fR @@ -587,8 +588,6 @@ Generate shell completion script Generate completion script for the specified shell: bash, zsh, fish, etc. The output can be directly sourced or saved to a file for automatic loading. Examples: source <(sk \-\-shell bash) (immediate use) sk \-\-shell bash >> ~/.bash_completion (persistent use) Supported shells: bash, zsh, fish, powershell, elvish - -Note: While PowerShell completions are supported, Windows is not supported for now. .br .br @@ -952,7 +951,11 @@ It will expand to the corresponding fields, separated by the `\-\-delimiter|\-d` .SH "ENVIRONMENT VARIABLES" .SS SKIM_DEFAULT_COMMAND -If set, skim will collect items with this command if no input is piped in (defaults to `find .` if not set) + +.br +If set, skim will collect items with this command if no input is piped in. +.br +If not set, defaults to `find .` on unix\-like systems and `dir /s /b /A:\-D` on Windows. .br .SS SKIM_DEFAULT_OPTIONS diff --git a/src/bin/main.rs b/src/bin/main.rs index de277274..60fa5226 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -154,7 +154,10 @@ fn sk_main(mut opts: SkimOptions) -> Result { //------------------------------------------------------------------------------ // output - let Some(result) = (if opts.tmux.is_some() && env::var("TMUX").is_ok() { + let Some(result) = (if opts.tmux.is_some() && env::var("TMUX").is_ok() && cfg!(unix) { + #[cfg(not(unix))] + unreachable!("tmux is ignored on windows"); + #[cfg(unix)] crate::tmux::run_with(&opts) } else { // read from pipe or command diff --git a/src/helper/item_reader.rs b/src/helper/item_reader.rs index cc622041..0cba7aab 100644 --- a/src/helper/item_reader.rs +++ b/src/helper/item_reader.rs @@ -2,7 +2,7 @@ use std::error::Error; use std::io::{BufRead, BufReader}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; @@ -400,8 +400,8 @@ type CommandOutput = (Option, Box); fn get_command_output(cmd: &str, send_error: bool) -> Result> { let (reader, writer) = std::io::pipe()?; - let mut sh = Command::new("sh"); - let command = sh.arg("-c").arg(cmd).stdout(writer.try_clone()?); + let mut command = crate::shell_cmd(cmd); + command.stdout(writer.try_clone()?); if send_error { trace!("redirecting stderr to the output"); command.stderr(writer); diff --git a/src/lib.rs b/src/lib.rs index d5369e6d..3605864b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; use std::any::Any; use std::borrow::Cow; use std::fmt::Display; +use std::process::Command; use std::sync::Arc; use crate::fuzzy_matcher::MatchIndices; @@ -66,6 +67,7 @@ mod skim; mod skim_item; pub mod spinlock; pub mod theme; +#[cfg(unix)] pub mod tmux; pub mod tui; mod util; @@ -75,6 +77,24 @@ pub mod manpage; #[cfg(feature = "cli")] pub mod shell; +#[cfg(unix)] +const SKIM_DEFAULT_COMMAND: &str = "find ."; +#[cfg(windows)] +const SKIM_DEFAULT_COMMAND: &str = "dir /s /b /A:-D"; + +#[cfg(unix)] +fn shell_cmd(cmd: &str) -> Command { + let mut c = Command::new("sh"); + c.arg("-c").arg(cmd); + c +} +#[cfg(windows)] +fn shell_cmd(cmd: &str) -> Command { + let mut c = Command::new("cmd"); + c.arg("/c").arg(cmd); + c +} + //------------------------------------------------------------------------------ /// Trait for downcasting to concrete types from trait objects pub trait AsAny { diff --git a/src/manpage.rs b/src/manpage.rs index e69b580f..d4d1c44e 100644 --- a/src/manpage.rs +++ b/src/manpage.rs @@ -321,7 +321,9 @@ It will expand to the corresponding fields, separated by the `--delimiter|-d` op subsection( &mut custom, "SKIM_DEFAULT_COMMAND", - "If set, skim will collect items with this command if no input is piped in (defaults to `find .` if not set)", + " +If set, skim will collect items with this command if no input is piped in. +If not set, defaults to `find .` on unix-like systems and `dir /s /b /A:-D` on Windows.", ); subsection( &mut custom, diff --git a/src/options.rs b/src/options.rs index c88f9dbe..1efefb14 100644 --- a/src/options.rs +++ b/src/options.rs @@ -312,7 +312,7 @@ pub struct SkimOptions { /// Command to invoke dynamically in interactive mode /// - /// Will be invoked using `sh -c` + /// Will be invoked using `sh -c` on unix-like systems and `cmd /c` on Windows #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))] pub cmd: Option, @@ -731,8 +731,6 @@ pub struct SkimOptions { /// `sk --shell bash >> ~/.bash_completion` (persistent use) /// /// Supported shells: bash, zsh, fish, powershell, elvish - /// - /// Note: While `PowerShell` completions are supported, Windows is not supported for now. #[cfg(feature = "cli")] #[cfg_attr( feature = "cli", @@ -775,6 +773,7 @@ pub struct SkimOptions { /// Depending on the direction, the order and behavior of the sizes varies: /// /// Default: center,50% + /// Ignored on Windows #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0..))] pub tmux: Option, diff --git a/src/skim.rs b/src/skim.rs index bbabdf30..4e6658f9 100644 --- a/src/skim.rs +++ b/src/skim.rs @@ -135,7 +135,6 @@ where /// /// Returns an error if parsing the height or other options fails. pub fn init(options: SkimOptions, source: Option) -> Result { - const SKIM_DEFAULT_COMMAND: &str = "find ."; let height = Size::try_from(options.height.as_str())?; // application state @@ -143,7 +142,7 @@ where let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options)); let reader = Reader::from_options(&options).source(source); let default_command = String::from(match env::var("SKIM_DEFAULT_COMMAND").as_deref() { - Err(_) | Ok("") => SKIM_DEFAULT_COMMAND, + Err(_) | Ok("") => crate::SKIM_DEFAULT_COMMAND, Ok(v) => v, }); let cmd = options.cmd.clone().unwrap_or(default_command); diff --git a/src/tui/app.rs b/src/tui/app.rs index 5998f3b6..9b3cfc60 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,4 +1,4 @@ -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -756,10 +756,9 @@ impl App { self.input.move_to_end(); } Execute(cmd) => { - let mut command = Command::new("sh"); let expanded_cmd = self.expand_cmd(cmd, true); debug!("execute: {expanded_cmd}"); - command.args(["-c", &expanded_cmd]); + let mut command = crate::shell_cmd(&expanded_cmd); let in_raw_mode = crossterm::terminal::is_raw_mode_enabled()?; if in_raw_mode { crossterm::terminal::disable_raw_mode()?; @@ -781,11 +780,10 @@ impl App { return Ok(vec![Event::Redraw]); } ExecuteSilent(cmd) => { - let mut command = Command::new("sh"); let expanded_cmd = self.expand_cmd(cmd, true); - command.args(["-c", &expanded_cmd]); - command.stdout(Stdio::null()); - command.stderr(Stdio::null()); + debug!("execute-silent: {expanded_cmd}"); + let mut command = crate::shell_cmd(&expanded_cmd); + command.stdout(Stdio::null()).stderr(Stdio::null()); let _ = command.spawn(); } First | Top => { diff --git a/src/tui/backend.rs b/src/tui/backend.rs index e50e3cfd..0ca91c92 100644 --- a/src/tui/backend.rs +++ b/src/tui/backend.rs @@ -110,29 +110,6 @@ where }) } - /// Creates a new TUI for testing with a fullscreen viewport. - /// - /// This constructor skips terminal-specific operations (cursor detection, - /// raw mode, scrolling) that don't work with `TestBackend`. Use this when - /// writing snapshot tests or other tests that need to render the UI. - /// - /// # Errors - /// - /// Returns an error if the terminal cannot be initialized with the given backend. - #[cfg(any(test, feature = "test-utils"))] - pub fn new_for_test(backend: B) -> Result { - let event_channel = channel(1024 * 1024); - Ok(Self { - terminal: ratatui::Terminal::new(backend)?, - task: None, - event_rx: event_channel.1, - event_tx: event_channel.0, - tick_rate: TICK_RATE, - cancellation_token: CancellationToken::default(), - is_fullscreen: true, - }) - } - /// Enters the TUI by enabling raw mode and starting event handling /// /// # Errors @@ -140,6 +117,10 @@ where /// Returns an error if enabling raw mode or mouse capture fails. pub fn enter(&mut self) -> Result<()> { crossterm::terminal::enable_raw_mode()?; + // On Windows, install a console ctrl handler so that CTRL_C_EVENT + // performs terminal cleanup instead of killing the process abruptly. + #[cfg(windows)] + super::windows::install_ctrl_c_handler()?; crossterm::execute!(std::io::stderr(), EnableMouseCapture, EnableBracketedPaste)?; if self.is_fullscreen { crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; @@ -155,16 +136,10 @@ where /// Returns an error if disabling raw mode or mouse capture fails. pub fn exit(&mut self) -> Result<()> { self.stop(); - if crossterm::terminal::is_raw_mode_enabled()? { - crossterm::execute!( - std::io::stderr(), - DisableMouseCapture, - DisableBracketedPaste, - LeaveAlternateScreen, - cursor::Show - )?; - crossterm::terminal::disable_raw_mode()?; - } + cleanup_terminal()?; + // Remove our console ctrl handler now that raw mode is off. + #[cfg(windows)] + super::windows::uninstall_ctrl_c_handler(); // When using the inline layout, we want to remove all previous output // -> reset cursor at the top of the drawing area if !self.is_fullscreen { @@ -275,8 +250,28 @@ fn set_panic_hook() { PANIC_HOOK_SET.call_once(|| { let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { - ratatui::restore(); // ignore any errors as we are already failing + let _ = cleanup_terminal(); + #[cfg(windows)] + super::windows::uninstall_ctrl_c_handler(); hook(panic_info); })); }); } + +/// Perform terminal cleanup: disable mouse capture, bracketed paste, +/// leave alternate screen, show cursor, and disable raw mode. +/// +/// This is safe to call from any thread since: +/// - Escape sequences are written atomically to stderr +/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows +pub(crate) fn cleanup_terminal() -> std::io::Result<()> { + crossterm::execute!( + std::io::stderr(), + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen, + cursor::Show + )?; + crossterm::terminal::disable_raw_mode()?; + Ok(()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3bf677aa..d9c5977a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -14,6 +14,8 @@ pub use widget::{SkimRender, SkimWidget}; mod app; mod backend; pub(crate) mod util; +#[cfg(windows)] +mod windows; pub use backend::Tui; /// Event handling and action definitions pub mod event; diff --git a/src/tui/preview.rs b/src/tui/preview.rs index c731d384..fb431d71 100644 --- a/src/tui/preview.rs +++ b/src/tui/preview.rs @@ -11,11 +11,10 @@ use ratatui::{ use tui_term::vt100; use tui_term::widget::PseudoTerminal; -use std::io::Read; -use std::process::Command; use std::sync::mpsc; use std::sync::{Arc, RwLock}; use std::thread::JoinHandle; +use std::{env, io::Read}; use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query}; use super::widget::{SkimRender, SkimWidget}; @@ -304,12 +303,13 @@ impl Preview { ); self.init_pty(); trace!("initialized pty"); + // no PTY on windows, we can keep sh let mut shell_cmd = portable_pty::CommandBuilder::new("/bin/sh"); shell_cmd.env("ROWS", self.rows.to_string()); shell_cmd.env("COLUMNS", self.cols.to_string()); shell_cmd.env("PAGER", ""); shell_cmd.arg("-c"); - if let Ok(cwd) = nix::unistd::getcwd() { + if let Ok(cwd) = env::current_dir() { shell_cmd.cwd(cwd); } shell_cmd.arg(cmd); @@ -392,14 +392,12 @@ impl Preview { })); } else { trace!("spawning preview cmd {cmd}"); - let mut shell_cmd = Command::new("/bin/sh"); + let mut shell_cmd = crate::shell_cmd(cmd); shell_cmd .env("ROWS", self.rows.to_string()) .env("COLUMNS", self.cols.to_string()) - .env("PAGER", "") - .arg("-c") - .arg(cmd); - if let Ok(cwd) = nix::unistd::getcwd() { + .env("PAGER", ""); + if let Ok(cwd) = env::current_dir() { shell_cmd.current_dir(cwd); } @@ -452,7 +450,7 @@ impl Drop for Preview { impl SkimWidget for Preview { fn from_options(options: &SkimOptions, theme: Arc) -> Self { - #[cfg_attr(target_os = "macos", allow(unused_mut))] + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] let mut res = Self { theme, border: options.border, diff --git a/src/tui/util.rs b/src/tui/util.rs index 42bdc037..ce6993f9 100644 --- a/src/tui/util.rs +++ b/src/tui/util.rs @@ -3,10 +3,14 @@ use ratatui::{ style::Style, text::{Line, Span, Text}, }; -use std::fs::OpenOptions; -use std::io::{self, Read, Write}; -use std::os::fd::{AsFd, AsRawFd}; -use std::os::unix::fs::OpenOptionsExt; +use std::io::{self, Write}; +#[cfg(unix)] +use std::{ + fs::OpenOptions, + io::Read, + os::fd::{AsFd, AsRawFd}, + os::unix::fs::OpenOptionsExt as _, +}; use unicode_display_width::is_double_width; // Directly taken from https://docs.rs/unicode-display-width/0.3.0/src/unicode_display_width/lib.rs.html#77-81 @@ -219,6 +223,7 @@ impl Drop for RawMode { } } +#[cfg(unix)] pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> { let _guard = RawMode::new()?; let mut tty = OpenOptions::new() @@ -274,6 +279,12 @@ pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> { Ok((cx, cy)) } +#[cfg(windows)] +pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> { + let _guard = RawMode::new()?; + crossterm::cursor::position() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/windows.rs b/src/tui/windows.rs new file mode 100644 index 00000000..429eb51a --- /dev/null +++ b/src/tui/windows.rs @@ -0,0 +1,59 @@ +//! Windows-specific console control handler. +//! +//! On Windows, pressing Ctrl-C generates a `CTRL_C_EVENT` that by default +//! terminates the process immediately — before any destructors (and therefore +//! terminal cleanup) can run. This module installs a custom handler via +//! `SetConsoleCtrlHandler` that performs terminal cleanup (disable mouse +//! capture, bracketed paste, alternate screen, raw mode) and then exits +//! with code 130 (the conventional "interrupted" exit code). + +use super::backend::cleanup_terminal; + +// Windows console control event constants. +const CTRL_C_EVENT: u32 = 0; +const CTRL_BREAK_EVENT: u32 = 1; +const CTRL_CLOSE_EVENT: u32 = 2; + +unsafe extern "system" { + fn SetConsoleCtrlHandler(handler_routine: Option i32>, add: i32) -> i32; +} + +/// Console ctrl handler callback invoked by Windows on `CTRL_C_EVENT` (and +/// other control events like `CTRL_BREAK_EVENT`, `CTRL_CLOSE_EVENT`, etc.). +/// +/// The handler cleans up the terminal state so that mouse capture, raw mode, +/// and the alternate screen are properly disabled, then exits with code 130. +/// +/// Returning `TRUE` (1) tells Windows that the event has been handled and the +/// default handler (which would call `ExitProcess` without cleanup) should +/// **not** run. +unsafe extern "system" fn ctrl_c_handler(ctrl_type: u32) -> i32 { + match ctrl_type { + CTRL_C_EVENT | CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT => { + let _ = cleanup_terminal(); + // 130 = 128 + SIGINT, the conventional exit code for Ctrl-C. + std::process::exit(130); + } + _ => 0, // Let the next handler deal with it. + } +} + +/// Install our console ctrl handler so that `CTRL_C_EVENT` triggers terminal +/// cleanup instead of an abrupt process termination. +pub(crate) fn install_ctrl_c_handler() -> std::io::Result<()> { + // SAFETY: `SetConsoleCtrlHandler` with a valid handler and `TRUE` is a + // well-defined Windows API call. + unsafe { + if SetConsoleCtrlHandler(Some(ctrl_c_handler), 1) == 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) +} + +/// Remove our console ctrl handler, restoring default behaviour. +pub(crate) fn uninstall_ctrl_c_handler() { + unsafe { + SetConsoleCtrlHandler(Some(ctrl_c_handler), 0); + } +} diff --git a/src/util.rs b/src/util.rs index 2230fa98..77c15de0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -97,8 +97,13 @@ pub fn printf<'a>( current: &Option, query: &str, command_query: &str, - quote_args: bool, + mut quote_args: bool, ) -> String { + // Windows uses different shell quoting conventions (double quotes, caret escaping) + // that are incompatible with the Unix-style single-quote escaping implemented here. + if cfg!(windows) { + quote_args = false; + } let escape_arg = |s: &str, quote: bool| { let mut res = s.replace('\0', "\\0").clone(); if quote && quote_args { @@ -315,7 +320,11 @@ mod test { "cmd query", true ), - "[1] 'item 2' [2] 'item 2' [3] '2' [4] 'item 1' 'item 2' 'item 3' 'item 4' [5] 'query' [6] 'cmd query' [7] 'item 1, item 2, item 3, item 4' [8] '0','0','0','0'" + if cfg!(unix) { + "[1] 'item 2' [2] 'item 2' [3] '2' [4] 'item 1' 'item 2' 'item 3' 'item 4' [5] 'query' [6] 'cmd query' [7] 'item 1, item 2, item 3, item 4' [8] '0','0','0','0'" + } else { + "[1] item 2 [2] item 2 [3] 2 [4] item 1 item 2 item 3 item 4 [5] query [6] cmd query [7] item 1, item 2, item 3, item 4 [8] 0','0','0','0" + } ); } #[test] @@ -331,7 +340,7 @@ mod test { "cq", true ), - "'1' '2'" + if cfg!(unix) { "'1' '2'" } else { "1 2" } ); assert_eq!( printf( @@ -344,7 +353,7 @@ mod test { "cq", true ), - "'1'" + if cfg!(unix) { "'1'" } else { "1" } ); } #[test] @@ -360,7 +369,7 @@ mod test { "cq", true ), - "'{..2}'" + if cfg!(unix) { "'{..2}'" } else { "{..2}" } ); } #[test] @@ -376,7 +385,7 @@ mod test { "cq", true ), - "{} '1'" + if cfg!(unix) { "{} '1'" } else { "{} 1" } ); } } diff --git a/test.dockerfile b/test.dockerfile index 955f4bbe..398a910a 100644 --- a/test.dockerfile +++ b/test.dockerfile @@ -7,4 +7,4 @@ RUN cargo install cargo-nextest COPY . . -CMD ["cargo", "nextest", "run", "--release", "--features", "test-utils"] +CMD ["cargo", "nextest", "run", "--release"] diff --git a/tests/ansi.rs b/tests/ansi.rs index 77be6d66..7d3fe461 100644 --- a/tests/ansi.rs +++ b/tests/ansi.rs @@ -2,8 +2,10 @@ #[macro_use] mod common; +#[cfg(unix)] use common::tmux::Keys::*; +#[cfg(unix)] sk_test!(test_ansi_flag_enabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &["--ansi", "--color", "current_match_bg:1,current_bg:2"], { @capture[0] starts_with(">"); @lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain"))); @@ -17,6 +19,7 @@ sk_test!(test_ansi_flag_enabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\ }); +#[cfg(unix)] sk_test!(test_ansi_flag_disabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &[], { @capture[0] starts_with(">"); @capture[*] contains("plain"); @@ -28,6 +31,7 @@ sk_test!(test_ansi_flag_disabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n @keys Enter; }); +#[cfg(unix)] sk_test!(test_ansi_matching_on_stripped_text, @cmd "echo -e '\\x1b[32mgreen\\x1b[0m text\\n\\x1b[31mred\\x1b[0m text\\nplain text'", &["--ansi"], { @capture[0] starts_with(">"); @lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain"))); @@ -44,6 +48,7 @@ sk_test!(test_ansi_matching_on_stripped_text, @cmd "echo -e '\\x1b[32mgreen\\x1b @lines |l| (l.len() == 3); }); +#[cfg(unix)] sk_test!(test_ansi_flag_no_strip, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &["--ansi", "--no-strip-ansi", "--color", "current_match_bg:1,current_bg:2"], { @capture[0] starts_with(">"); @lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain"))); diff --git a/tests/common/insta.rs b/tests/common/insta.rs index 132913d0..958c4284 100644 --- a/tests/common/insta.rs +++ b/tests/common/insta.rs @@ -7,7 +7,7 @@ use ratatui::backend::TestBackend; use skim::{ Skim, SkimItemReceiver, prelude::*, - tui::{Event, Tui, event::Action}, + tui::{Event, Size, Tui, event::Action}, }; /// A test harness for running skim TUI tests with insta snapshots. @@ -359,7 +359,7 @@ fn enter_sized_with_source( source: Option, ) -> Result { let backend = TestBackend::new(width, height); - let tui = Tui::new_for_test(backend)?; + let tui = Tui::new_with_height_and_backend(backend, Size::Percent(100))?; let mut skim = Skim::::init(options, source)?; skim.init_tui_with(tui); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 53fbbd2d..2589f93f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,15 +1,25 @@ #[macro_use] pub mod insta; #[macro_use] +#[cfg(unix)] pub mod tmux; -#[cfg(all(debug_assertions, coverage))] +#[cfg(all(unix, debug_assertions, coverage))] pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/llvm-cov-target/debug/sk"; -#[cfg(all(debug_assertions, not(coverage)))] +#[cfg(all(unix, debug_assertions, not(coverage)))] pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/debug/sk"; -#[cfg(all(not(debug_assertions), coverage))] +#[cfg(all(unix, not(debug_assertions), coverage))] pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/llvm-cov-target/release/sk"; -#[cfg(all(not(debug_assertions), not(coverage)))] +#[cfg(all(unix, not(debug_assertions), not(coverage)))] pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= SKIM_OPTIONS_FILE= ./target/release/sk"; + +#[cfg(all(windows, debug_assertions, coverage))] +pub static SK: &str = r".\target\llvm-cov-target\debug\sk.exe"; +#[cfg(all(windows, debug_assertions, not(coverage)))] +pub static SK: &str = r".\target\debug\sk.exe"; +#[cfg(all(windows, not(debug_assertions), coverage))] +pub static SK: &str = r".\target\llvm-cov-target\release\sk.exe"; +#[cfg(all(windows, not(debug_assertions), not(coverage)))] +pub static SK: &str = r".\target\release\sk.exe"; diff --git a/tests/defaults.rs b/tests/defaults.rs index 95316606..7d7fd60b 100644 --- a/tests/defaults.rs +++ b/tests/defaults.rs @@ -18,6 +18,7 @@ insta_test!( &[] ); +#[cfg(unix)] insta_test!(interactive_mode_command_execution, @interactive, &["-i", "--cmd", "echo 'foo {q}'"], { @snap; @type "bar"; @@ -26,6 +27,15 @@ insta_test!(interactive_mode_command_execution, @interactive, &["-i", "--cmd", " @snap; }); +#[cfg(windows)] +insta_test!(interactive_mode_command_execution, @interactive, &["-i", "--cmd", "echo foo {q}"], { + @snap; + @type "bar"; + @snap; + @type "baz"; + @snap; +}); + insta_test!(unicode_input, [""], &["-q", "󰬈󰬉󰬊"], { @snap; @type "|"; diff --git a/tests/highlighting.rs b/tests/highlighting.rs index 0da94002..e9896189 100644 --- a/tests/highlighting.rs +++ b/tests/highlighting.rs @@ -1,3 +1,4 @@ +#![cfg(unix)] #[allow(dead_code)] #[macro_use] mod common; diff --git a/tests/history.rs b/tests/history.rs index 40eb3319..f18c6b13 100644 --- a/tests/history.rs +++ b/tests/history.rs @@ -1,3 +1,4 @@ +#![cfg(unix)] #[allow(dead_code)] mod common; diff --git a/tests/listen.rs b/tests/listen.rs index b32831a7..da1be151 100644 --- a/tests/listen.rs +++ b/tests/listen.rs @@ -1,3 +1,6 @@ +// TODO: automate listen tests on windows +// Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing +#![cfg(unix)] #[allow(dead_code)] #[macro_use] mod common; diff --git a/tests/options.rs b/tests/options.rs index 657ce093..b87f93c6 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -19,6 +19,7 @@ insta_test!(opt_min_query_length, ["line1", "line2", "line3"], &["--min-query-le }); // Use info=hidden to hide the spinner +#[cfg(unix)] insta_test!(opt_min_query_length_interactive, @interactive, &["-i", "--min-query-length", "3", "--cmd", "printf 'line1\\nline2\\nline3'", "--info", "hidden"], { @snap; @type "li"; @@ -27,6 +28,15 @@ insta_test!(opt_min_query_length_interactive, @interactive, &["-i", "--min-query @snap; }); +#[cfg(windows)] +insta_test!(opt_min_query_length_interactive, @interactive, &["-i", "--min-query-length", "3", "--cmd", "echo line1 & echo line2 & echo line3", "--info", "hidden"], { + @snap; + @type "li"; + @snap; + @char 'n'; + @snap; +}); + insta_test!(opt_with_nth_1, ["f1,f2,f3,f4"], &["--delimiter", ",", "--with-nth", "1"], { @snap; }); @@ -319,12 +329,20 @@ insta_test!(opt_pre_select_pat, ["a", "b", "c"], &["-m", "--pre-select-pat", "[b @snap; }); +#[cfg(unix)] insta_test!(opt_no_clear_if_empty, @interactive, &["-i", "--no-clear-if-empty", "-c", "printf {q}", "--cmd-query", "xxxx"], { @snap; @ctrl 'w'; @snap; }); +#[cfg(windows)] +insta_test!(opt_no_clear_if_empty, @interactive, &["-i", "--no-clear-if-empty", "-c", "if not [{q}]==[] echo.{q}", "--cmd-query", "xxxx"], { + @snap; + @ctrl 'w'; + @snap; +}); + insta_test!(opt_tac, ["a", "b"], &["--tac"], { @snap; }); @@ -386,18 +404,36 @@ insta_test!(opt_multiple_flags_prompt, [""], &["--prompt", "a", "--prompt", "b", @snap; }); +#[cfg(unix)] insta_test!(opt_multiple_flags_cmd_prompt, @interactive, &["-i", "--cmd-prompt", "a", "--cmd-prompt", "c", "--cmd", "echo"], { @snap; }); +#[cfg(windows)] +insta_test!(opt_multiple_flags_cmd_prompt, @interactive, &["-i", "--cmd-prompt", "a", "--cmd-prompt", "c", "--cmd", "echo."], { + @snap; +}); + +#[cfg(unix)] insta_test!(opt_multiple_flags_cmd_query, @interactive, &["-i", "--cmd-query", "a", "--cmd-query", "b", "--cmd", "echo"], { @snap; }); +#[cfg(windows)] +insta_test!(opt_multiple_flags_cmd_query, @interactive, &["-i", "--cmd-query", "a", "--cmd-query", "b", "--cmd", "echo."], { + @snap; +}); + +#[cfg(unix)] insta_test!(opt_multiple_flags_interactive, @interactive, &["-i", "--interactive", "--interactive", "--cmd", "echo"], { @snap; }); +#[cfg(windows)] +insta_test!(opt_multiple_flags_interactive, @interactive, &["-i", "--interactive", "--interactive", "--cmd", "echo."], { + @snap; +}); + insta_test!(opt_multiple_flags_reverse, [""], &["--reverse", "--reverse"], { @snap; }); @@ -468,6 +504,7 @@ insta_test!(opt_border_quadrant_outside, ["a", "b", "c", "ac"], &["-q", "a", "-- @snap; }); +#[cfg(unix)] #[test] fn opt_select_1() -> std::io::Result<()> { let res = Command::new("/bin/sh") @@ -481,6 +518,23 @@ fn opt_select_1() -> std::io::Result<()> { Ok(()) } +#[cfg(windows)] +#[test] +fn opt_select_1_windows() -> std::io::Result<()> { + let res = Command::new("cmd") + .arg("/C") + .arg(format!(r"(echo 1 & echo 2 & echo 3) | {SK} --select-1 -q 3")) + .env("SKIM_DEFAULT_OPTIONS", "") + .env("SKIM_DEFAULT_COMMAND", "") + .env("SKIM_OPTIONS_FILE", "") + .stdin(std::process::Stdio::null()) + .output()?; + assert_eq!(res.status.code(), Some(0)); + assert!(res.stdout.starts_with(b"3")); + Ok(()) +} + +#[cfg(unix)] #[test] fn opt_exit_0() -> std::io::Result<()> { let res = Command::new("/bin/sh") @@ -494,6 +548,22 @@ fn opt_exit_0() -> std::io::Result<()> { Ok(()) } +#[cfg(windows)] +#[test] +fn opt_exit_0_windows() -> std::io::Result<()> { + let res = Command::new("cmd") + .arg("/C") + .arg(format!(r"(echo 1 & echo 2 & echo 3) | {SK} --exit-0 -q 4")) + .env("SKIM_DEFAULT_OPTIONS", "") + .env("SKIM_DEFAULT_COMMAND", "") + .env("SKIM_OPTIONS_FILE", "") + .stdin(std::process::Stdio::null()) + .output()?; + assert_eq!(res.status.code(), Some(1)); + assert_eq!(res.stdout, &[]); + Ok(()) +} + insta_test!(opt_select_1_enter, ["1", "2", "3", "11"], &["-q", "1", "--select-1"], { @snap; }); diff --git a/tests/preview.rs b/tests/preview.rs index 7427c471..d95966af 100644 --- a/tests/preview.rs +++ b/tests/preview.rs @@ -2,12 +2,17 @@ #[macro_use] mod common; +#[cfg(unix)] const PREVIEW: &str = "printf \"=%.0s\\n\" $(seq 1 1000)"; +#[cfg(windows)] +const PREVIEW: &str = "for /l %i in (1,1,1000) do @echo ="; +#[cfg(unix)] insta_test!(preview_preserve_quotes, ["'\"ABC\"'"], &["--preview", "echo X{}X"], { @snap; }); +#[cfg(unix)] insta_test!(preview_nul_char, ["a\0b"], &["--preview", "printf \"{}\" | hexdump -C"], { @snap; }); diff --git a/tests/tmux.rs b/tests/tmux.rs index 26c0829f..acabd0b4 100644 --- a/tests/tmux.rs +++ b/tests/tmux.rs @@ -1,3 +1,4 @@ +#![cfg(unix)] #[allow(dead_code)] mod common; diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 00000000..ebb07447 --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 1 + + + + + + + + + + + + + + + + + +