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}