feat!: feature-gate listen and image to allow opting out (#1103)

* feat!: feature-gate listen and image to allow opting out

This is breaking since disabling the default features now also disables
those. It is NOT breaking for cli users, only for library ones.

* fix: add warn on listener transfer failure
This commit is contained in:
LoricAndre 2026-06-29 15:35:50 +02:00 committed by GitHub
parent aeba919fab
commit f2ca01e187
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 131 additions and 33 deletions

View file

@ -2,4 +2,8 @@ set -xeuo pipefail
cargo +nightly fmt --check --all
cargo clippy --all-targets -- -Dwarnings
cargo check --no-default-features
cargo clippy --no-default-features -- -Dwarnings
cargo clippy --no-default-features --features cli -- -Dwarnings
cargo clippy --no-default-features --features image -- -Dwarnings
cargo clippy --no-default-features --features listen -- -Dwarnings
cargo clippy --no-default-features --features frizbee -- -Dwarnings

View file

@ -138,6 +138,7 @@ jobs:
path: target/llvm-cov/html
deploy-coverage-page:
if: *if-master
needs: coverage
runs-on: ubuntu-latest
permissions:
@ -149,7 +150,6 @@ jobs:
steps:
- name: "Deploy gh pages"
id: deployment
if: *if-master
uses: actions/deploy-pages@v4
clippy:
@ -174,7 +174,7 @@ jobs:
run: |
cargo fmt --all -- --check
build-no-default-features:
clippy-no-default-features:
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
@ -182,9 +182,13 @@ jobs:
- *checkout
- *toolchain
- *cache
- name: Build without any feature
- name: Run clippy with specific features
run: |
cargo build --no-default-features
cargo clippy --no-default-features -- -Dwarnings
cargo clippy --no-default-features --features cli -- -Dwarnings
cargo clippy --no-default-features --features image -- -Dwarnings
cargo clippy --no-default-features --features listen -- -Dwarnings
cargo clippy --no-default-features --features frizbee -- -Dwarnings
msrv:

View file

@ -169,6 +169,17 @@ The single crate exports:
The `cli` feature gates `clap`, `clap_complete`, `shlex`, `env_logger`, and `clap_mangen`.
The `image` feature (enabled by default) gates image preview support, including the
`image` and `ratatui-image` dependencies, the `ImageProtocol` enum, the
`SkimOptions::image` / `SkimOptions::image_picker` fields, and the
`PreviewContent::Image` rendering path. With the feature off, the `--image` flag and
its supporting code are compiled out entirely and neither image crate is pulled in.
The `listen` feature (enabled by default) gates the IPC socket that lets other processes
drive skim via `--listen` / `--remote`, including the `interprocess`, `ron`, and `serde`
dependencies, the `SkimOptions::listen` / `SkimOptions::remote` fields, and the `serde`
derives on `Action`. See [IPC / Listen Socket](#ipc--listen-socket).
---
## Entry Points
@ -799,7 +810,7 @@ Pre-selection is applied when items first appear: `DefaultSkimSelector::should_s
**PTY mode** (`--preview-window pty`): creates a real pseudo-terminal pair via `portable_pty`. The child process sees a properly sized terminal (via `ROWS`/`COLUMNS` env and PTY dimensions). Output is parsed by a `vt100::Parser` with a scrollback buffer, stored as `PreviewContent::Terminal(Arc<RwLock<vt100::Parser>>)`. This enables interactive preview programs (e.g. `bat`, `delta`).
**Image mode** (`--image[=detect|halfblocks]`): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane.
**Image mode** (`--image[=detect|halfblocks]`, requires the default `image` feature): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane.
`Preview::spawn()`:
```
@ -996,6 +1007,14 @@ Exit codes: `0` = items selected, `1` = no items selected, `130` = abort, `135`
## IPC / Listen Socket
This subsystem is gated behind the default `listen` Cargo feature, which also pulls in the
`interprocess`, `ron`, and `serde` dependencies. With the feature off, the `--listen` /
`--remote` flags, the `Skim::listener` field, the `select!` listener branch, and the
`serde` derives on `Action` are all compiled out. The `select!` branch cannot carry a
`#[cfg]` attribute (tokio's macro rejects it), so it stays in place but its future becomes
a never-resolving `pending()` and its handler is unreachable (the stream type alias
`RemoteStream` is uninhabited).
When `--listen <socket_name>` is set, `Skim::init_listener()` creates an `interprocess` local socket. The main event loop's `select!` accepts connections and spawns Tokio tasks to read RON-encoded `Action` values line by line:
```

View file

@ -24,11 +24,15 @@ required-features = ["cli"]
[features]
# Default is destined to the CLI, not to library usage.
default = ["cli", "frizbee"]
default = ["cli", "frizbee", "image", "listen"]
# Everything needed to use skim as a cli (argument parsing, shell integrations...). This should not be needed for most libraries.
cli = ["dep:clap", "dep:clap_complete", "dep:shlex", "dep:env_logger", "dep:clap_mangen"]
cli = ["dep:clap", "dep:clap_complete", "dep:clap_complete_nushell", "dep:shlex", "dep:env_logger", "dep:clap_mangen"]
# Include frizbee as a matching algorithm
frizbee = ["dep:frizbee"]
# Enable image previews (renders the preview argument as an image)
image = ["dep:image", "dep:ratatui-image"]
# Enable the IPC socket (--listen / --remote), driving skim from other processes
listen = ["dep:interprocess", "dep:ron", "dep:serde"]
# Enable gungraun (Valgrind-based) benchmarks
gungraun = ["dep:gungraun"]
@ -61,7 +65,7 @@ ansi-to-tui = "8.0.1"
assert_enum_variants = "0.1.2"
clap = { version = "4.6.1" , optional = true, features = ["cargo", "derive", "unstable-markdown"] }
clap_complete = { version = "4.6.5", optional = true }
clap_complete_nushell = "4.6.0"
clap_complete_nushell = { version = "4.6.0", optional = true }
clap_mangen = { version = "0.3.0", optional = true }
color-eyre = "0.6.5"
# Crossterm's version is selected by ratatui
@ -71,9 +75,9 @@ derive_more = { version = "2.1.1", features = ["debug", "eq"] }
env_logger = { version = "0.11.10", optional = true, features = ["humantime"] }
futures = "0.3.32"
gungraun = { version = "0.19.1", optional = true }
image = "0.25.10"
image = { version = "0.25.10", optional = true }
indexmap = "2.13.1"
interprocess = { version = "2.4.2", features = ["tokio"] }
interprocess = { version = "2.4.2", features = ["tokio"], optional = true }
kanal = "0.1.1"
log = "0.4.31"
memchr = "2.8.1"
@ -81,11 +85,11 @@ mimalloc = { version = "0.1.48", features = ["v3"] }
nix = { version = "0.31.3", features = ["fs", "poll"] }
portable-pty = "0.9.0"
ratatui = "0.30.0"
ratatui-image = { version = "11.0.4", features = ["image-defaults", "crossterm"], default-features = false }
ratatui-image = { version = "11.0.4", features = ["image-defaults", "crossterm"], default-features = false, optional = true }
regex = "1.12.3"
roff = "1.1.1"
ron = "0.12.1"
serde = { version = "1.0.228", features = ["derive"] }
ron = { version = "0.12.1", optional = true }
serde = { version = "1.0.228", features = ["derive"], optional = true }
shell-quote = "0.7.2"
shlex = { version = "2.0.1", optional = true }
tempfile = "3.27.0"

View file

@ -11,10 +11,14 @@ extern crate skim;
use color_eyre::Result;
use color_eyre::eyre::eyre;
#[cfg(feature = "listen")]
use interprocess::bound_util::RefWrite;
#[cfg(feature = "listen")]
use interprocess::local_socket::ToNsName as _;
#[cfg(feature = "listen")]
use interprocess::local_socket::traits::Stream as _;
use log::trace;
#[cfg(feature = "listen")]
use skim::binds::parse_action_chain;
use skim::reader::CommandCollector;
use std::fs::File;
@ -90,6 +94,7 @@ fn main() -> Result<()> {
return Ok(());
}
#[cfg(feature = "listen")]
if let Some(remote) = opts.remote {
let ns_name = remote
.to_ns_name::<interprocess::local_socket::GenericNamespaced>()

View file

@ -184,6 +184,7 @@ const ACTIONS_SS: &str = "
* yank: ctrl-y
";
#[cfg(feature = "listen")]
const REMOTE_SECTION: &str = "
skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags.
@ -356,6 +357,7 @@ Example:
section(&mut custom, "THEME", THEME_SECTION);
#[cfg(feature = "listen")]
section(&mut custom, "LISTEN/REMOTE", REMOTE_SECTION);
section(&mut custom, "EXIT CODES", EXIT_CODES_SECTION);

View file

@ -8,6 +8,7 @@ use std::rc::Rc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use derive_builder::Builder;
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
use regex::Regex;
@ -62,6 +63,7 @@ pub enum MatchScheme {
}
/// Image rendering protocols
#[cfg(feature = "image")]
#[derive(Default, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum ImageProtocol {
@ -709,6 +711,7 @@ pub struct SkimOptions {
///
/// Note: the backend detection **will not** work when piping data into skim, use
/// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image`
#[cfg(feature = "image")]
#[cfg_attr(
feature = "cli",
arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..)
@ -717,6 +720,7 @@ pub struct SkimOptions {
/// Terminal image protocol picker, queried after entering the alternate screen.
/// Built from `options.image` and an stdio detection if needed
#[cfg(feature = "image")]
#[cfg_attr(feature = "cli", clap(skip))]
#[builder(setter(skip))]
#[debug(skip)]
@ -842,6 +846,7 @@ pub struct SkimOptions {
///
/// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions
/// To write to it, see the `--remote` option or the man page
#[cfg(feature = "listen")]
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
pub listen: Option<String>,
@ -850,6 +855,7 @@ pub struct SkimOptions {
/// The commands are read from stdin, one per line, in the same format as the actions in the
/// bind flag. They can also be chained using `+` as a separator.
/// All other arguments will be ignored
#[cfg(feature = "listen")]
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
pub remote: Option<String>,
@ -1056,7 +1062,9 @@ impl Default for SkimOptions {
no_strip_ansi: false,
wrap_items: false,
multiline: None,
#[cfg(feature = "listen")]
listen: None,
#[cfg(feature = "listen")]
remote: None,
print_header: false,
print_current: false,
@ -1115,7 +1123,9 @@ impl Default for SkimOptions {
cmd_history_size: 1000,
preview: Default::default(),
preview_window: PreviewLayout::default(),
#[cfg(feature = "image")]
image: None,
#[cfg(feature = "image")]
image_picker: None,
query: Default::default(),
cmd_query: Default::default(),

View file

@ -5,6 +5,7 @@ use std::time::Duration;
use color_eyre::eyre::{self, OptionExt, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
use tokio::runtime::Handle;
use tokio::select;
@ -15,6 +16,13 @@ use crate::tui::event::Action;
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
/// Stream type yielded by the IPC listener. With the `listen` feature disabled the
/// listener branch can never fire, so its payload type is uninhabited.
#[cfg(feature = "listen")]
type RemoteStream = interprocess::local_socket::tokio::Stream;
#[cfg(not(feature = "listen"))]
type RemoteStream = std::convert::Infallible;
/// Main entry point for running skim
pub struct Skim<Backend = ratatui::backend::CrosstermBackend<BufWriter<Stderr>>>
where
@ -29,6 +37,7 @@ where
initial_cmd: String,
reader_control: Option<ReaderControl>,
matcher_interval: Option<tokio::time::Interval>,
#[cfg(feature = "listen")]
listener: Option<interprocess::local_socket::tokio::Listener>,
final_event: Event,
final_key: KeyEvent,
@ -175,6 +184,7 @@ where
tui: None,
reader_control: None,
matcher_interval: None,
#[cfg(feature = "listen")]
listener: None,
final_event: Event::Quit,
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
@ -350,6 +360,7 @@ where
.expect("TUI needs to be initialized using Skim::init_tui before entering");
tui.enter_terminal()?;
#[cfg(feature = "image")]
if self.app.options.image == Some(crate::options::ImageProtocol::Detect) {
if !tui.is_fullscreen {
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
@ -466,7 +477,9 @@ where
/// Initialize the IPC socket listener
/// This needs to be called from an async context despite being sync
#[cfg_attr(not(feature = "listen"), allow(clippy::unnecessary_wraps, clippy::unused_self))]
fn init_listener(&mut self) -> Result<()> {
#[cfg(feature = "listen")]
if let Some(socket_name) = &self.app.options.listen {
self.listener = Some(
interprocess::local_socket::ListenerOptions::new()
@ -628,26 +641,38 @@ where
self.app.restart_matcher(false);
self.try_flush_render();
}
// IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
// branch, so the branch stays but its body is gated: with the `listen`
// feature off the future is a never-resolving `pending()` and the handler
// is unreachable (`RemoteStream` is uninhabited).
Ok(stream) = async {
match &self.listener {
Some(l) => interprocess::local_socket::traits::tokio::Listener::accept(l).await,
None => std::future::pending().await,
#[cfg(feature = "listen")]
if let Some(l) = self.listener.as_ref() {
return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
}
std::future::pending::<std::io::Result<RemoteStream>>().await
} => {
debug!("Listener accepted a connection");
let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
debug!("listener: got {line}");
if let Ok(act) = ron::from_str::<Action>(&line) {
debug!("listener: parsed into action {act:?}");
_ = event_tx_clone_ipc.try_send(Event::Action(act));
#[cfg(feature = "listen")]
{
debug!("Listener accepted a connection");
let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
debug!("listener: got {line}");
if let Ok(act) = ron::from_str::<Action>(&line) {
debug!("listener: parsed into action {act:?}");
if let Err(e) = event_tx_clone_ipc.try_send(Event::Action(act)) {
warn!("listener: failed to send action to backend: {e:?}");
}
}
}
}
});
});
}
#[cfg(not(feature = "listen"))]
match stream {}
}
}

View file

@ -466,7 +466,10 @@ impl App {
preview,
ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
);
#[cfg(feature = "image")]
let quote_cmd = self.options.image.is_none();
#[cfg(not(feature = "image"))]
let quote_cmd = true;
match preview {
ItemPreview::Command(cmd) => self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))?,
ItemPreview::Text(t) | ItemPreview::AnsiText(t) => {

View file

@ -151,7 +151,8 @@ pub enum Event {
}
/// Actions that can be performed in skim
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
pub enum Action {
/// Abort and exit with error
Abort,
@ -293,7 +294,7 @@ pub enum Action {
#[debug("custom")]
#[eq(skip)]
#[partial_eq(skip)]
#[serde(skip)]
#[cfg_attr(feature = "listen", serde(skip))]
Custom(ActionCallback),
}

View file

@ -6,7 +6,9 @@ use ratatui::prelude::Backend;
use ratatui::style::Stylize;
use ratatui::text::{Line, Text};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
#[cfg(feature = "image")]
use ratatui_image::protocol::Protocol as ImageProtocol;
use tui_term::vt100;
use tui_term::widget::PseudoTerminal;
@ -37,6 +39,7 @@ pub(crate) enum PreviewContent {
/// Terminal screen (for PTY previews with cursor positioning)
Terminal(Arc<RwLock<vt100::Parser>>),
/// Image
#[cfg(feature = "image")]
Image {
source: image::DynamicImage,
protocol: Option<ImageProtocol>,
@ -90,7 +93,9 @@ pub struct Preview {
pub wrap: bool,
pty: Option<PtyPair>,
pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
#[cfg(feature = "image")]
image: bool,
#[cfg(feature = "image")]
image_picker: Option<Picker>,
pub total_lines: u16,
loading: bool,
@ -104,6 +109,7 @@ impl Default for Preview {
}
impl Preview {
#[cfg(feature = "image")]
fn image_protocol(
picker: Option<&Picker>,
source: image::DynamicImage,
@ -138,6 +144,7 @@ impl Preview {
picker.new_protocol(source, size, ratatui_image::Resize::Scale(None))
}
#[cfg(feature = "image")]
pub(crate) fn set_image_picker(&mut self, picker: Option<Picker>) {
self.image_picker = picker;
}
@ -331,6 +338,7 @@ impl Preview {
let event_tx_clone = tui.event_tx.clone();
let content = self.content.clone();
#[cfg(feature = "image")]
if self.image {
let cmd = self.cmd.clone();
@ -606,6 +614,7 @@ impl Preview {
}
total_lines
}
#[cfg(feature = "image")]
fn render_image(
&self,
mut outer: Block,
@ -667,7 +676,9 @@ impl SkimWidget for Preview {
interrupt_tx: None,
pty: None,
pty_child: None,
#[cfg(feature = "image")]
image: options.image.is_some(),
#[cfg(feature = "image")]
image_picker: options.image_picker.clone(),
total_lines: 0,
loading: false,
@ -717,6 +728,7 @@ impl SkimWidget for Preview {
match &mut *content {
PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
#[cfg(feature = "image")]
PreviewContent::Image { source, protocol, size } => {
self.render_image(block, area, buf, source, protocol, size);
}

View file

@ -1,13 +1,18 @@
#[cfg(feature = "image")]
use image::{DynamicImage, RgbaImage};
#[cfg(feature = "image")]
use ratatui::layout::Size;
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
use super::Preview;
#[cfg(feature = "image")]
fn image(width: u32, height: u32) -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::new(width, height))
}
#[cfg(feature = "image")]
#[test]
fn image_protocol_constrains_by_width() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(400, 200), Size::new(20, 10))
@ -16,6 +21,7 @@ fn image_protocol_constrains_by_width() {
assert_eq!(protocol.size(), Size::new(20, 5));
}
#[cfg(feature = "image")]
#[test]
fn image_protocol_constrains_by_height() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(200, 400), Size::new(20, 10))
@ -24,6 +30,7 @@ fn image_protocol_constrains_by_height() {
assert_eq!(protocol.size(), Size::new(10, 10));
}
#[cfg(feature = "image")]
#[test]
fn image_protocol_keeps_at_least_one_cell() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(1000, 1), Size::new(1, 1))
@ -32,6 +39,7 @@ fn image_protocol_keeps_at_least_one_cell() {
assert_eq!(protocol.size(), Size::new(1, 1));
}
#[cfg(feature = "image")]
#[test]
fn image_protocol_uses_halfblocks_picker_when_none_is_provided() {
let protocol = Preview::image_protocol(None, image(400, 200), Size::new(20, 10))
@ -160,6 +168,7 @@ fn size_to_offset_resolves_each_variant() {
assert_eq!(p.size_to_offset(super::super::Size::Neg(10), false), 70);
}
#[cfg(feature = "image")]
#[test]
fn set_image_picker_sets_and_clears() {
let mut p = Preview::default();

View file

@ -1,7 +1,7 @@
// TODO: automate listen tests on windows
// Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing
#![allow(missing_docs, clippy::pedantic)]
#![cfg(unix)]
#![cfg(all(unix, feature = "listen"))]
#[allow(dead_code)]
#[macro_use]
mod common;