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