From 6b355e144ab7cbb0995221a1bfe716483904f1a1 Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:46:12 +0200 Subject: [PATCH] feat: rename tmux -> popup and add zellij (#1027) * feat: rename tmux -> popup and add zellij * chore: generate completions & manpage * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: fixes * chore: generate completions & manpage * chore: misc, windows todo * chore: disable popup on windows for now * chore: generate completions & manpage * fix: always quote using sh * chore: expect * fix: avoid nested popup invocations * fix: tests * fix: correctly gate popup * chore(docs): update ARCHITECTURE.md [skip ci] --------- Co-authored-by: Skim bot Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- ARCHITECTURE.md | 58 +++++-- Cargo.lock | 42 +++++ Cargo.toml | 3 +- flake.nix | 128 +++++++------- man/man1/sk.1 | 15 +- shell/completion.bash | 6 +- shell/completion.fish | 6 +- shell/completion.nu | 4 +- shell/completion.zsh | 6 +- src/bin/main.rs | 25 ++- src/lib.rs | 4 +- src/matcher.rs | 2 +- src/options.rs | 28 +-- src/{tmux.rs => popup/mod.rs} | 312 +++++++++++++++++----------------- src/popup/tmux.rs | 239 ++++++++++++++++++++++++++ src/popup/zellij.rs | 288 +++++++++++++++++++++++++++++++ src/tui/app.rs | 8 +- src/tui/mod.rs | 9 + tests/{tmux.rs => popup.rs} | 77 +++------ 19 files changed, 925 insertions(+), 335 deletions(-) rename src/{tmux.rs => popup/mod.rs} (55%) create mode 100644 src/popup/tmux.rs create mode 100644 src/popup/zellij.rs rename tests/{tmux.rs => popup.rs} (66%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d480efd9..123fa838 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,7 +12,7 @@ - [Interactive / Command Mode (`--interactive`)](#interactive--command-mode---interactive) - [Select-1 / Exit-0 / Sync Modes](#select-1--exit-0--sync-modes) - [ANSI Mode (`--ansi`)](#ansi-mode---ansi) - - [Tmux Mode (`--tmux`)](#tmux-mode---tmux) + - [Popup Mode (`--popup` / `--tmux`)](#popup-mode---popup----tmux) 6. [Item Ingestion Pipeline](#item-ingestion-pipeline) 7. [The Matching Subsystem](#the-matching-subsystem) - [Match Engine Hierarchy](#match-engine-hierarchy) @@ -105,7 +105,10 @@ skim/ ← workspace root │ ├── field.rs ← field range parsing (--nth / --with-nth) │ ├── spinlock.rs ← lightweight SpinLock │ ├── util.rs ← printf helper, misc utilities -│ ├── tmux.rs ← tmux popup integration +│ ├── popup/ ← tmux & zellij popup integration +│ │ ├── mod.rs ← SkimPopup trait, run_with(), check_env(), SkimPopupOutput +│ │ ├── tmux.rs ← TmuxPopup (builds/runs tmux display-popup) +│ │ └── zellij.rs ← ZellijPopup (builds/runs zellij action new-floating-pane) │ ├── prelude.rs ← convenience re-exports │ ├── manpage.rs ← man-page generation (cli feature) │ ├── shell.rs ← shell completion generation (cli feature) @@ -186,7 +189,7 @@ main() │ ├─ SkimItemReader::new(reader_opts) ← configure stdin reader │ ├─ opts.cmd_collector = cmd_collector │ │ - │ ├─ if --tmux && TMUX is set → tmux::run_with(&opts) + │ ├─ if --popup/--tmux && check_env() → popup::run_with(&opts) │ │ │ └─ else: │ ├─ if stdin not a TTY (piped) → cmd_collector.of_bufread(stdin) @@ -346,21 +349,40 @@ Without `--ansi`, any ANSI escape codes are passed through to `text()` and displ **Key files:** `src/helper/item.rs` (`DefaultSkimItem::new`, `strip_ansi`, `display`) -### Tmux Mode (`--tmux`) +### Popup Mode (`--popup` / `--tmux`) -When `--tmux [direction[,size[,size]]]` is set and `$TMUX` is in the environment, the binary delegates to `tmux::run_with()` instead of `Skim::run_with()`. +When `--popup [direction[,size[,size]]]` (alias `--tmux`) is set, the binary calls `check_and_run_popup()`, which checks `popup::check_env()` and, if true, delegates to `popup::run_with()` instead of `Skim::run_with()`. -The tmux mode works by: -1. Creating a temp directory for IPC (`/tmp/sk-tmux-XXXXXXXX/`). -2. If stdin is piped, creating a named FIFO and spawning a thread to copy stdin to it (so the child process can read incrementally). -3. Reconstructing the full `sk` command line without `--tmux`, adding `--print-query --print-cmd --print-header --print-current --print-score`. -4. Launching `tmux display-popup -E … sh -c > stdout_file`. -5. Waiting for the popup to exit. -6. Parsing the structured stdout file (always `query\ncmd\nheader\ncurrent\nitem1\nscore1\nitem2\nscore2\n…`) into a `SkimOutput`. +**`check_env()`** returns `true` only when: +- `$_SKIM_POPUP` is **not** set in the environment (prevents the child process from recursing back into popup mode), and +- at least one supported multiplexer is detected: tmux (`$TMUX` set) or Zellij (`$ZELLIJ` set). -The child `sk` process runs fully independently inside the tmux popup. The parent reads back a synthetic `SkimOutput` from the file. +The popup flow: +1. Creates a temp directory for IPC (`/tmp/sk-popup-XXXXXXXX/`). +2. If stdin is piped, creates a named FIFO (`tmp_stdin`) and spawns a thread to relay stdin into it incrementally so the child can stream-read. +3. Reconstructs the `sk` command line from `std::env::args()`, stripping `--popup`/`--tmux` and `--output-format`, then appending `--print-query --print-cmd --print-header --print-current --print-score`. +4. Forwards all `SKIM_*`, `RUST*`, and `PATH` environment variables to the child via the multiplexer's `-e` flag, **plus `_SKIM_POPUP=1`** to prevent re-entry. +5. Launches the popup via the appropriate backend: + - **tmux**: `tmux display-popup -E … sh -c > stdout_file` + - **Zellij**: `zellij action new-floating-pane … -- sh -c > stdout_file` +6. Waits for the popup process to exit. +7. Parses the structured stdout file (`query\ncmd\nheader\ncurrent_item\nitem1\nscore1\n…`) into a synthetic `SkimOutput`. -**Key files:** `src/tmux.rs`, `src/bin/main.rs` (`sk_main`) +The internal `SkimPopup` trait abstracts the two multiplexer backends: + +```rust +trait SkimPopup { + fn from_options(options: &SkimOptions) -> Box; + fn add_env(&mut self, key: &str, value: &str); + fn run_and_wait(&mut self, command: &str) -> std::io::Result; +} +``` + +`TmuxPopup` and `ZellijPopup` each implement this trait. The active backend is selected at runtime: Zellij takes priority if both are available. + +The child `sk` process runs fully independently inside the popup. The parent reads back a synthetic `SkimOutput` from the captured file. Because `_SKIM_POPUP=1` is set in the child's environment, `check_env()` returns `false` in the child, so it runs as a normal interactive skim session regardless of what `SKIM_DEFAULT_OPTIONS` contains. + +**Key files:** `src/popup/mod.rs` (`run_with`, `check_env`), `src/popup/tmux.rs` (`TmuxPopup`), `src/popup/zellij.rs` (`ZellijPopup`), `src/bin/main.rs` (`check_and_run_popup`) --- @@ -1062,7 +1084,7 @@ Preview thread (OS thread, per preview spawn): IPC handler task (Tokio, per connection): └─ reads RON actions → sends Event::Action to TUI channel -Tmux stdin relay thread (OS thread, only in --tmux mode): +Popup stdin relay thread (OS thread, only in --popup/--tmux mode): └─ copies stdin → FIFO for child sk process ``` @@ -1108,8 +1130,10 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread | `Tui::new_with_height_and_backend` | `src/tui/backend.rs:68` | Terminal init + viewport sizing | | `Tui::enter` | `src/tui/backend.rs:130` | Enable raw mode + start event pump | | `Tui::start` | `src/tui/backend.rs:163` | Spawn crossterm EventStream task | -| `tmux::run_with` | `src/tmux.rs:107` | Delegate to tmux popup + parse output | -| `sk_main` | `src/bin/main.rs:121` | CLI orchestration + output printing | +| `popup::run_with` | `src/popup/mod.rs:91` | Delegate to multiplexer popup + parse output | +| `popup::check_env` | `src/popup/mod.rs:78` | Guard: multiplexer present and not already in popup | +| `check_and_run_popup` | `src/bin/main.rs:130` | Check popup conditions, dispatch to popup::run_with | +| `sk_main` | `src/bin/main.rs:142` | CLI orchestration + output printing | | `parse_key` | `src/binds.rs:130` | `"ctrl-a"` → `KeyEvent` | | `parse_action_chain` | `src/binds.rs:214` | `"down+select"` → `Vec` | | `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:~140` | Build engine factory chain from options | diff --git a/Cargo.lock b/Cargo.lock index 3918f99a..e1852842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2096,12 +2096,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -2162,6 +2177,32 @@ dependencies = [ "winapi", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2297,6 +2338,7 @@ dependencies = [ "ron", "serde", "serde_json", + "serial_test", "shell-quote", "shlex", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 92248cf5..5d566bf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ gungraun = ["dep:gungraun"] criterion = { version = "0.8.2", features = ["async_tokio"] } insta = "1.47" serde_json = { version = "=1.0.149" } +serial_test = "=3.4.0" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] } @@ -125,4 +126,4 @@ required-features = ["gungraun"] [[bench]] name = "cli" harness = false -bench = false \ No newline at end of file +bench = false diff --git a/flake.nix b/flake.nix index 605fb9df..0a392b4d 100644 --- a/flake.nix +++ b/flake.nix @@ -3,74 +3,70 @@ inputs.nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; - outputs = - inputs: - let - inherit (inputs.nixpkgs) lib; - systems = lib.systems.flakeExposed; - eachSystem = lib.genAttrs systems; - pkgsFor = - system: - import inputs.nixpkgs { - inherit system; - config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "vagrant" ]; - }; - in - { - devShells = eachSystem ( - system: - let - pkgs = pkgsFor system; + outputs = inputs: let + inherit (inputs.nixpkgs) lib; + systems = lib.systems.flakeExposed; + eachSystem = lib.genAttrs systems; + pkgsFor = system: + import inputs.nixpkgs { + inherit system; + config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) ["vagrant"]; + }; + in { + 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 - ]; - vagrantDeps = with pkgs; [ - vagrant - rsync - ]; + # --- 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 + cargo-cross + cargo-xwin + ]; + gungraun = with pkgs; [ + valgrind + libclang + binutils + ]; + 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" - ''; + # --- 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; - vagrant = mkShell (base ++ vagrantDeps) vagrantHook; - full = mkShell (base ++ tests ++ utils ++ gungraun ++ vagrantDeps) (gungraunHook + vagrantHook); - } - ); + 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; + vagrant = mkShell (base ++ vagrantDeps) vagrantHook; + full = mkShell (base ++ tests ++ utils ++ gungraun ++ vagrantDeps) (gungraunHook + vagrantHook); + } + ); - formatter = eachSystem (system: (pkgsFor system).nixfmt); - }; + formatter = eachSystem (system: (pkgsFor system).nixfmt); + }; } diff --git a/man/man1/sk.1 b/man/man1/sk.1 index ebf9a771..bf3f448a 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -8,7 +8,7 @@ sk \- Fuzzy Finder in rust! .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH SYNOPSIS -\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-tmux\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] +\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH OPTIONS @@ -458,15 +458,12 @@ The optional value is used as the indicator \fB\-\-no\-scrollbar\fR Disable the scrollbar in the item list .TP -\fB\-\-tmux\fR [\fI...\fR] -Run in a tmux popup +\fB\-\-popup\fR [\fI...\fR] +Run in a tmux or zellij popup -Format: `sk \-\-tmux [,SIZE[%]][,SIZE[%]]` - -Depending on the direction, the order and behavior of the sizes varies: - -Default: center,50% -Ignored on Windows +Format: `sk \-\-popup [,SIZE[%]][,SIZE[%]]` +Note: this will try to detect a Zellij session, then a Tmux session +This means that in nested sesions, skim will prioritize Zellij over Tmux .SH HISTORY .TP \fB\-\-history\fR \fI\fR diff --git a/shell/completion.bash b/shell/completion.bash index 92e5f7de..91aea366 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -23,7 +23,7 @@ _sk() { case "${cmd}" in sk) - opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --tmux --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version" + opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -253,7 +253,7 @@ _sk() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; - --tmux) + --popup) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; @@ -266,7 +266,7 @@ _sk() { return 0 ;; --flags) - COMPREPLY=($(compgen -W "no-preview-pty show-score show-index" -- "${cur}")) + COMPREPLY=($(compgen -W "no-preview-pty show-score show-index single-reader single-matcher" -- "${cur}")) return 0 ;; --hscroll-off) diff --git a/shell/completion.fish b/shell/completion.fish index 8f587bfd..e05b4dac 100644 --- a/shell/completion.fish +++ b/shell/completion.fish @@ -85,12 +85,14 @@ power-shell\t'PowerShell' zsh\t'Zsh'" complete -c sk -l listen -d 'Run an IPC socket with optional name (defaults to sk)' -r complete -c sk -l remote -d 'Send commands to an IPC socket with optional name (defaults to sk)' -r -complete -c sk -l tmux -d 'Run in a tmux popup' -r +complete -c sk -l popup -d 'Run in a tmux or zellij popup' -r complete -c sk -l log-level -d 'Set the log level' -r complete -c sk -l log-file -d 'Pipe log output to a file' -r complete -c sk -l flags -d 'Feature flags' -r -f -a "no-preview-pty\t'Disable preview PTY on linux' show-score\t'Display the item\'s match score before its value in the item list (for matcher debugging)' -show-index\t'Display the item\'s index before its value in the item list'" +show-index\t'Display the item\'s index before its value in the item list' +single-reader\t'Limit the reader thread pool to a single thread' +single-matcher\t'Limit the matcher thread pool to a single thread'" complete -c sk -l hscroll-off -r complete -c sk -l jump-labels -r complete -c sk -l tail -r diff --git a/shell/completion.nu b/shell/completion.nu index 6c50b8f2..6ced65cb 100644 --- a/shell/completion.nu +++ b/shell/completion.nu @@ -33,7 +33,7 @@ module completions { } def "nu-complete sk flags" [] { - [ "no-preview-pty" "show-score" "show-index" ] + [ "no-preview-pty" "show-score" "show-index" "single-reader" "single-matcher" ] } # Fuzzy Finder in rust! @@ -126,7 +126,7 @@ module completions { --man # Generate man page and output it to stdout --listen: string # Run an IPC socket with optional name (defaults to sk) --remote: string # Send commands to an IPC socket with optional name (defaults to sk) - --tmux: string # Run in a tmux popup + --popup: string # Run in a tmux or zellij popup --log-level: string # Set the log level --log-file: string # Pipe log output to a file --flags: string@"nu-complete sk flags" # Feature flags diff --git a/shell/completion.zsh b/shell/completion.zsh index da430b76..d1181981 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -86,12 +86,14 @@ power-shell\:"PowerShell" zsh\:"Zsh"))' \ '--listen=[Run an IPC socket with optional name (defaults to sk)]::LISTEN:_default' \ '--remote=[Send commands to an IPC socket with optional name (defaults to sk)]::REMOTE:_default' \ -'--tmux=[Run in a tmux popup]::TMUX:_default' \ +'--popup=[Run in a tmux or zellij popup]::POPUP:_default' \ '--log-level=[Set the log level]:LOG_LEVEL:_default' \ '--log-file=[Pipe log output to a file]:LOG_FILE:_default' \ '*--flags=[Feature flags]:FLAGS:((no-preview-pty\:"Disable preview PTY on linux" show-score\:"Display the item'\''s match score before its value in the item list (for matcher debugging)" -show-index\:"Display the item'\''s index before its value in the item list"))' \ +show-index\:"Display the item'\''s index before its value in the item list" +single-reader\:"Limit the reader thread pool to a single thread" +single-matcher\:"Limit the matcher thread pool to a single thread"))' \ '--hscroll-off=[]:HSCROLL_OFF:_default' \ '--jump-labels=[]:JUMP_LABELS:_default' \ '--tail=[]:TAIL:_default' \ diff --git a/src/bin/main.rs b/src/bin/main.rs index 60fa5226..daa66543 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -20,8 +20,8 @@ use skim::binds::parse_action_chain; use skim::reader::CommandCollector; use skim::tui::event::Action; use std::fs::File; +use std::io; use std::io::{BufReader, BufWriter, IsTerminal, Write}; -use std::{env, io}; use skim::prelude::*; @@ -125,6 +125,20 @@ fn main() -> Result<()> { } } +/// Returns `None` if the popup should not open, otherwise run the popup and return the result +#[cfg(unix)] +fn check_and_run_popup(opts: &SkimOptions) -> Option> { + if opts.popup.is_some() && popup::check_env() { + Some(crate::popup::run_with(opts)) + } else { + None + } +} +#[cfg(not(unix))] +fn check_and_run_popup(_opts: &SkimOptions) -> Option> { + None +} + fn sk_main(mut opts: SkimOptions) -> Result { let reader_opts = SkimItemReaderOption::from_options(&opts); let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts))); @@ -154,12 +168,7 @@ fn sk_main(mut opts: SkimOptions) -> Result { //------------------------------------------------------------------------------ // output - 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 { + let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| { // read from pipe or command let rx_item = if io::stdin().is_terminal() || (opts.interactive && opts.cmd.is_some()) { None @@ -167,7 +176,7 @@ fn sk_main(mut opts: SkimOptions) -> Result { let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin())); Some(rx_item) }; - Some(Skim::run_with(opts, rx_item)?) + Skim::run_with(opts, rx_item).ok() }) else { return Ok(135); }; diff --git a/src/lib.rs b/src/lib.rs index 4c802f6c..d42d45f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,6 +61,8 @@ pub mod item; pub mod matcher; pub mod options; mod output; +#[cfg(unix)] +pub mod popup; pub mod prelude; pub mod reader; mod skim; @@ -68,8 +70,6 @@ mod skim_item; pub mod spinlock; pub mod theme; pub mod thread_pool; -#[cfg(unix)] -pub mod tmux; pub mod tui; mod util; diff --git a/src/matcher.rs b/src/matcher.rs index 83510aa0..622a2eef 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -304,7 +304,7 @@ impl Matcher { // // The chunk size controls the granularity of work distribution and // the frequency of atomic counter updates / interrupt checks. - const CHUNK_SIZE: usize = 512; + const CHUNK_SIZE: usize = 1 << 12; // Convert items into an Arc slice so all workers can share them. let shared_items: Arc<[Arc]> = items.into(); diff --git a/src/options.rs b/src/options.rs index d48de14f..55e6ac5e 100644 --- a/src/options.rs +++ b/src/options.rs @@ -804,16 +804,13 @@ pub struct SkimOptions { #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))] pub remote: Option, - /// Run in a tmux popup + /// Run in a tmux or zellij popup /// - /// Format: `sk --tmux [,SIZE[%]][,SIZE[%]]` - /// - /// 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, + /// Format: `sk --popup [,SIZE[%]][,SIZE[%]]` + /// Note: this will try to detect a Zellij session, then a Tmux session + /// This means that in nested sesions, skim will prioritize Zellij over Tmux + #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))] + pub popup: Option, /// Set the log level #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] @@ -1084,7 +1081,7 @@ impl Default for SkimOptions { pre_select_items: Default::default(), pre_select_file: Default::default(), filter: Default::default(), - tmux: Default::default(), + popup: Default::default(), log_file: Default::default(), extended: Default::default(), literal: Default::default(), @@ -1327,6 +1324,17 @@ pub enum FeatureFlag { ShowScore, /// Display the item's index before its value in the item list ShowIndex, + /// Limit the reader thread pool to a single thread + /// + /// Forces the reader pipeline to run on exactly one thread, regardless of the number of + /// available CPU cores. Useful for debugging reader-side behaviour or for environments where + /// parallelism causes ordering issues. + SingleReader, + /// Limit the matcher thread pool to a single thread + /// + /// Forces the matcher to run on exactly one thread, regardless of the number of available CPU + /// cores. Useful for reproducing deterministic match ordering or for debugging the matcher. + SingleMatcher, } #[allow(unused_macros)] diff --git a/src/tmux.rs b/src/popup/mod.rs similarity index 55% rename from src/tmux.rs rename to src/popup/mod.rs index 72d33df1..70efdef3 100644 --- a/src/tmux.rs +++ b/src/popup/mod.rs @@ -1,14 +1,16 @@ -//! Tmux integration utilities. +//! Tmux & Zellij integration utilities. //! -//! This module provides functionality for running skim within tmux panes, +//! This module provides functionality for running skim within tmux/zellij panes, //! allowing skim to be used as a tmux popup or split pane. +mod tmux; +mod zellij; + use std::{ borrow::Cow, - env, fmt::Write as FmtWrite, io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _}, - process::{Command, Stdio}, + process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -20,7 +22,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use nix::sys::stat::Mode; use nix::unistd::mkfifo; use rand::{RngExt as _, distr::Alphanumeric}; -use which::which; use crate::{ Rank, SkimItem, SkimOptions, SkimOutput, @@ -28,8 +29,11 @@ use crate::{ tui::{Event, event::Action}, }; +use tmux::TmuxPopup; +use zellij::ZellijPopup; + #[derive(Debug, PartialEq, Eq)] -enum TmuxWindowDir { +enum PopupWindowDir { Center, Top, Bottom, @@ -37,9 +41,9 @@ enum TmuxWindowDir { Right, } -impl From<&str> for TmuxWindowDir { +impl From<&str> for PopupWindowDir { fn from(value: &str) -> Self { - use TmuxWindowDir::{Bottom, Center, Left, Right, Top}; + use PopupWindowDir::{Bottom, Center, Left, Right, Top}; match value { "top" => Top, "bottom" => Bottom, @@ -50,51 +54,29 @@ impl From<&str> for TmuxWindowDir { } } -#[derive(Debug, PartialEq, Eq)] -struct TmuxOptions<'a> { - width: &'a str, - height: &'a str, - x: &'a str, - y: &'a str, +trait SkimPopup { + fn from_options(options: &SkimOptions) -> Box + where + Self: Sized; + fn add_env(&mut self, key: &str, value: &str); + fn run_and_wait(&mut self, command: &str) -> std::io::Result; } -struct SkimTmuxOutput { +struct SkimPopupOutput { line: String, } -impl SkimItem for SkimTmuxOutput { +impl SkimItem for SkimPopupOutput { fn text(&self) -> Cow<'_, str> { Cow::from(&self.line) } } -impl<'a> From<&'a String> for TmuxOptions<'a> { - fn from(value: &'a String) -> Self { - let (raw_dir, size) = value.split_once(',').unwrap_or((value, "50%")); - let dir = TmuxWindowDir::from(raw_dir); - let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') { - match dir { - TmuxWindowDir::Center | TmuxWindowDir::Left | TmuxWindowDir::Right => (rhs, lhs), - TmuxWindowDir::Top | TmuxWindowDir::Bottom => (lhs, rhs), - } - } else { - match dir { - TmuxWindowDir::Left | TmuxWindowDir::Right => ("100%", size), - TmuxWindowDir::Top | TmuxWindowDir::Bottom => (size, "100%"), - TmuxWindowDir::Center => (size, size), - } - }; - - let (x, y) = match dir { - TmuxWindowDir::Center => ("C", "C"), - TmuxWindowDir::Top => ("C", "0%"), - TmuxWindowDir::Bottom => ("C", "100%"), - TmuxWindowDir::Left => ("0%", "C"), - TmuxWindowDir::Right => ("100%", "C"), - }; - - Self { width, height, x, y } - } +/// Returns true if a compatible multiplexer is running and we are not already in a popup +/// (`$_SKIM_POPUP`) +#[must_use] +pub fn check_env() -> bool { + std::env::var("_SKIM_POPUP").is_err() && (tmux::is_available() || zellij::is_available()) } /// Run skim in a tmux popup @@ -109,7 +91,7 @@ impl<'a> From<&'a String> for TmuxOptions<'a> { pub fn run_with(opts: &SkimOptions) -> Option { // Create temp dir for downstream output let temp_dir_name = format!( - "sk-tmux-{}", + "sk-popup-{}", &rand::rng() .sample_iter(&Alphanumeric) .take(8) @@ -173,14 +155,14 @@ pub fn run_with(opts: &SkimOptions) -> Option { }; // Build args to send to downstream sk invocation - let mut tmux_shell_cmd = String::new(); - let mut prev_is_tmux_flag = false; + let mut stripped_shell_cmd = String::new(); + let mut prev_is_popup = false; let mut prev_is_output_format_flag = false; // We keep argv[0] to use in the popup's command for arg in std::env::args() { debug!("Got arg {arg}"); - if prev_is_tmux_flag { - prev_is_tmux_flag = false; + if prev_is_popup { + prev_is_popup = false; if !arg.starts_with('-') { continue; } @@ -188,12 +170,12 @@ pub fn run_with(opts: &SkimOptions) -> Option { prev_is_output_format_flag = false; continue; } - if arg == "--tmux" { - debug!("Found tmux arg, skipping this and the next"); - prev_is_tmux_flag = true; + if arg == "--tmux" || arg == "--popup" { + debug!("Found popup arg, skipping this and the next"); + prev_is_popup = true; continue; - } else if arg.starts_with("--tmux") { - debug!("Found equal tmux arg, skipping"); + } else if arg.starts_with("--tmux") || arg.starts_with("--popup") { + debug!("Found equal popup arg, skipping"); continue; } else if arg == "--output-format" { debug!("Found output format arg, skipping this and the next"); @@ -203,7 +185,7 @@ pub fn run_with(opts: &SkimOptions) -> Option { debug!("Found equal output format arg, skipping"); continue; } - push_quoted_arg(&mut tmux_shell_cmd, &arg); + push_quoted_arg(&mut stripped_shell_cmd, &arg); } // Always add all --print-xxx flags to the child sk command so that the output // is fully structured and can be parsed unconditionally below, regardless of @@ -215,49 +197,37 @@ pub fn run_with(opts: &SkimOptions) -> Option { "--print-current", "--print-score", ] { - let _ = write!(tmux_shell_cmd, " {flag}"); + let _ = write!(stripped_shell_cmd, " {flag}"); } - tmux_shell_cmd = tmux_shell_cmd.replace("--output-format", ""); if has_piped_input { - let _ = write!(tmux_shell_cmd, " <{}", tmp_stdin.display()); + let _ = write!(stripped_shell_cmd, " <{}", tmp_stdin.display()); } - let _ = write!(tmux_shell_cmd, " >{}", tmp_stdout.display()); + let _ = write!(stripped_shell_cmd, " >{}", tmp_stdout.display()); - debug!("build cmd {}", &tmux_shell_cmd); + debug!("build cmd {}", &stripped_shell_cmd); // Run downstream sk in tmux - let raw_tmux_opts = &opts.tmux.clone().unwrap(); - let tmux_opts = TmuxOptions::from(raw_tmux_opts); - let mut tmux_cmd = Command::new(which("tmux").unwrap_or_else(|e| panic!("Failed to find tmux in path: {e}"))); - - tmux_cmd - .arg("display-popup") - .arg("-E") - .args(["-d", std::env::current_dir().unwrap().to_str().unwrap()]) - .args(["-h", tmux_opts.height]) - .args(["-w", tmux_opts.width]) - .args(["-x", tmux_opts.x]) - .args(["-y", tmux_opts.y]); + let mut popup: Box = if zellij::is_available() { + ZellijPopup::from_options(opts) + } else if tmux::is_available() { + TmuxPopup::from_options(opts) + } else { + panic!("You shouldn't have been able to get here"); + }; for (name, value) in std::env::vars() { if name.starts_with("SKIM") || name == "PATH" || name.starts_with("RUST") { let value = sanitize_value(value); debug!("adding {name} = {value} to the command's env"); - tmux_cmd.args(["-e", &format!("{name}={value}")]); + popup.add_env(&name, &value); } } + popup.add_env("_SKIM_POPUP", "1"); - tmux_cmd.args(["sh", "-c", &tmux_shell_cmd]); - - debug!("tmux command: {tmux_cmd:?}"); - - let status = tmux_cmd - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) - .status() - .unwrap_or_else(|e| panic!("Tmux invocation failed with {e}")); + let status = popup + .run_and_wait(&stripped_shell_cmd) + .unwrap_or_else(|e| panic!("Popup invocation of {stripped_shell_cmd} failed with {e}")); // Signal the stdin thread to stop and wait for it to exit stop_reading.store(true, Ordering::Relaxed); @@ -268,6 +238,8 @@ pub fn run_with(opts: &SkimOptions) -> Option { let mut stdout = stdout_bytes.split(output_ending); let _ = std::fs::remove_dir_all(temp_dir); + debug!("popup stdout: {stdout:?}"); + // The child sk process always runs with --print-query, --print-cmd, --print-header, // and --print-score, so we always read those lines unconditionally. let query_str = if status.success() { @@ -295,7 +267,7 @@ pub fn run_with(opts: &SkimOptions) -> Option { None } else { Some(MatchedItem::new( - Arc::new(SkimTmuxOutput { line: line.to_string() }), + Arc::new(SkimPopupOutput { line: line.to_string() }), Rank::default(), None, &RankBuilder::default(), @@ -315,7 +287,7 @@ pub fn run_with(opts: &SkimOptions) -> Option { ..Default::default() }; let item = MatchedItem::new( - Arc::new(SkimTmuxOutput { line: line.to_string() }), + Arc::new(SkimPopupOutput { line: line.to_string() }), rank, None, &RankBuilder::default(), @@ -334,8 +306,8 @@ pub fn run_with(opts: &SkimOptions) -> Option { final_event, is_abort, final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - // Note: In tmux mode, the actual final key is not available since skim runs in a separate - // tmux popup process. Only the output text is captured. Use --expect with --bind to capture + // Note: In poup mode, the actual final key is not available since skim runs in a separate + // popup process. Only the output text is captured. Use --expect with --bind to capture // specific accept keys in the output if needed. query: query_str.to_string(), cmd: command_str.to_string(), @@ -347,19 +319,11 @@ pub fn run_with(opts: &SkimOptions) -> Option { } fn push_quoted_arg(args_str: &mut String, arg: &str) { - use shell_quote::{Bash, Fish, Quote as _, Sh, Zsh}; - let shell_path = env::var("SHELL").unwrap_or(String::from("/bin/sh")); - let shell = shell_path.rsplit_once('/').unwrap_or(("", "sh")).1; - let quoted_arg: Vec = match shell { - "zsh" => Zsh::quote(arg), - "bash" => Bash::quote(arg), - "fish" => Fish::quote(arg), - _ => Sh::quote(arg), - }; + use shell_quote::{Quote as _, Sh}; let _ = write!( args_str, " {}", - String::from_utf8(quoted_arg).expect("Failed to parse quoted arg as utf8, this should not happen") + String::from_utf8(Sh::quote(arg)).expect("Failed to parse quoted arg as utf8, this should not happen") ); } @@ -377,76 +341,106 @@ fn sanitize_value(value: String) -> String { mod tests { use super::*; - fn check(input: &str, height: &str, width: &str, x: &str, y: &str) { - assert_eq!( - TmuxOptions::from(&String::from(input)), - TmuxOptions { width, height, x, y } - ); + // ── PopupWindowDir::from ────────────────────────────────────────────────── + + #[test] + fn popup_window_dir_known_values() { + assert_eq!(PopupWindowDir::from("center"), PopupWindowDir::Center); + assert_eq!(PopupWindowDir::from("top"), PopupWindowDir::Top); + assert_eq!(PopupWindowDir::from("bottom"), PopupWindowDir::Bottom); + assert_eq!(PopupWindowDir::from("left"), PopupWindowDir::Left); + assert_eq!(PopupWindowDir::from("right"), PopupWindowDir::Right); } #[test] - fn tmux_options_default() { - check("", "50%", "50%", "C", "C"); + fn popup_window_dir_unknown_falls_back_to_center() { + assert_eq!(PopupWindowDir::from(""), PopupWindowDir::Center); + assert_eq!(PopupWindowDir::from("foobar"), PopupWindowDir::Center); + assert_eq!(PopupWindowDir::from("CENTER"), PopupWindowDir::Center); // case-sensitive } + + // ── sanitize_value ──────────────────────────────────────────────────────── + #[test] - fn tmux_options_center() { - let (x, y) = ("C", "C"); - check("center", "50%", "50%", x, y); - check("center,10", "10", "10", x, y); - check("center,10,20", "20", "10", x, y); - check("center,10%,20", "20", "10%", x, y); - check("center,10%,20%", "20%", "10%", x, y); - } - #[test] - fn tmux_options_top() { - let (x, y) = ("C", "0%"); - check("top", "50%", "100%", x, y); - check("top,10", "10", "100%", x, y); - check("top,10,20", "10", "20", x, y); - check("top,10%,20", "10%", "20", x, y); - check("top,10%,20%", "10%", "20%", x, y); - } - #[test] - fn tmux_options_bottom() { - let (x, y) = ("C", "100%"); - check("bottom", "50%", "100%", x, y); - check("bottom,10", "10", "100%", x, y); - check("bottom,10,20", "10", "20", x, y); - check("bottom,10%,20", "10%", "20", x, y); - check("bottom,10%,20%", "10%", "20%", x, y); - } - #[test] - fn tmux_options_left() { - let (x, y) = ("0%", "C"); - check("left", "100%", "50%", x, y); - check("left,10", "100%", "10", x, y); - check("left,10,20", "20", "10", x, y); - check("left,10%,20", "20", "10%", x, y); - check("left,10%,20%", "20%", "10%", x, y); - } - #[test] - fn tmux_options_right() { - let (x, y) = ("100%", "C"); - check("right", "100%", "50%", x, y); - check("right,10", "100%", "10", x, y); - check("right,10,20", "20", "10", x, y); - check("right,10%,20", "20", "10%", x, y); - check("right,10%,20%", "20%", "10%", x, y); + fn sanitize_value_no_semicolon() { + assert_eq!(sanitize_value("hello".to_string()), "hello"); + assert_eq!(sanitize_value("foo=bar".to_string()), "foo=bar"); + assert_eq!(sanitize_value(String::new()), ""); } #[test] - fn test_sanitize_value() { - assert_eq!(sanitize_value("some-value".to_string()), "some-value".to_string()); - assert_eq!(sanitize_value("some-value;".to_string()), "some-value\\;".to_string()); - assert_eq!(sanitize_value("some-value;;".to_string()), "some-value;\\;".to_string()); - assert_eq!( - sanitize_value("some-value;;;".to_string()), - "some-value;;\\;".to_string() - ); - assert_eq!(sanitize_value("some-value;x".to_string()), "some-value;x".to_string()); - assert_eq!( - sanitize_value("some-value;x;".to_string()), - "some-value;x\\;".to_string() - ); + fn sanitize_value_trailing_semicolon_is_escaped() { + assert_eq!(sanitize_value("hello;".to_string()), "hello\\;"); + assert_eq!(sanitize_value(";".to_string()), "\\;"); + } + + #[test] + fn sanitize_value_semicolon_in_middle_unchanged() { + assert_eq!(sanitize_value("hel;lo".to_string()), "hel;lo"); + assert_eq!(sanitize_value("a;b;c".to_string()), "a;b;c"); + } + + // ── push_quoted_arg ─────────────────────────────────────────────────────── + // These tests mutate the SHELL env var. `#[serial]` ensures they never run + // concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81 + // (edition 2024); the SAFETY invariant holds because `#[serial]` serialises + // access so no other thread reads the var while it is being written. + + #[test] + #[serial_test::serial] + fn push_quoted_arg_simple_word_sh() { + // SAFETY: serialised by #[serial]; no concurrent reads of SHELL. + unsafe { std::env::set_var("SHELL", "/bin/sh") }; + let mut s = String::new(); + push_quoted_arg(&mut s, "hello"); + assert_eq!(s, " hello"); + unsafe { std::env::remove_var("SHELL") }; + } + + #[test] + #[serial_test::serial] + fn push_quoted_arg_spaces_are_quoted() { + // SAFETY: serialised by #[serial]; no concurrent reads of SHELL. + unsafe { std::env::set_var("SHELL", "/bin/sh") }; + let mut s = String::new(); + push_quoted_arg(&mut s, "hello world"); + // The result must preserve both words and not be a bare unquoted string + assert!(s.contains("hello")); + assert!(s.contains("world")); + assert_ne!(s.trim(), "hello world"); // must be quoted somehow + unsafe { std::env::remove_var("SHELL") }; + } + + #[test] + #[serial_test::serial] + fn push_quoted_arg_appends_with_space_prefix() { + // SAFETY: serialised by #[serial]; no concurrent reads of SHELL. + unsafe { std::env::set_var("SHELL", "/bin/sh") }; + let mut s = String::from("sk"); + push_quoted_arg(&mut s, "--flag"); + assert!(s.starts_with("sk ")); + unsafe { std::env::remove_var("SHELL") }; + } + + #[test] + #[serial_test::serial] + fn push_quoted_arg_bash_shell() { + // SAFETY: serialised by #[serial]; no concurrent reads of SHELL. + unsafe { std::env::set_var("SHELL", "/usr/bin/bash") }; + let mut s = String::new(); + push_quoted_arg(&mut s, "simple"); + assert_eq!(s, " simple"); + unsafe { std::env::remove_var("SHELL") }; + } + + #[test] + #[serial_test::serial] + fn push_quoted_arg_zsh_shell() { + // SAFETY: serialised by #[serial]; no concurrent reads of SHELL. + unsafe { std::env::set_var("SHELL", "/bin/zsh") }; + let mut s = String::new(); + push_quoted_arg(&mut s, "simple"); + assert_eq!(s, " simple"); + unsafe { std::env::remove_var("SHELL") }; } } diff --git a/src/popup/tmux.rs b/src/popup/tmux.rs new file mode 100644 index 00000000..0f898778 --- /dev/null +++ b/src/popup/tmux.rs @@ -0,0 +1,239 @@ +use crate::SkimOptions; + +use super::{PopupWindowDir, SkimPopup}; +use std::process::{Command, ExitStatus, Stdio}; + +pub fn is_available() -> bool { + cfg!(unix) && std::env::var("TMUX").is_ok() && which::which("tmux").is_ok() +} + +pub(super) struct TmuxPopup { + cmd: Command, +} + +impl TmuxPopup { + fn build(options: &SkimOptions) -> Self { + let arg = options.popup.as_ref().expect("this arg should be present to get here"); + let mut cmd = Command::new( + which::which("tmux").expect("tmux not found in path. This should have been caught by is_available"), + ); + cmd.arg("display-popup").arg("-E").args([ + "-d", + &std::env::current_dir() + .ok() + .map_or(".".to_string(), |d| d.to_string_lossy().to_string()), + ]); + + let border = { + use crate::tui::BorderType::{Plain, Rounded, Thick}; + match options.border { + None => "none", + Some(Plain) => "single", + Some(Rounded) => "rounded", + Some(Thick) => "heavy", + Some(_) => "double", + } + }; + + let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%")); + let dir = PopupWindowDir::from(raw_dir); + let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') { + match dir { + PopupWindowDir::Center | PopupWindowDir::Left | PopupWindowDir::Right => (rhs, lhs), + PopupWindowDir::Top | PopupWindowDir::Bottom => (lhs, rhs), + } + } else { + match dir { + PopupWindowDir::Left | PopupWindowDir::Right => ("100%", size), + PopupWindowDir::Top | PopupWindowDir::Bottom => (size, "100%"), + PopupWindowDir::Center => (size, size), + } + }; + + let (x, y) = match dir { + PopupWindowDir::Center => ("C", "C"), + PopupWindowDir::Top => ("C", "0%"), + PopupWindowDir::Bottom => ("C", "100%"), + PopupWindowDir::Left => ("0%", "C"), + PopupWindowDir::Right => ("100%", "C"), + }; + + cmd.args(["-h", height]) + .args(["-w", width]) + .args(["-x", x]) + .args(["-y", y]) + .args(["-b", border]); + + Self { cmd } + } +} + +impl SkimPopup for TmuxPopup { + fn from_options(options: &SkimOptions) -> Box { + Box::new(Self::build(options)) as Box + } + + fn add_env(&mut self, key: &str, value: &str) { + self.cmd.args(["-e", &format!("{key}={value}")]); + } + + fn run_and_wait(&mut self, command: &str) -> std::io::Result { + debug!("tmux command: {command:?}"); + self.cmd.args(["sh", "-c", command]); + + debug!("tmux full command: {:?}", self.cmd); + self.cmd + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .status() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::SkimOptionsBuilder; + + /// Skip the test if `tmux` is not in PATH (CI environments without tmux). + macro_rules! require_tmux { + () => { + if which::which("tmux").is_err() { + return; + } + }; + } + + fn opts(tmux: &str) -> crate::SkimOptions { + SkimOptionsBuilder::default() + .popup(tmux) + .build() + .expect("valid options") + } + + fn opts_with_border(tmux: &str, border: crate::tui::BorderType) -> crate::SkimOptions { + SkimOptionsBuilder::default() + .popup(tmux) + .border(border) + .build() + .expect("valid options") + } + + #[test] + fn border_none_does_not_panic() { + require_tmux!(); + // Ensure each BorderType variant can be passed without panicking. + for border in [ + crate::tui::BorderType::Plain, + crate::tui::BorderType::Rounded, + crate::tui::BorderType::Thick, + crate::tui::BorderType::Double, + ] { + let _ = TmuxPopup::build(&opts_with_border("center", border)); + } + // No border option + let _ = TmuxPopup::build(&opts("center")); + } + + fn args(popup: &TmuxPopup) -> Vec { + popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect() + } + + fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str()) + } + + #[test] + fn center_default_size() { + require_tmux!(); + let popup = TmuxPopup::build(&opts("center")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("50%")); + assert_eq!(get_flag(&a, "-w"), Some("50%")); + assert_eq!(get_flag(&a, "-x"), Some("C")); + assert_eq!(get_flag(&a, "-y"), Some("C")); + } + + #[test] + fn center_no_direction_defaults_to_center() { + require_tmux!(); + // Bare "50%" with no direction keyword defaults to Center + let popup = TmuxPopup::build(&opts("50%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-x"), Some("C")); + assert_eq!(get_flag(&a, "-y"), Some("C")); + } + + #[test] + fn top_direction() { + require_tmux!(); + let popup = TmuxPopup::build(&opts("top,40%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("40%")); + assert_eq!(get_flag(&a, "-w"), Some("100%")); + assert_eq!(get_flag(&a, "-x"), Some("C")); + assert_eq!(get_flag(&a, "-y"), Some("0%")); + } + + #[test] + fn bottom_direction() { + require_tmux!(); + let popup = TmuxPopup::build(&opts("bottom,30%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("30%")); + assert_eq!(get_flag(&a, "-w"), Some("100%")); + assert_eq!(get_flag(&a, "-x"), Some("C")); + assert_eq!(get_flag(&a, "-y"), Some("100%")); + } + + #[test] + fn left_direction() { + require_tmux!(); + let popup = TmuxPopup::build(&opts("left,30%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("100%")); + assert_eq!(get_flag(&a, "-w"), Some("30%")); + assert_eq!(get_flag(&a, "-x"), Some("0%")); + assert_eq!(get_flag(&a, "-y"), Some("C")); + } + + #[test] + fn right_direction() { + require_tmux!(); + let popup = TmuxPopup::build(&opts("right,30%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("100%")); + assert_eq!(get_flag(&a, "-w"), Some("30%")); + assert_eq!(get_flag(&a, "-x"), Some("100%")); + assert_eq!(get_flag(&a, "-y"), Some("C")); + } + + #[test] + fn two_dimensional_size_center() { + // "center,WIDTH,HEIGHT" — for Center/Left/Right: height=rhs, width=lhs + require_tmux!(); + let popup = TmuxPopup::build(&opts("center,60%,40%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-w"), Some("60%")); + assert_eq!(get_flag(&a, "-h"), Some("40%")); + } + + #[test] + fn two_dimensional_size_top() { + // "top,HEIGHT,WIDTH" — for Top/Bottom: height=lhs, width=rhs + require_tmux!(); + let popup = TmuxPopup::build(&opts("top,30%,80%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "-h"), Some("30%")); + assert_eq!(get_flag(&a, "-w"), Some("80%")); + } + + #[test] + fn add_env_appends_e_flag() { + require_tmux!(); + let mut popup = TmuxPopup::build(&opts("center")); + popup.add_env("FOO", "bar"); + let a = args(&popup); + assert!(a.windows(2).any(|w| w[0] == "-e" && w[1] == "FOO=bar")); + } +} diff --git a/src/popup/zellij.rs b/src/popup/zellij.rs new file mode 100644 index 00000000..ad5b6fd4 --- /dev/null +++ b/src/popup/zellij.rs @@ -0,0 +1,288 @@ +use super::{PopupWindowDir, SkimPopup}; +use crate::{SkimOptions, tui::Size}; + +use std::fmt::Write as _; +use std::process::{Command, ExitStatus, Stdio}; + +pub fn is_available() -> bool { + std::env::var("ZELLIJ").is_ok() && which::which("zellij").is_ok() +} + +pub(super) struct ZellijPopup { + cmd: Command, + env: String, +} + +fn middle_coord(size: Size, var: &str) -> Size { + match size { + Size::Percent(p) => Size::Percent(100u16.saturating_sub(p) / 2), + Size::Fixed(cols) => Size::Fixed( + std::env::var(var) + .map(|s| s.parse().unwrap_or(80)) + .unwrap_or(80u16) + .saturating_sub(cols) + / 2, + ), + } +} + +fn align_end_coord(size: Size, var: &str) -> Size { + match size { + Size::Percent(p) => Size::Percent(100 - p), + Size::Fixed(cols) => Size::Fixed( + std::env::var(var) + .map(|s| s.parse().unwrap_or(80)) + .unwrap_or(80u16) + .saturating_sub(cols), + ), + } +} + +impl ZellijPopup { + fn build(options: &SkimOptions) -> Self { + let mut cmd = Command::new( + which::which("zellij").expect("zellij not found in path. This should have been caught by is_available"), + ); + cmd.arg("run") + .arg("--floating") + .arg("--block-until-exit") + .arg("--close-on-exit") + .args(["--pinned", "true"]) + .args(["--name", "skim"]) + .args([ + "--cwd", + &std::env::current_dir() + .ok() + .map_or(".".to_string(), |d| d.to_string_lossy().to_string()), + ]); + + if options.border.is_none() { + cmd.args(["--borderless", "true"]); + } + + let arg = options.popup.as_ref().expect("this arg should be present to get here"); + + let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%")); + let dir = PopupWindowDir::from(raw_dir); + let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') { + let parsed_rhs = Size::try_from(rhs).unwrap_or(Size::Percent(50)); + let parsed_lhs = Size::try_from(lhs).unwrap_or(Size::Percent(50)); + + match dir { + PopupWindowDir::Center | PopupWindowDir::Left | PopupWindowDir::Right => (parsed_rhs, parsed_lhs), + PopupWindowDir::Top | PopupWindowDir::Bottom => (parsed_lhs, parsed_rhs), + } + } else { + let parsed_size = Size::try_from(size).unwrap_or(Size::Percent(50)); + let full_size = Size::Percent(100); + match dir { + PopupWindowDir::Left | PopupWindowDir::Right => (full_size, parsed_size), + PopupWindowDir::Top | PopupWindowDir::Bottom => (parsed_size, full_size), + PopupWindowDir::Center => (parsed_size, parsed_size), + } + }; + + let (x, y) = match dir { + PopupWindowDir::Center => { + let x = middle_coord(width, "COLUMNS"); + let y = middle_coord(height, "ROWS"); + (x, y) + } + PopupWindowDir::Top => (middle_coord(width, "COLUMNS"), Size::Fixed(0)), + PopupWindowDir::Bottom => (middle_coord(width, "COLUMNS"), align_end_coord(height, "ROWS")), + PopupWindowDir::Left => (Size::Fixed(0), middle_coord(height, "ROWS")), + PopupWindowDir::Right => (align_end_coord(width, "COLUMNS"), middle_coord(height, "ROWS")), + }; + + cmd.args(["--height", &height.to_string()]) + .args(["--width", &width.to_string()]) + .args(["-x", &x.to_string()]) + .args(["-y", &y.to_string()]); + + Self { + cmd, + env: String::new(), + } + } +} + +impl SkimPopup for ZellijPopup { + fn from_options(options: &SkimOptions) -> Box { + Box::new(Self::build(options)) + } + + fn add_env(&mut self, key: &str, value: &str) { + let _ = write!( + self.env, + " {key}={}", + &String::from_utf8_lossy(&shell_quote::Sh::quote_vec(value)) + ); + } + + fn run_and_wait(&mut self, command: &str) -> std::io::Result { + debug!("zellij command: {command:?}"); + self.cmd + .arg("--") + .args(["sh", "-c", format!("{} {command}", self.env).trim()]); + debug!("zellij full command: {:?}", self.cmd); + + self.cmd + // .stdout(Stdio::null()) + // .stderr(Stdio::null()) + .stdin(Stdio::null()) + .status() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::SkimOptionsBuilder; + + /// Skip the test if `zellij` is not in PATH (CI environments without zellij). + macro_rules! require_zellij { + () => { + if which::which("zellij").is_err() { + return; + } + }; + } + + fn opts(popup: &str) -> crate::SkimOptions { + SkimOptionsBuilder::default() + .popup(popup) + .build() + .expect("valid options") + } + + fn args(popup: &ZellijPopup) -> Vec { + popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect() + } + + fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str()) + } + + // ── middle_coord ──────────────────────────────────────────────────────── + // Tests that mutate COLUMNS are annotated with #[serial] so they never run + // concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81 + // (edition 2024); the SAFETY invariant holds because #[serial] serialises + // access so no other thread reads the var while it is being written. + + #[test] + fn middle_coord_percent() { + // 50% wide in a 100% viewport → offset should be 25% + assert_eq!(middle_coord(Size::Percent(50), "COLUMNS"), Size::Percent(25)); + } + + #[test] + #[serial_test::serial] + fn middle_coord_fixed_uses_env_var() { + // SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS. + unsafe { std::env::set_var("COLUMNS", "80") }; + // 20 cols wide → offset = (80 - 20) / 2 = 30 + assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30)); + unsafe { std::env::remove_var("COLUMNS") }; + } + + #[test] + #[serial_test::serial] + fn middle_coord_fixed_fallback() { + // SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS. + unsafe { std::env::remove_var("COLUMNS") }; + // fallback width = 80; (80 - 20) / 2 = 30 + assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30)); + } + + // ── align_end_coord ────────────────────────────────────────────────────── + + #[test] + fn align_end_coord_percent() { + // 30% → end offset = 70% + assert_eq!(align_end_coord(Size::Percent(30), "COLUMNS"), Size::Percent(70)); + } + + #[test] + #[serial_test::serial] + fn align_end_coord_fixed_uses_env_var() { + // SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS. + unsafe { std::env::set_var("COLUMNS", "80") }; + // 20 cols wide → end offset = 80 - 20 = 60 + assert_eq!(align_end_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(60)); + unsafe { std::env::remove_var("COLUMNS") }; + } + + // ── from_options / build ───────────────────────────────────────────────── + + #[test] + fn center_default_size() { + require_zellij!(); + let popup = ZellijPopup::build(&opts("center")); + let a = args(&popup); + assert_eq!(get_flag(&a, "--height"), Some("50%")); + assert_eq!(get_flag(&a, "--width"), Some("50%")); + } + + #[test] + fn top_direction() { + require_zellij!(); + let popup = ZellijPopup::build(&opts("top,40%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "--height"), Some("40%")); + assert_eq!(get_flag(&a, "--width"), Some("100%")); + assert_eq!(get_flag(&a, "-y"), Some("0")); + } + + #[test] + fn left_direction() { + require_zellij!(); + let popup = ZellijPopup::build(&opts("left,30%")); + let a = args(&popup); + assert_eq!(get_flag(&a, "--width"), Some("30%")); + assert_eq!(get_flag(&a, "-x"), Some("0")); + } + + #[test] + #[serial_test::serial] + fn right_direction() { + require_zellij!(); + // SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS. + unsafe { std::env::set_var("COLUMNS", "80") }; + let popup = ZellijPopup::build(&opts("right,25%")); + let a = args(&popup); + // width = 25%, x = align_end_coord(25%, "COLUMNS") = 75% + assert_eq!(get_flag(&a, "--width"), Some("25%")); + assert_eq!(get_flag(&a, "-x"), Some("75%")); + unsafe { std::env::remove_var("COLUMNS") }; + } + + #[test] + fn borderless_when_no_border_option() { + require_zellij!(); + let popup = ZellijPopup::build(&opts("center")); + let a = args(&popup); + assert!(a.contains(&"--borderless".to_string())); + } + + #[test] + fn no_borderless_when_border_set() { + require_zellij!(); + let opts = SkimOptionsBuilder::default() + .popup("center") + .border(crate::tui::BorderType::Plain) + .build() + .expect("valid options"); + let popup = ZellijPopup::build(&opts); + let a = args(&popup); + assert!(!a.contains(&"--borderless".to_string())); + } + + #[test] + fn add_env_appends_to_env_string() { + require_zellij!(); + let mut popup = ZellijPopup::build(&opts("center")); + popup.add_env("FOO", "bar"); + popup.add_env("BAZ", "qux"); + assert_eq!(popup.env, " FOO=bar BAZ=qux"); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index b636527f..6d0487c1 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -259,7 +259,13 @@ impl App { let initial_header_height = header.height(); let layout_template = LayoutTemplate::from_options(&options, initial_header_height); let layout = layout_template.apply(Rect::default()); - let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS); + let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS); + if options.flags.contains(&crate::options::FeatureFlag::SingleReader) { + reader_threads = 1; + } + if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) { + matcher_threads = 1; + } Self { input: Input::from_options(&options, theme.clone()), preview: Preview::from_options(&options, theme.clone()), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c94a10ba..b3a4d084 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -112,6 +112,15 @@ impl Default for Size { } } +impl std::fmt::Display for Size { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Percent(p) => f.write_fmt(format_args!("{p}%")), + Self::Fixed(s) => f.write_fmt(format_args!("{s}")), + } + } +} + /// This mirrors Ratatui's border type /// /// We need it so that we can properly use `ValueEnum` diff --git a/tests/tmux.rs b/tests/popup.rs similarity index 66% rename from tests/tmux.rs rename to tests/popup.rs index acabd0b4..7fb80d3a 100644 --- a/tests/tmux.rs +++ b/tests/popup.rs @@ -42,6 +42,30 @@ fn get_tmux_cmd(outfile: &str) -> Result { Ok(cmd) } +/// Regression test: when --popup/--tmux is set via SKIM_DEFAULT_OPTIONS the child +/// sk process must not re-enter the popup path (infinite recursion guard). +#[test] +fn tmux_via_skim_default_options() -> Result<()> { + let tmux = TmuxController::new()?; + let outfile = setup_tmux_mock(&tmux)?; + // Run sk with SKIM_DEFAULT_OPTIONS=--tmux inline (bypassing the SK constant + // which always clears SKIM_DEFAULT_OPTIONS). + let sk_bin = crate::common::SK + .split_whitespace() + .last() + .expect("SK must have a binary path"); + let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {sk_bin}"); + tmux.send_keys(&[Str(&cmd), Enter])?; + tmux.until(|_| Path::new(&outfile).exists())?; + let cmd = get_tmux_cmd(&outfile)?; + // The parent should have opened exactly one popup; the child must not loop back. + assert!(cmd.starts_with("display-popup")); + // _SKIM_POPUP must be forwarded so the child knows it is already inside a popup + assert!(cmd.contains("_SKIM_POPUP=1")); + + Ok(()) +} + #[test] fn tmux_vanilla() -> Result<()> { let mut tmux = TmuxController::new()?; @@ -104,42 +128,7 @@ fn tmux_stdin() -> Result<()> { } #[test] -fn tmux_quote_bash() -> Result<()> { - let mut tmux = TmuxController::new()?; - let outfile = setup_tmux_mock(&tmux)?; - tmux.send_keys(&[Str("export SHELL=/bin/bash"), Enter])?; - tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?; - tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; - tmux.until(|_| Path::new(&outfile).exists())?; - let cmd = get_tmux_cmd(&outfile)?; - assert!(cmd.starts_with("display-popup")); - assert!(cmd.contains("-E")); - assert!(cmd.contains("--bind $'ctrl-a:reload(ls /foo*)'")); - assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;")); - - Ok(()) -} -#[test] -fn tmux_quote_zsh() -> Result<()> { - let mut tmux = TmuxController::new()?; - let outfile = setup_tmux_mock(&tmux)?; - tmux.send_keys(&[Str("export SHELL=/bin/zsh"), Enter])?; - tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?; - tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; - tmux.until(|_| Path::new(&outfile).exists())?; - let cmd = get_tmux_cmd(&outfile)?; - println!("{cmd}"); - assert!(cmd.starts_with("display-popup")); - assert!(cmd.contains("-E")); - assert!(cmd.contains( - "sk --bind $'ctrl-a:reload(ls /foo*)' --print-query --print-cmd --print-header --print-current --print-score >" - )); - assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;")); - - Ok(()) -} -#[test] -fn tmux_quote_sh() -> Result<()> { +fn tmux_quote() -> Result<()> { let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?; @@ -154,19 +143,3 @@ fn tmux_quote_sh() -> Result<()> { Ok(()) } -#[test] -fn tmux_quote_fish() -> Result<()> { - let mut tmux = TmuxController::new()?; - let outfile = setup_tmux_mock(&tmux)?; - tmux.send_keys(&[Str("export SHELL=/bin/fish"), Enter])?; - tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?; - tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; - tmux.until(|_| Path::new(&outfile).exists())?; - let cmd = get_tmux_cmd(&outfile)?; - assert!(cmd.starts_with("display-popup")); - assert!(cmd.contains("-E")); - assert!(cmd.contains("--bind ctrl-a':reload(ls /foo*)'")); - assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;")); - - Ok(()) -}