Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,8 @@ A panic hook is installed once (`PANIC_HOOK_SET: Once`) to ensure `cleanup_termi

Two subtleties make the resume correct. First, `Tui::start` installs a **fresh** `CancellationToken` on every call: a token stays cancelled once cancelled, so reusing the one `stop_and_join` cancelled would make the respawned reader observe the cancellation immediately and exit without reading input. Second, the post-execute repaint uses `Tui::force_full_redraw` (which resets both of ratatui's diff buffers) followed by `Event::Render`, rather than `Event::Redraw`/`tui.clear()`: ratatui's `Terminal::clear` first queries the cursor position, and crossterm writes that query (`ESC [ 6 n`) to **stdout**. Since skim renders to stderr and its stdout is routinely redirected (`sk > file`, `find | sk | …`), that query would reach no terminal, get no reply, and stall the UI for seconds before erroring out. `force_full_redraw` performs no cursor query and works for both fullscreen and inline viewports.

**Child-process environment (issue #1195):** `App::child_env()` snapshots the TUI state (query, current and selected item text, counts, cursor position, area geometry, list direction, input state, prompt/pointer, item wrap, preview geometry, last action and key) and returns it as `(name, value)` pairs. `App::handle_action` records the canonical name of the last action, and `App::handle_key` records the fzf-style name of the last real key press (via `binds::key_name`); synthetic events routed through the keymap do not overwrite it. Every value is exported under both a `SKIM_` and an `FZF_` prefix so fzf-style scripts work unchanged. Variables that do not apply are omitted rather than exported empty, matching fzf: `CURRENT_ITEM` when there is no current item, `NTH`/`WITH_NTH` when unset, `WRAP` when wrapping is off, and the `PREVIEW_*` group when no preview is configured. `App::child_env_unset` returns the names the snapshot omits, and `tui::util::apply_child_env` removes them from the inherited environment before setting the pairs, so an enclosing skim/fzf process cannot leak stale values into a child. `Preview::spawn` and `Tui::run_execute` call it, `execute-silent` calls it inline, and the reload / `--cmd` path forwards both lists through `Reader::set_child_env` → `CommandCollector::set_env` (a defaulted trait method), which `SkimItemReader` applies to the command it spawns. `Skim::start` and `Skim::handle_reload` snapshot the state *before* the reader and list are cleared, so the first command and a reload both see the state that triggered them; `execute` captures its snapshot when it is dispatched (`App::pending_execute_env`) so a later action cannot overwrite it. `LINES`/`COLUMNS` come from `App::term_area`, recorded on each render.

Image preview protocol detection is owned by `Skim::enter()`, not `Tui::enter_terminal()`: `--image=detect` temporarily ensures an alternate screen is active, queries `ratatui_image::picker::Picker::from_query_stdio()`, falls back to `Picker::halfblocks()` on failure, then stores the picker in both `SkimOptions.image_picker` and the `Preview` widget. `--image=halfblocks` skips detection and installs `Picker::halfblocks()` directly.

### Event Loop
Expand Down Expand Up @@ -715,6 +717,7 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event
| `layout` | `AppLayout` | Last-frame widget areas (updated in render) |
| `needs_render` | `Arc<AtomicBool>` | Signal from matcher → event loop |
| `yank_register` | `String` | Cut/yank buffer |
| `term_area` | `Rect` | Area recorded on every render; source of `LINES`/`COLUMNS` in `child_env` |
| `query_history` / `cmd_history` | `Vec<String>` | History for ↑/↓ navigation |
| `last_left_click` | `Option<Instant>` | Detect two left clicks within the 500 ms `double-click` window |

Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,43 @@ You can configure key bindings to start external processes without leaving Skim
sk --bind 'f1:execute(less -f {}),ctrl-y:execute-silent(echo {} | pbcopy)+abort'
```

### Environment variables exported to child processes

Commands run by `--preview`, `execute`, `execute-silent` and `reload` inherit a snapshot of
skim's state as environment variables. Each value is exported twice: once under a `SKIM_`
prefix and once under the same name with an `FZF_` prefix, so fzf-style scripts work
unchanged. This avoids quoting issues and keeps commands short.

| Variable | Value |
|---|---|
| `QUERY` | Current query input |
| `CURRENT_ITEM` | Text of the current item (ANSI-stripped); unset if there is none |
| `SELECTED_ITEMS` | Selected items, newline-separated |
| `POS` | 1-based position of the current item |
| `TOTAL_COUNT` | Total number of items |
| `MATCH_COUNT` | Number of matched items |
| `SELECT_COUNT` | Number of selected items |
| `LINES`, `COLUMNS` | Height and width of the skim area |
| `DIRECTION` | List direction (`up` or `down`) |
| `INPUT_STATE` | `enabled` or `disabled` |
| `PROMPT` | Current prompt string |
| `POINTER` | Current pointer (selector) string |
| `ACTION`, `KEY` | Name of the running action and of the last key pressed (empty before any input); a follow-up such as `act-up:execute-silent(...)` reports `execute-silent` |
| `WRAP` | `char` when item wrapping is on |
| `PREVIEW_LINES`, `PREVIEW_COLUMNS`, `PREVIEW_TOP`, `PREVIEW_LEFT` | Geometry of the preview window (only set when a preview is configured) |
| `NTH`, `WITH_NTH` | Current `--nth` and `--with-nth` values (only set when configured) |

```sh
# Equivalent to --preview 'echo {}', without the quoting pitfalls of {}
sk --preview 'printf "%s" "$SKIM_CURRENT_ITEM"'
```

A variable that does not apply to the current state (no preview, no `--nth`, an oversized value,
…) is removed from the child's environment instead of being left at its inherited value, so a skim
started inside another skim or fzf never leaks the enclosing process's values into its children.
The same variables are passed to the very first `--cmd` and to a `reload`, the latter capturing the
focused and selected items as they were when the reload was triggered.

## Algorithms

Skim offers multiple algorithms, check the help or manpage for an exhaustive list. Among them are:
Expand Down
12 changes: 11 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@
export VAGRANT_LIBVIRT_OVMF_CODE="${pkgs.OVMF.fd}/FV/OVMF_CODE.fd"
'';

mkShell = packages: shellHook: pkgs.mkShell { inherit packages shellHook; };
mkShell =
packages: shellHook:
pkgs.mkShell {
inherit packages;
shellHook = ''
echo "Making sure the rust toolchains are available"
${lib.getExe pkgs.rustup} -q install --no-update --no-self-update
${lib.getExe pkgs.rustup} -q install --no-update --no-self-update nightly
Comment thread
LoricAndre marked this conversation as resolved.
Resolved
${shellHook}
'';
};
in
{
default = mkShell base "";
Expand Down
54 changes: 54 additions & 0 deletions man/man1/sk.1
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,10 @@ In the `preview` flag, `execute`, `reload`, `set\-query`... binds, sk will expan
item(s).
.br

.br
Instead of placeholders, child commands can also read the same state from the `SKIM_*`/`FZF_*` variables listed under ENVIRONMENT VARIABLES.
.br

.SS "Field index expression"

.br
Expand Down Expand Up @@ -1204,6 +1208,56 @@ Example:
If set to a non\-empty value, will disable coloring
.br

.SS "Variables exported to child processes"

.br
Commands spawned by `\-\-preview`, `execute`, `execute\-silent` and `reload` inherit these variables.
.br
Every value is exported twice: under a `SKIM_` prefix and under the same name with an `FZF_` prefix.
.br
* `QUERY`: current query input.
.br
* `CURRENT_ITEM`: text of the current item, with ANSI sequences stripped; unset when there is none.
.br
* `SELECTED_ITEMS`: selected items, newline\-separated.
.br
* `POS`: 1\-based position of the current item in the list.
.br
* `TOTAL_COUNT`: total number of items.
.br
* `MATCH_COUNT`: number of matched items.
.br
* `SELECT_COUNT`: number of selected items.
.br
* `LINES`, `COLUMNS`: height and width of the skim area.
.br
* `DIRECTION`: list direction, `up` or `down`.
.br
* `INPUT_STATE`: `enabled` or `disabled`.
.br
* `PROMPT`, `POINTER`: current prompt and pointer (selector) strings.
.br
* `ACTION`, `KEY`: name of the last action performed and last key pressed (empty before any input).
.br
`ACTION` is the action that is running, so a child spawned by a follow\-up
.br
(`act\-up:execute\-silent(...)`) sees `execute\-silent`.
.br
* `WRAP`: `char` when item wrapping is enabled, otherwise unset.
.br
* `PREVIEW_LINES`, `PREVIEW_COLUMNS`, `PREVIEW_TOP`, `PREVIEW_LEFT`: geometry of the preview
.br
window; only set when a preview is configured.
.br
* `NTH`, `WITH_NTH`: current `\-\-nth` and `\-\-with\-nth` values, when set.
.br
A variable that does not apply to the current state is removed from the child\*(Aqs environment, so a
.br
skim started inside another skim or fzf never passes the enclosing process\*(Aqs values through.
.br
Example: `sk \-\-preview \*(Aqprintf %s "$SKIM_QUERY"\*(Aq`.
.br

.SH THEME

.br
Expand Down
96 changes: 96 additions & 0 deletions src/binds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,40 @@ impl SkimEvent {
_ => None,
}
}

/// The event's name, as accepted by [`Self::from_name`].
#[must_use]
pub const fn name(self) -> &'static str {
match self {
SkimEvent::Start => "start",
SkimEvent::Load => "load",
SkimEvent::Change => "change",
SkimEvent::Result => "result",
SkimEvent::Focus => "focus",
SkimEvent::Zero => "zero",
SkimEvent::One => "one",
SkimEvent::DoubleClick => "double-click",
}
}

/// Reverse of [`Self::key_code`]: the event carried by a reserved key code.
///
/// Returns `None` for real keys, so callers can tell a key press from a
/// synthetic event routed through the keymap.
#[must_use]
pub const fn from_key_code(code: KeyCode) -> Option<Self> {
match code {
KeyCode::F(255) => Some(SkimEvent::Change),
KeyCode::F(254) => Some(SkimEvent::Start),
KeyCode::F(253) => Some(SkimEvent::Load),
KeyCode::F(252) => Some(SkimEvent::Result),
KeyCode::F(251) => Some(SkimEvent::Focus),
KeyCode::F(250) => Some(SkimEvent::Zero),
KeyCode::F(249) => Some(SkimEvent::One),
KeyCode::F(248) => Some(SkimEvent::DoubleClick),
_ => None,
}
}
}

impl From<SkimEvent> for KeyEvent {
Expand Down Expand Up @@ -289,6 +323,68 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
Ok(KeyEvent::new(keycode, mods))
}

/// Formats a key event as an fzf-style name (`ctrl-a`, `alt-b`, `shift-tab`,
/// `up`, `f1`, `space`).
///
/// Synthetic `SkimEvent`s are named after the event (see
/// [`SkimEvent::from_key_code`]). This is the value exported as
/// `SKIM_KEY`/`FZF_KEY` to child processes.
#[must_use]
pub fn key_name(key: &KeyEvent) -> String {
if let Some(event) = SkimEvent::from_key_code(key.code) {
return event.name().to_string();
}

let mut name = String::new();
if key.modifiers.contains(KeyModifiers::CONTROL) {
name.push_str("ctrl-");
}
if key.modifiers.contains(KeyModifiers::ALT) {
name.push_str("alt-");
}
// `BackTab` already means shift-tab, so don't double it up.
if key.modifiers.contains(KeyModifiers::SHIFT) && !matches!(key.code, KeyCode::Char(_) | KeyCode::BackTab) {
name.push_str("shift-");
}

let body = match key.code {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => {
// `parse_key` folds a shift into an upper-case letter, so reproduce
// that; a shift on a non-letter keeps the explicit prefix.
if key.modifiers.contains(KeyModifiers::SHIFT) {
if c.is_ascii_alphabetic() {
c.to_ascii_uppercase().to_string()
} else {
name.push_str("shift-");
c.to_string()
}
} else {
c.to_string()
}
}
KeyCode::Enter => "enter".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "shift-tab".to_string(),
Comment thread
LoricAndre marked this conversation as resolved.
Resolved
KeyCode::Esc => "esc".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "page-up".to_string(),
KeyCode::PageDown => "page-down".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::F(n) => format!("f{n}"),
other => format!("{other:?}").to_lowercase(),
};
name.push_str(&body);
name
}

/// Parse an iterator of keymaps into a `KeyMap`
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
where
Expand Down
47 changes: 47 additions & 0 deletions src/binds_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,53 @@ fn skim_event_name_roundtrip() {
}
}

