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::config::PickerBindings;
9use crate::process::{CommandRunner, default_runner};
10use crate::ui::DisplayStyle;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum EntryKind {
14    Session,
15    Directory,
16    Project,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum SelectAction {
21    Open,
22    Delete,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct Entry {
27    pub kind: EntryKind,
28    pub label: String,
29    pub value: String,
30}
31
32impl Entry {
33    pub fn session(style: DisplayStyle, value: String) -> Self {
34        Self {
35            kind: EntryKind::Session,
36            label: style.session_label(&value),
37            value,
38        }
39    }
40
41    pub fn directory(style: DisplayStyle, value: String) -> Self {
42        Self {
43            kind: EntryKind::Directory,
44            label: style.directory_label(&value),
45            value,
46        }
47    }
48
49    pub fn project(style: DisplayStyle, value: String) -> Self {
50        Self {
51            kind: EntryKind::Project,
52            label: style.project_label(&value),
53            value,
54        }
55    }
56
57    fn encode(&self) -> String {
58        let kind = match self.kind {
59            EntryKind::Session => "session",
60            EntryKind::Directory => "folder",
61            EntryKind::Project => "project",
62        };
63
64        format!("{kind}\t{}\t{}", self.value, self.label)
65    }
66
67    fn decode(line: &str) -> Result<Self> {
68        let mut parts = line.splitn(3, '\t');
69        let kind = parts.next().context("missing entry kind")?;
70        let value = parts.next().context("missing entry value")?.to_owned();
71        let label = parts.next().context("missing entry label")?.to_owned();
72
73        let kind = match kind {
74            "session" => EntryKind::Session,
75            "folder" => EntryKind::Directory,
76            "project" => EntryKind::Project,
77            other => bail!("unknown picker entry kind: {other}"),
78        };
79
80        Ok(Self { kind, label, value })
81    }
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct Selection {
86    pub action: SelectAction,
87    pub entry: Entry,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct Choice {
92    pub kind: String,
93    pub label: String,
94    pub value: String,
95}
96
97impl Choice {
98    pub fn new(kind: impl Into<String>, label: String, value: String) -> Self {
99        Self {
100            kind: kind.into(),
101            label,
102            value,
103        }
104    }
105
106    fn encode(&self) -> String {
107        format!("{}\t{}\t{}", self.kind, self.value, self.label)
108    }
109
110    fn decode(line: &str) -> Result<Self> {
111        let mut parts = line.splitn(3, '\t');
112        let kind = parts.next().context("missing choice kind")?.to_owned();
113        let value = parts.next().context("missing choice value")?.to_owned();
114        let label = parts.next().context("missing choice label")?.to_owned();
115        Ok(Self { kind, label, value })
116    }
117}
118
119pub fn select(entries: Vec<Entry>, bindings: &PickerBindings) -> Result<Option<Selection>> {
120    select_with_runner(default_runner(), entries, "smux> ", bindings)
121}
122
123pub fn select_value(prompt: &str, choices: Vec<Choice>) -> Result<Option<String>> {
124    select_value_with_runner(default_runner(), prompt, choices)
125}
126
127struct TempInputFile {
128    path: PathBuf,
129}
130
131impl TempInputFile {
132    fn new(contents: &str) -> Result<Self> {
133        let mut path = std::env::temp_dir();
134        let nanos = SystemTime::now()
135            .duration_since(UNIX_EPOCH)
136            .context("system clock should be after unix epoch")?
137            .as_nanos();
138        path.push(format!("smux-fzf-{}-{nanos}.tsv", std::process::id()));
139        fs::write(&path, contents)
140            .with_context(|| format!("failed to write {}", path.display()))?;
141        Ok(Self { path })
142    }
143
144    fn shell_quoted_path(&self) -> String {
145        shell_quote(&self.path)
146    }
147}
148
149impl Drop for TempInputFile {
150    fn drop(&mut self) {
151        let _ = fs::remove_file(&self.path);
152    }
153}
154
155fn shell_quote(path: &Path) -> String {
156    let value = path.to_string_lossy();
157    format!("'{}'", value.replace('\'', "'\\''"))
158}
159
160fn cat_command(file: &TempInputFile) -> String {
161    format!("cat {}", file.shell_quoted_path())
162}
163
164fn filter_command(file: &TempInputFile, kind: &str) -> String {
165    format!(
166        "awk -F '\\t' '$1 == \"{kind}\"' {}",
167        file.shell_quoted_path()
168    )
169}
170
171fn add_common_picker_args(args: &mut Vec<String>, prompt: &str, header: &str, bindings: &str) {
172    args.extend([
173        "--ansi".to_owned(),
174        "--delimiter".to_owned(),
175        "\t".to_owned(),
176        "--layout".to_owned(),
177        "reverse".to_owned(),
178        "--header".to_owned(),
179        header.to_owned(),
180        "--bind".to_owned(),
181        "tab:down,btab:up".to_owned(),
182        "--bind".to_owned(),
183        bindings.to_owned(),
184        "--with-nth".to_owned(),
185        "3".to_owned(),
186        "--nth".to_owned(),
187        "1,2".to_owned(),
188        "--prompt".to_owned(),
189        prompt.to_owned(),
190        "--no-sort".to_owned(),
191    ]);
192}
193
194fn select_value_with_runner(
195    runner: Arc<dyn CommandRunner>,
196    prompt: &str,
197    choices: Vec<Choice>,
198) -> Result<Option<String>> {
199    let mut args = Vec::new();
200    let input = choices
201        .into_iter()
202        .map(|choice| choice.encode())
203        .collect::<Vec<_>>()
204        .join("\n")
205        + "\n";
206    let input_file = TempInputFile::new(&input)?;
207    let all_command = cat_command(&input_file);
208    let template_command = filter_command(&input_file, "template");
209    add_common_picker_args(
210        &mut args,
211        prompt,
212        "ctrl-x all  ctrl-t templates",
213        &format!(
214            "ctrl-x:change-prompt(template> )+clear-query+reload({all_command}),ctrl-t:change-prompt(template> )+clear-query+reload({template_command})"
215        ),
216    );
217    let output = runner
218        .run_capture_with_input("fzf", &args, &input)
219        .context("failed to launch fzf")?;
220
221    if output.status.code == Some(130) {
222        return Ok(None);
223    }
224
225    if !output.status.success {
226        bail!("fzf exited with status {:?}", output.status.code);
227    }
228
229    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
230    let selection = selection.trim_end();
231
232    if selection.is_empty() {
233        return Ok(None);
234    }
235
236    Ok(Some(Choice::decode(selection)?.value))
237}
238
239fn select_with_runner(
240    runner: Arc<dyn CommandRunner>,
241    entries: Vec<Entry>,
242    prompt: &str,
243    bindings: &PickerBindings,
244) -> Result<Option<Selection>> {
245    let mut args = Vec::new();
246    let input = entries
247        .into_iter()
248        .map(|entry| entry.encode())
249        .collect::<Vec<_>>()
250        .join("\n")
251        + "\n";
252    let input_file = TempInputFile::new(&input)?;
253    let all_command = cat_command(&input_file);
254    let session_command = filter_command(&input_file, "session");
255    let folder_command = filter_command(&input_file, "folder");
256    let project_command = filter_command(&input_file, "project");
257    add_common_picker_args(
258        &mut args,
259        prompt,
260        &format!(
261            "enter open  {delete} kill session  {reset} all  {sessions} sessions  {folders} folders  {projects} projects",
262            delete = bindings.delete_session,
263            reset = bindings.reset,
264            sessions = bindings.sessions,
265            folders = bindings.folders,
266            projects = bindings.projects,
267        ),
268        &format!(
269            "{reset}:change-prompt(smux> )+clear-query+reload({all_command}),{sessions}:change-prompt(session> )+clear-query+reload({session_command}),{folders}:change-prompt(folder> )+clear-query+reload({folder_command}),{projects}:change-prompt(project> )+clear-query+reload({project_command})",
270            reset = bindings.reset,
271            sessions = bindings.sessions,
272            folders = bindings.folders,
273            projects = bindings.projects,
274        ),
275    );
276    args.extend(["--expect".to_owned(), bindings.delete_session.clone()]);
277    let output = runner
278        .run_capture_with_input("fzf", &args, &input)
279        .context("failed to launch fzf")?;
280
281    if output.status.code == Some(130) {
282        return Ok(None);
283    }
284
285    if !output.status.success {
286        bail!("fzf exited with status {:?}", output.status.code);
287    }
288
289    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
290    let selection = selection.trim_end();
291
292    if selection.is_empty() {
293        return Ok(None);
294    }
295
296    let mut lines = selection.lines();
297    let first = lines
298        .next()
299        .context("fzf selection output was unexpectedly empty")?;
300    let (action, encoded_entry) = match lines.next() {
301        Some(encoded_entry) if !first.is_empty() => {
302            let action = match first {
303                key if key == bindings.delete_session => SelectAction::Delete,
304                other => bail!("unknown picker action: {other}"),
305            };
306            (action, encoded_entry)
307        }
308        Some(encoded_entry) => (SelectAction::Open, encoded_entry),
309        None => (SelectAction::Open, first),
310    };
311
312    Ok(Some(Selection {
313        action,
314        entry: Entry::decode(encoded_entry)?,
315    }))
316}
317
318#[cfg(test)]
319mod tests {
320    use std::sync::Arc;
321
322    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
323
324    use super::{
325        Choice, Entry, EntryKind, SelectAction, select_value_with_runner, select_with_runner,
326    };
327    use crate::config::{IconMode, PickerBindings};
328    use crate::ui::DisplayStyle;
329
330    #[test]
331    fn entry_round_trip() {
332        let entry = Entry {
333            kind: EntryKind::Directory,
334            label: "dir      /tmp/example".to_owned(),
335            value: "/tmp/example".to_owned(),
336        };
337
338        let decoded = Entry::decode(&entry.encode()).expect("entry should decode");
339        assert_eq!(decoded, entry);
340    }
341
342    #[test]
343    fn selector_passes_entries_to_fzf() {
344        let runner = Arc::new(FakeCommandRunner::new());
345        runner.push_capture(Ok(CommandOutput {
346            status: CommandStatus {
347                success: true,
348                code: Some(0),
349            },
350            stdout: b"folder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
351            stderr: Vec::new(),
352        }));
353
354        let result = select_with_runner(
355            runner.clone(),
356            vec![Entry::directory(
357                DisplayStyle::from_icon_mode(IconMode::Never),
358                "/tmp/example".to_owned(),
359            )],
360            "smux> ",
361            &PickerBindings::default(),
362        )
363        .expect("selection should succeed");
364
365        assert!(result.is_some());
366        let recorded = runner.recorded();
367        assert_eq!(recorded[0].program, "fzf");
368        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
369        assert!(recorded[0].args.contains(&"reverse".to_owned()));
370        assert!(recorded[0].args.contains(&"3".to_owned()));
371        assert!(recorded[0].args.contains(&"1,2".to_owned()));
372        assert!(recorded[0].args.contains(&"--expect".to_owned()));
373        assert!(recorded[0].args.contains(&"ctrl-x".to_owned()));
374        assert!(
375            recorded[0]
376                .args
377                .iter()
378                .any(|arg| arg.contains("enter open  ctrl-x kill session"))
379        );
380        assert!(
381            recorded[0]
382                .args
383                .iter()
384                .any(|arg| arg.contains("ctrl-c:change-prompt(smux> )+clear-query+reload("))
385        );
386        assert!(
387            recorded[0]
388                .args
389                .iter()
390                .any(|arg| arg.contains("ctrl-p projects"))
391        );
392        assert!(
393            recorded[0]
394                .args
395                .iter()
396                .any(|arg| arg.contains("ctrl-p:change-prompt(project> )+clear-query+reload("))
397        );
398        assert_eq!(
399            recorded[0].stdin.as_deref(),
400            Some("folder\t/tmp/example\tdir      /tmp/example\n")
401        );
402        let selection = result.expect("selection should be present");
403        assert_eq!(selection.action, SelectAction::Open);
404        assert_eq!(selection.entry.kind, EntryKind::Directory);
405    }
406
407    #[test]
408    fn selector_supports_delete_action_for_sessions() {
409        let runner = Arc::new(FakeCommandRunner::new());
410        runner.push_capture(Ok(CommandOutput {
411            status: CommandStatus {
412                success: true,
413                code: Some(0),
414            },
415            stdout: b"ctrl-x\nsession\tdemo\tsession  demo\n".to_vec(),
416            stderr: Vec::new(),
417        }));
418
419        let result = select_with_runner(
420            runner,
421            vec![Entry::session(
422                DisplayStyle::from_icon_mode(IconMode::Never),
423                "demo".to_owned(),
424            )],
425            "smux> ",
426            &PickerBindings::default(),
427        )
428        .expect("selection should succeed")
429        .expect("selection should be present");
430
431        assert_eq!(result.action, SelectAction::Delete);
432        assert_eq!(result.entry.kind, EntryKind::Session);
433        assert_eq!(result.entry.value, "demo");
434    }
435
436    #[test]
437    fn selector_treats_empty_expect_key_as_open() {
438        let runner = Arc::new(FakeCommandRunner::new());
439        runner.push_capture(Ok(CommandOutput {
440            status: CommandStatus {
441                success: true,
442                code: Some(0),
443            },
444            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
445            stderr: Vec::new(),
446        }));
447
448        let result = select_with_runner(
449            runner,
450            vec![Entry::directory(
451                DisplayStyle::from_icon_mode(IconMode::Never),
452                "/tmp/example".to_owned(),
453            )],
454            "smux> ",
455            &PickerBindings::default(),
456        )
457        .expect("selection should succeed")
458        .expect("selection should be present");
459
460        assert_eq!(result.action, SelectAction::Open);
461        assert_eq!(result.entry.kind, EntryKind::Directory);
462        assert_eq!(result.entry.value, "/tmp/example");
463    }
464
465    #[test]
466    fn selector_uses_configured_picker_bindings() {
467        let runner = Arc::new(FakeCommandRunner::new());
468        runner.push_capture(Ok(CommandOutput {
469            status: CommandStatus {
470                success: true,
471                code: Some(0),
472            },
473            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
474            stderr: Vec::new(),
475        }));
476
477        let bindings = PickerBindings {
478            reset: "alt-a".to_owned(),
479            sessions: "alt-s".to_owned(),
480            folders: "alt-f".to_owned(),
481            projects: "alt-p".to_owned(),
482            delete_session: "alt-x".to_owned(),
483        };
484
485        let _ = select_with_runner(
486            runner.clone(),
487            vec![Entry::directory(
488                DisplayStyle::from_icon_mode(IconMode::Never),
489                "/tmp/example".to_owned(),
490            )],
491            "smux> ",
492            &bindings,
493        )
494        .expect("selection should succeed");
495
496        let recorded = runner.recorded();
497        assert!(recorded[0].args.contains(&"alt-x".to_owned()));
498        assert!(
499            recorded[0]
500                .args
501                .iter()
502                .any(|arg| arg.contains("alt-a:change-prompt(smux> )+clear-query+reload("))
503        );
504        assert!(
505            recorded[0]
506                .args
507                .iter()
508                .any(|arg| arg.contains("alt-s:change-prompt(session> )+clear-query+reload("))
509        );
510        assert!(
511            recorded[0]
512                .args
513                .iter()
514                .any(|arg| arg.contains("alt-f:change-prompt(folder> )+clear-query+reload("))
515        );
516        assert!(
517            recorded[0]
518                .args
519                .iter()
520                .any(|arg| arg.contains("alt-p:change-prompt(project> )+clear-query+reload("))
521        );
522    }
523
524    #[test]
525    fn template_selector_returns_selected_value() {
526        let runner = Arc::new(FakeCommandRunner::new());
527        runner.push_capture(Ok(CommandOutput {
528            status: CommandStatus {
529                success: true,
530                code: Some(0),
531            },
532            stdout: b"template\trust\ttemplate rust\n".to_vec(),
533            stderr: Vec::new(),
534        }));
535
536        let result = select_value_with_runner(
537            runner.clone(),
538            "template> ",
539            vec![
540                Choice::new(
541                    "template",
542                    "template default".to_owned(),
543                    "default".to_owned(),
544                ),
545                Choice::new("template", "template rust".to_owned(), "rust".to_owned()),
546            ],
547        )
548        .expect("selection should succeed");
549
550        assert_eq!(result.as_deref(), Some("rust"));
551        let recorded = runner.recorded();
552        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
553        assert!(recorded[0].args.contains(&"reverse".to_owned()));
554        assert!(recorded[0].args.contains(&"3".to_owned()));
555        assert!(recorded[0].args.contains(&"1,2".to_owned()));
556        assert!(
557            recorded[0]
558                .args
559                .iter()
560                .any(|arg| arg.contains("ctrl-t templates"))
561        );
562        assert!(
563            recorded[0]
564                .args
565                .iter()
566                .any(|arg| arg.contains("ctrl-t:change-prompt(template> )+clear-query+reload("))
567        );
568        assert_eq!(
569            recorded[0].stdin.as_deref(),
570            Some("template\tdefault\ttemplate default\ntemplate\trust\ttemplate rust\n")
571        );
572    }
573}