Skip to main content

smux/
fzf.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result, bail};
7
8use crate::process::{CommandRunner, default_runner};
9use crate::ui::DisplayStyle;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum EntryKind {
13    Session,
14    Directory,
15    Project,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct Entry {
20    pub kind: EntryKind,
21    pub label: String,
22    pub value: String,
23}
24
25impl Entry {
26    pub fn session(style: DisplayStyle, value: String) -> Self {
27        Self {
28            kind: EntryKind::Session,
29            label: style.session_label(&value),
30            value,
31        }
32    }
33
34    pub fn directory(style: DisplayStyle, value: String) -> Self {
35        Self {
36            kind: EntryKind::Directory,
37            label: style.directory_label(&value),
38            value,
39        }
40    }
41
42    pub fn project(style: DisplayStyle, value: String) -> Self {
43        Self {
44            kind: EntryKind::Project,
45            label: style.project_label(&value),
46            value,
47        }
48    }
49
50    fn encode(&self) -> String {
51        let kind = match self.kind {
52            EntryKind::Session => "session",
53            EntryKind::Directory => "folder",
54            EntryKind::Project => "project",
55        };
56
57        format!("{kind}\t{}\t{}", self.value, self.label)
58    }
59
60    fn decode(line: &str) -> Result<Self> {
61        let mut parts = line.splitn(3, '\t');
62        let kind = parts.next().context("missing entry kind")?;
63        let value = parts.next().context("missing entry value")?.to_owned();
64        let label = parts.next().context("missing entry label")?.to_owned();
65
66        let kind = match kind {
67            "session" => EntryKind::Session,
68            "folder" => EntryKind::Directory,
69            "project" => EntryKind::Project,
70            other => bail!("unknown picker entry kind: {other}"),
71        };
72
73        Ok(Self { kind, label, value })
74    }
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct Choice {
79    pub kind: String,
80    pub label: String,
81    pub value: String,
82}
83
84impl Choice {
85    pub fn new(kind: impl Into<String>, label: String, value: String) -> Self {
86        Self {
87            kind: kind.into(),
88            label,
89            value,
90        }
91    }
92
93    fn encode(&self) -> String {
94        format!("{}\t{}\t{}", self.kind, self.value, self.label)
95    }
96
97    fn decode(line: &str) -> Result<Self> {
98        let mut parts = line.splitn(3, '\t');
99        let kind = parts.next().context("missing choice kind")?.to_owned();
100        let value = parts.next().context("missing choice value")?.to_owned();
101        let label = parts.next().context("missing choice label")?.to_owned();
102        Ok(Self { kind, label, value })
103    }
104}
105
106pub fn select(entries: Vec<Entry>) -> Result<Option<Entry>> {
107    select_with_runner(default_runner(), entries, "smux> ")
108}
109
110pub fn select_value(prompt: &str, choices: Vec<Choice>) -> Result<Option<String>> {
111    select_value_with_runner(default_runner(), prompt, choices)
112}
113
114struct TempInputFile {
115    path: PathBuf,
116}
117
118impl TempInputFile {
119    fn new(contents: &str) -> Result<Self> {
120        let mut path = std::env::temp_dir();
121        let nanos = SystemTime::now()
122            .duration_since(UNIX_EPOCH)
123            .context("system clock should be after unix epoch")?
124            .as_nanos();
125        path.push(format!("smux-fzf-{}-{nanos}.tsv", std::process::id()));
126        fs::write(&path, contents)
127            .with_context(|| format!("failed to write {}", path.display()))?;
128        Ok(Self { path })
129    }
130
131    fn shell_quoted_path(&self) -> String {
132        shell_quote(&self.path)
133    }
134}
135
136impl Drop for TempInputFile {
137    fn drop(&mut self) {
138        let _ = fs::remove_file(&self.path);
139    }
140}
141
142fn shell_quote(path: &Path) -> String {
143    let value = path.to_string_lossy();
144    format!("'{}'", value.replace('\'', "'\\''"))
145}
146
147fn cat_command(file: &TempInputFile) -> String {
148    format!("cat {}", file.shell_quoted_path())
149}
150
151fn filter_command(file: &TempInputFile, kind: &str) -> String {
152    format!(
153        "awk -F '\\t' '$1 == \"{kind}\"' {}",
154        file.shell_quoted_path()
155    )
156}
157
158fn add_common_picker_args(args: &mut Vec<String>, prompt: &str, header: &str, bindings: &str) {
159    args.extend([
160        "--ansi".to_owned(),
161        "--delimiter".to_owned(),
162        "\t".to_owned(),
163        "--layout".to_owned(),
164        "reverse".to_owned(),
165        "--header".to_owned(),
166        header.to_owned(),
167        "--bind".to_owned(),
168        "tab:down,btab:up".to_owned(),
169        "--bind".to_owned(),
170        bindings.to_owned(),
171        "--with-nth".to_owned(),
172        "3".to_owned(),
173        "--nth".to_owned(),
174        "1,2".to_owned(),
175        "--prompt".to_owned(),
176        prompt.to_owned(),
177        "--no-sort".to_owned(),
178    ]);
179}
180
181fn select_value_with_runner(
182    runner: Arc<dyn CommandRunner>,
183    prompt: &str,
184    choices: Vec<Choice>,
185) -> Result<Option<String>> {
186    let mut args = Vec::new();
187    let input = choices
188        .into_iter()
189        .map(|choice| choice.encode())
190        .collect::<Vec<_>>()
191        .join("\n")
192        + "\n";
193    let input_file = TempInputFile::new(&input)?;
194    let all_command = cat_command(&input_file);
195    let template_command = filter_command(&input_file, "template");
196    add_common_picker_args(
197        &mut args,
198        prompt,
199        "ctrl-x all  ctrl-t templates",
200        &format!(
201            "ctrl-x:change-prompt(template> )+clear-query+reload({all_command}),ctrl-t:change-prompt(template> )+clear-query+reload({template_command})"
202        ),
203    );
204    let output = runner
205        .run_capture_with_input("fzf", &args, &input)
206        .context("failed to launch fzf")?;
207
208    if output.status.code == Some(130) {
209        return Ok(None);
210    }
211
212    if !output.status.success {
213        bail!("fzf exited with status {:?}", output.status.code);
214    }
215
216    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
217    let selection = selection.trim_end();
218
219    if selection.is_empty() {
220        return Ok(None);
221    }
222
223    Ok(Some(Choice::decode(selection)?.value))
224}
225
226fn select_with_runner(
227    runner: Arc<dyn CommandRunner>,
228    entries: Vec<Entry>,
229    prompt: &str,
230) -> Result<Option<Entry>> {
231    let mut args = Vec::new();
232    let input = entries
233        .into_iter()
234        .map(|entry| entry.encode())
235        .collect::<Vec<_>>()
236        .join("\n")
237        + "\n";
238    let input_file = TempInputFile::new(&input)?;
239    let all_command = cat_command(&input_file);
240    let session_command = filter_command(&input_file, "session");
241    let folder_command = filter_command(&input_file, "folder");
242    let project_command = filter_command(&input_file, "project");
243    add_common_picker_args(
244        &mut args,
245        prompt,
246        "ctrl-x all  ctrl-s sessions  ctrl-f folders  ctrl-p projects",
247        &format!(
248            "ctrl-x:change-prompt(smux> )+clear-query+reload({all_command}),ctrl-s:change-prompt(session> )+clear-query+reload({session_command}),ctrl-f:change-prompt(folder> )+clear-query+reload({folder_command}),ctrl-p:change-prompt(project> )+clear-query+reload({project_command})"
249        ),
250    );
251    let output = runner
252        .run_capture_with_input("fzf", &args, &input)
253        .context("failed to launch fzf")?;
254
255    if output.status.code == Some(130) {
256        return Ok(None);
257    }
258
259    if !output.status.success {
260        bail!("fzf exited with status {:?}", output.status.code);
261    }
262
263    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
264    let selection = selection.trim_end();
265
266    if selection.is_empty() {
267        return Ok(None);
268    }
269
270    Ok(Some(Entry::decode(selection)?))
271}
272
273#[cfg(test)]
274mod tests {
275    use std::sync::Arc;
276
277    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
278
279    use super::{Choice, Entry, EntryKind, select_value_with_runner, select_with_runner};
280    use crate::config::IconMode;
281    use crate::ui::DisplayStyle;
282
283    #[test]
284    fn entry_round_trip() {
285        let entry = Entry {
286            kind: EntryKind::Directory,
287            label: "dir      /tmp/example".to_owned(),
288            value: "/tmp/example".to_owned(),
289        };
290
291        let decoded = Entry::decode(&entry.encode()).expect("entry should decode");
292        assert_eq!(decoded, entry);
293    }
294
295    #[test]
296    fn selector_passes_entries_to_fzf() {
297        let runner = Arc::new(FakeCommandRunner::new());
298        runner.push_capture(Ok(CommandOutput {
299            status: CommandStatus {
300                success: true,
301                code: Some(0),
302            },
303            stdout: b"folder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
304            stderr: Vec::new(),
305        }));
306
307        let result = select_with_runner(
308            runner.clone(),
309            vec![Entry::directory(
310                DisplayStyle::from_icon_mode(IconMode::Never),
311                "/tmp/example".to_owned(),
312            )],
313            "smux> ",
314        )
315        .expect("selection should succeed");
316
317        assert!(result.is_some());
318        let recorded = runner.recorded();
319        assert_eq!(recorded[0].program, "fzf");
320        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
321        assert!(recorded[0].args.contains(&"reverse".to_owned()));
322        assert!(recorded[0].args.contains(&"3".to_owned()));
323        assert!(recorded[0].args.contains(&"1,2".to_owned()));
324        assert!(
325            recorded[0]
326                .args
327                .iter()
328                .any(|arg| arg.contains("ctrl-s sessions"))
329        );
330        assert!(
331            recorded[0]
332                .args
333                .iter()
334                .any(|arg| arg.contains("ctrl-f:change-prompt(folder> )+clear-query+reload("))
335        );
336        assert!(
337            recorded[0]
338                .args
339                .iter()
340                .any(|arg| arg.contains("ctrl-p projects"))
341        );
342        assert!(
343            recorded[0]
344                .args
345                .iter()
346                .any(|arg| arg.contains("ctrl-p:change-prompt(project> )+clear-query+reload("))
347        );
348        assert_eq!(
349            recorded[0].stdin.as_deref(),
350            Some("folder\t/tmp/example\tdir      /tmp/example\n")
351        );
352    }
353
354    #[test]
355    fn template_selector_returns_selected_value() {
356        let runner = Arc::new(FakeCommandRunner::new());
357        runner.push_capture(Ok(CommandOutput {
358            status: CommandStatus {
359                success: true,
360                code: Some(0),
361            },
362            stdout: b"template\trust\ttemplate rust\n".to_vec(),
363            stderr: Vec::new(),
364        }));
365
366        let result = select_value_with_runner(
367            runner.clone(),
368            "template> ",
369            vec![
370                Choice::new(
371                    "template",
372                    "template default".to_owned(),
373                    "default".to_owned(),
374                ),
375                Choice::new("template", "template rust".to_owned(), "rust".to_owned()),
376            ],
377        )
378        .expect("selection should succeed");
379
380        assert_eq!(result.as_deref(), Some("rust"));
381        let recorded = runner.recorded();
382        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
383        assert!(recorded[0].args.contains(&"reverse".to_owned()));
384        assert!(recorded[0].args.contains(&"3".to_owned()));
385        assert!(recorded[0].args.contains(&"1,2".to_owned()));
386        assert!(
387            recorded[0]
388                .args
389                .iter()
390                .any(|arg| arg.contains("ctrl-t templates"))
391        );
392        assert!(
393            recorded[0]
394                .args
395                .iter()
396                .any(|arg| arg.contains("ctrl-t:change-prompt(template> )+clear-query+reload("))
397        );
398        assert_eq!(
399            recorded[0].stdin.as_deref(),
400            Some("template\tdefault\ttemplate default\ntemplate\trust\ttemplate rust\n")
401        );
402    }
403}