#[test]
fn key_name_formats_fzf_style() {
// Canonical names round-trip through `parse_key`.
for name in [
"a",
"A",
"1",
"ctrl-a",
"alt-b",
"ctrl-alt-c",
"ctrl-space",
"space",
"alt-enter",
"shift-tab",
"esc",
"up",
"down",
"left",
"right",
"home",
"end",
"f10",
] {
let key = parse_key(name).unwrap();
assert_eq!(key_name(&key), name, "{name}");
}
// Keys that `parse_key` only knows by an alias still get the canonical name.
assert_eq!(key_name(&parse_key("pgup").unwrap()), "page-up");
assert_eq!(key_name(&parse_key("bspace").unwrap()), "backspace");
assert_eq!(key_name(&parse_key("alt-bspace").unwrap()), "alt-backspace");
// A shift on a non-character key keeps the prefix.
assert_eq!(key_name(&KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)), "shift-tab");
// Synthetic events report their event name.
for event in [
SkimEvent::Start,
SkimEvent::Load,
SkimEvent::Change,
SkimEvent::Result,
SkimEvent::Focus,
SkimEvent::Zero,
SkimEvent::One,
SkimEvent::DoubleClick,
] {
assert_eq!(key_name(&event.key_event()), event.name());
}
}

#[test]
fn double_click_accepts_by_default() {
assert_eq!(
Expand Down
25 changes: 23 additions & 2 deletions src/helper/item_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ pub struct SkimItemReader {
/// [`with_thread_pool`](Self::with_thread_pool) or
/// [`set_thread_pool`](Self::set_thread_pool).
thread_pool: Arc<ThreadPool>,
/// Environment variables exported to the spawned command (see issue #1195).
env: Vec<(String, String)>,
/// State variables to remove from the inherited environment before spawning,
/// because the current snapshot omits them (see issue #1195).
env_unset: Vec<String>,
}

fn default_thread_pool() -> Arc<ThreadPool> {
Expand All @@ -209,6 +214,8 @@ impl Default for SkimItemReader {
Self {
option: Arc::new(Default::default()),
thread_pool: default_thread_pool(),
env: Vec::new(),
env_unset: Vec::new(),
}
}
}
Expand All @@ -220,6 +227,8 @@ impl SkimItemReader {
Self {
option: Arc::new(option),
thread_pool: default_thread_pool(),
env: Vec::new(),
env_unset: Vec::new(),
}
}

Expand Down Expand Up @@ -527,20 +536,32 @@ impl CommandCollector for SkimItemReader {
components_to_stop: Arc<AtomicUsize>,
) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
let send_error = self.option.show_error;
let (child, source) = get_command_output(cmd, send_error).expect("command not found");
let (child, source) =
get_command_output(cmd, send_error, &self.env, &self.env_unset).expect("command not found");
self.parallel_bufread(source, child, &components_to_stop)
}

fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
self.thread_pool = pool;
}

