mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
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 <skim-bot@skim-rs.github.io> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
parent
a18e88aafd
commit
6b355e144a
|
|
@ -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<T>
|
||||
│ ├── 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 <sk command> > 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 <cmd> > stdout_file`
|
||||
- **Zellij**: `zellij action new-floating-pane … -- sh -c <cmd> > 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<dyn SkimPopup>;
|
||||
fn add_env(&mut self, key: &str, value: &str);
|
||||
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
|
||||
}
|
||||
```
|
||||
|
||||
`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<Action>` |
|
||||
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:~140` | Build engine factory chain from options |
|
||||
|
|
|
|||
42
Cargo.lock
generated
42
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)'] }
|
||||
|
|
|
|||
18
flake.nix
18
flake.nix
|
|
@ -3,23 +3,18 @@
|
|||
|
||||
inputs.nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
|
||||
|
||||
outputs =
|
||||
inputs:
|
||||
let
|
||||
outputs = inputs: let
|
||||
inherit (inputs.nixpkgs) lib;
|
||||
systems = lib.systems.flakeExposed;
|
||||
eachSystem = lib.genAttrs systems;
|
||||
pkgsFor =
|
||||
system:
|
||||
pkgsFor = system:
|
||||
import inputs.nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) ["vagrant"];
|
||||
};
|
||||
in
|
||||
{
|
||||
in {
|
||||
devShells = eachSystem (
|
||||
system:
|
||||
let
|
||||
system: let
|
||||
pkgs = pkgsFor system;
|
||||
|
||||
# --- package groups -------------------------------------------------------
|
||||
|
|
@ -39,6 +34,8 @@
|
|||
cargo-public-api
|
||||
git-cliff
|
||||
cargo-dist
|
||||
cargo-cross
|
||||
cargo-xwin
|
||||
];
|
||||
gungraun = with pkgs; [
|
||||
valgrind
|
||||
|
|
@ -60,8 +57,7 @@
|
|||
'';
|
||||
|
||||
mkShell = packages: shellHook: pkgs.mkShellNoCC {inherit packages shellHook;};
|
||||
in
|
||||
{
|
||||
in {
|
||||
default = mkShell base "";
|
||||
tests = mkShell (base ++ tests) "";
|
||||
utils = mkShell (base ++ utils) "";
|
||||
|
|
|
|||
|
|
@ -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<TMUX>...\fR]
|
||||
Run in a tmux popup
|
||||
\fB\-\-popup\fR [\fI<POPUP>...\fR]
|
||||
Run in a tmux or zellij popup
|
||||
|
||||
Format: `sk \-\-tmux <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
|
||||
|
||||
Depending on the direction, the order and behavior of the sizes varies:
|
||||
|
||||
Default: center,50%
|
||||
Ignored on Windows
|
||||
Format: `sk \-\-popup <center|top|bottom|left|right>[,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<HISTORY_FILE>\fR
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' \
|
||||
|
|
|
|||
|
|
@ -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<Option<SkimOutput>> {
|
||||
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<Option<SkimOutput>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
|
||||
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<i32> {
|
|||
//------------------------------------------------------------------------------
|
||||
// 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<i32> {
|
|||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dyn SkimItem>]> = items.into();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
||||
/// Run in a tmux popup
|
||||
/// Run in a tmux or zellij popup
|
||||
///
|
||||
/// Format: `sk --tmux <center|top|bottom|left|right>[,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<String>,
|
||||
/// Format: `sk --popup <center|top|bottom|left|right>[,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<String>,
|
||||
|
||||
/// 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)]
|
||||
|
|
|
|||
|
|
@ -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<dyn SkimPopup>
|
||||
where
|
||||
Self: Sized;
|
||||
fn add_env(&mut self, key: &str, value: &str);
|
||||
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
|
||||
}
|
||||
|
||||
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<SkimOutput> {
|
||||
// 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<SkimOutput> {
|
|||
};
|
||||
|
||||
// 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<SkimOutput> {
|
|||
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<SkimOutput> {
|
|||
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<SkimOutput> {
|
|||
"--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<dyn SkimPopup> = 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<SkimOutput> {
|
|||
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<SkimOutput> {
|
|||
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<SkimOutput> {
|
|||
..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<SkimOutput> {
|
|||
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<SkimOutput> {
|
|||
}
|
||||
|
||||
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<u8> = 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") };
|
||||
}
|
||||
}
|
||||
239
src/popup/tmux.rs
Normal file
239
src/popup/tmux.rs
Normal file
|
|
@ -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<dyn SkimPopup> {
|
||||
Box::new(Self::build(options)) as Box<dyn SkimPopup>
|
||||
}
|
||||
|
||||
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<ExitStatus> {
|
||||
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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
288
src/popup/zellij.rs
Normal file
288
src/popup/zellij.rs
Normal file
|
|
@ -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<dyn SkimPopup> {
|
||||
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<ExitStatus> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -42,6 +42,30 @@ fn get_tmux_cmd(outfile: &str) -> Result<String> {
|
|||
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(())
|
||||
}
|
||||
Loading…
Reference in a new issue