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}