fn set_env(&mut self, env: &[(String, String)], unset: &[String]) {
self.env = env.to_vec();
self.env_unset = unset.to_vec();
}
}

type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);

fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
fn get_command_output(
cmd: &str,
send_error: bool,
env: &[(String, String)],
unset: &[String],
) -> Result<CommandOutput, Box<dyn Error>> {
let (reader, writer) = std::io::pipe()?;
let mut command = crate::shell_cmd(cmd);
crate::tui::util::apply_child_env(&mut command, env, unset);
command.stdout(writer.try_clone()?);
if send_error {
trace!("redirecting stderr to the output");
Expand Down
14 changes: 14 additions & 0 deletions src/helper/item_reader_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ fn invoke_with_show_error_redirects_stderr() {
assert_eq!(drain(rx), vec!["oops"]);
}

#[test]
fn invoke_exports_child_env() {
// `set_env` (issue #1195) makes the spawned command inherit the variables.
let cmd = if cfg!(windows) {
"echo %SKIM_QUERY%"
} else {
"printf '%s' \"$SKIM_QUERY\""
};
let mut reader = SkimItemReader::default();
reader.set_env(&[("SKIM_QUERY".to_string(), "hi".to_string())], &[]);
let (rx, _tx) = reader.invoke(cmd, Arc::new(AtomicUsize::new(0)));
assert_eq!(drain(rx), vec!["hi"]);
}

#[test]
fn read0_false_restores_newline_ending() {
let opt = SkimItemReaderOption::default().read0(true).read0(false).build();
Expand Down
Loading
Loading