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}