1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct FileEntry {
10 pub name: String,
11 pub is_dir: bool,
12 pub size: Option<u64>,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BrowserPane {
18 Local,
19 Remote,
20}
21
22pub struct CopyRequest {
24 pub sources: Vec<String>,
25 pub source_pane: BrowserPane,
26 pub has_dirs: bool,
27}
28
29pub struct FileBrowserState {
31 pub alias: String,
32 pub askpass: Option<String>,
33 pub active_pane: BrowserPane,
34 pub local_path: PathBuf,
36 pub local_entries: Vec<FileEntry>,
37 pub local_list_state: ListState,
38 pub local_selected: HashSet<String>,
39 pub local_error: Option<String>,
40 pub remote_path: String,
42 pub remote_entries: Vec<FileEntry>,
43 pub remote_list_state: ListState,
44 pub remote_selected: HashSet<String>,
45 pub remote_error: Option<String>,
46 pub remote_loading: bool,
47 pub show_hidden: bool,
49 pub confirm_copy: Option<CopyRequest>,
51 pub transferring: Option<String>,
53 pub transfer_error: Option<String>,
55 pub connection_recorded: bool,
57}
58
59pub fn list_local(path: &Path, show_hidden: bool) -> anyhow::Result<Vec<FileEntry>> {
62 let mut entries = Vec::new();
63 for entry in std::fs::read_dir(path)? {
64 let entry = entry?;
65 let name = entry.file_name().to_string_lossy().to_string();
66 if !show_hidden && name.starts_with('.') {
67 continue;
68 }
69 let metadata = entry.metadata()?;
70 let is_dir = metadata.is_dir();
71 let size = if is_dir { None } else { Some(metadata.len()) };
72 entries.push(FileEntry { name, is_dir, size });
73 }
74 entries.sort_by(|a, b| {
75 b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
76 });
77 Ok(entries)
78}
79
80pub fn parse_ls_output(output: &str, show_hidden: bool) -> Vec<FileEntry> {
83 let mut entries = Vec::new();
84 for line in output.lines() {
85 let line = line.trim();
86 if line.is_empty() || line.starts_with("total ") {
87 continue;
88 }
89 let mut parts: Vec<&str> = Vec::with_capacity(9);
92 let mut rest = line;
93 for _ in 0..8 {
94 rest = rest.trim_start();
95 if rest.is_empty() {
96 break;
97 }
98 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
99 parts.push(&rest[..end]);
100 rest = &rest[end..];
101 }
102 rest = rest.trim_start();
103 if !rest.is_empty() {
104 parts.push(rest);
105 }
106 if parts.len() < 9 {
107 continue;
108 }
109 let permissions = parts[0];
110 let is_dir = permissions.starts_with('d');
111 let name_field = parts[8];
112 if name_field.is_empty() {
114 continue;
115 }
116 let name = if permissions.starts_with('l') {
118 name_field.split(" -> ").next().unwrap_or(name_field)
119 } else {
120 name_field
121 };
122 if !show_hidden && name.starts_with('.') {
123 continue;
124 }
125 let size = if is_dir {
127 None
128 } else {
129 Some(parse_human_size(parts[4]))
130 };
131 entries.push(FileEntry {
132 name: name.to_string(),
133 is_dir,
134 size,
135 });
136 }
137 entries.sort_by(|a, b| {
138 b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
139 });
140 entries
141}
142
143fn parse_human_size(s: &str) -> u64 {
145 let s = s.trim();
146 if s.is_empty() {
147 return 0;
148 }
149 let last = s.as_bytes()[s.len() - 1];
150 let multiplier = match last {
151 b'K' => 1024,
152 b'M' => 1024 * 1024,
153 b'G' => 1024 * 1024 * 1024,
154 b'T' => 1024u64 * 1024 * 1024 * 1024,
155 _ => 1,
156 };
157 let num_str = if multiplier > 1 {
158 &s[..s.len() - 1]
159 } else {
160 s
161 };
162 let num: f64 = num_str.parse().unwrap_or(0.0);
163 (num * multiplier as f64) as u64
164}
165
166fn shell_escape(path: &str) -> String {
169 format!("'{}'", path.replace('\'', "'\\''"))
170}
171
172pub fn get_remote_home(
174 alias: &str,
175 config_path: &Path,
176 askpass: Option<&str>,
177 bw_session: Option<&str>,
178 has_active_tunnel: bool,
179) -> anyhow::Result<String> {
180 let result = crate::snippet::run_snippet(
181 alias,
182 config_path,
183 "pwd",
184 askpass,
185 bw_session,
186 true,
187 has_active_tunnel,
188 )?;
189 if result.status.success() {
190 Ok(result.stdout.trim().to_string())
191 } else {
192 anyhow::bail!("Failed to get remote home: {}", result.stderr.trim())
193 }
194}
195
196pub fn fetch_remote_listing(
198 alias: &str,
199 config_path: &Path,
200 remote_path: &str,
201 show_hidden: bool,
202 askpass: Option<&str>,
203 bw_session: Option<&str>,
204 has_tunnel: bool,
205) -> Result<Vec<FileEntry>, String> {
206 let command = format!("LC_ALL=C ls -lhA {}", shell_escape(remote_path));
207 let result = crate::snippet::run_snippet(
208 alias,
209 config_path,
210 &command,
211 askpass,
212 bw_session,
213 true,
214 has_tunnel,
215 );
216 match result {
217 Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden)),
218 Ok(r) => {
219 let msg = r.stderr.trim().to_string();
220 if msg.is_empty() {
221 Err(format!("ls exited with code {}.", r.status.code().unwrap_or(1)))
222 } else {
223 Err(msg)
224 }
225 }
226 Err(e) => Err(e.to_string()),
227 }
228}
229
230#[allow(clippy::too_many_arguments)]
233pub fn spawn_remote_listing<F>(
234 alias: String,
235 config_path: PathBuf,
236 remote_path: String,
237 show_hidden: bool,
238 askpass: Option<String>,
239 bw_session: Option<String>,
240 has_tunnel: bool,
241 send: F,
242) where
243 F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
244{
245 std::thread::spawn(move || {
246 let listing = fetch_remote_listing(
247 &alias,
248 &config_path,
249 &remote_path,
250 show_hidden,
251 askpass.as_deref(),
252 bw_session.as_deref(),
253 has_tunnel,
254 );
255 send(alias, remote_path, listing);
256 });
257}
258
259pub struct ScpResult {
261 pub status: ExitStatus,
262 pub stderr_output: String,
263}
264
265pub fn run_scp(
271 alias: &str,
272 config_path: &Path,
273 askpass: Option<&str>,
274 bw_session: Option<&str>,
275 has_active_tunnel: bool,
276 scp_args: &[String],
277) -> anyhow::Result<ScpResult> {
278 let mut cmd = Command::new("scp");
279 cmd.arg("-F").arg(config_path);
280
281 if has_active_tunnel {
282 cmd.arg("-o").arg("ClearAllForwardings=yes");
283 }
284
285 for arg in scp_args {
286 cmd.arg(arg);
287 }
288
289 cmd.stdin(Stdio::null())
290 .stdout(Stdio::null())
291 .stderr(Stdio::piped());
292
293 if askpass.is_some() {
294 let exe = std::env::current_exe()
295 .ok()
296 .map(|p| p.to_string_lossy().to_string())
297 .or_else(|| std::env::args().next())
298 .unwrap_or_else(|| "purple".to_string());
299 cmd.env("SSH_ASKPASS", &exe)
300 .env("SSH_ASKPASS_REQUIRE", "prefer")
301 .env("PURPLE_ASKPASS_MODE", "1")
302 .env("PURPLE_HOST_ALIAS", alias)
303 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
304 }
305
306 if let Some(token) = bw_session {
307 cmd.env("BW_SESSION", token);
308 }
309
310 let output = cmd
311 .output()
312 .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
313
314 let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
315
316 Ok(ScpResult { status: output.status, stderr_output })
317}
318
319pub fn extract_scp_error(stderr: &str) -> String {
322 stderr
323 .lines()
324 .filter(|line| {
325 let trimmed = line.trim();
326 !trimmed.is_empty()
327 && !trimmed.starts_with("** ")
328 && !trimmed.starts_with("Warning:")
329 && !trimmed.contains("see https://")
330 && !trimmed.contains("See https://")
331 && !trimmed.starts_with("The server may need")
332 && !trimmed.starts_with("This session may be")
333 })
334 .collect::<Vec<_>>()
335 .join("\n")
336}
337
338pub fn build_scp_args(
346 alias: &str,
347 source_pane: BrowserPane,
348 local_path: &Path,
349 remote_path: &str,
350 filenames: &[String],
351 has_dirs: bool,
352) -> Vec<String> {
353 let mut args = Vec::new();
354 if has_dirs {
355 args.push("-r".to_string());
356 }
357 args.push("--".to_string());
358
359 match source_pane {
360 BrowserPane::Local => {
362 for name in filenames {
363 args.push(local_path.join(name).to_string_lossy().to_string());
364 }
365 let dest = format!("{}:{}", alias, remote_path);
366 args.push(dest);
367 }
368 BrowserPane::Remote => {
370 let base = remote_path.trim_end_matches('/');
371 for name in filenames {
372 let rpath = format!("{}/{}", base, name);
373 args.push(format!("{}:{}", alias, rpath));
374 }
375 args.push(local_path.to_string_lossy().to_string());
376 }
377 }
378 args
379}
380
381pub fn format_size(bytes: u64) -> String {
383 if bytes >= 1024 * 1024 * 1024 {
384 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
385 } else if bytes >= 1024 * 1024 {
386 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
387 } else if bytes >= 1024 {
388 format!("{:.1} KB", bytes as f64 / 1024.0)
389 } else {
390 format!("{} B", bytes)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
403 fn test_shell_escape_simple() {
404 assert_eq!(shell_escape("/home/user"), "'/home/user'");
405 }
406
407 #[test]
408 fn test_shell_escape_with_single_quote() {
409 assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
410 }
411
412 #[test]
413 fn test_shell_escape_with_spaces() {
414 assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
415 }
416
417 #[test]
422 fn test_parse_ls_basic() {
423 let output = "\
424total 24
425drwxr-xr-x 2 user user 4096 Jan 1 12:00 subdir
426-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
427-rw-r--r-- 1 user user 1.1K Jan 1 12:00 big.log
428";
429 let entries = parse_ls_output(output, true);
430 assert_eq!(entries.len(), 3);
431 assert_eq!(entries[0].name, "subdir");
432 assert!(entries[0].is_dir);
433 assert_eq!(entries[0].size, None);
434 assert_eq!(entries[1].name, "big.log");
436 assert!(!entries[1].is_dir);
437 assert_eq!(entries[1].size, Some(1126)); assert_eq!(entries[2].name, "file.txt");
439 assert!(!entries[2].is_dir);
440 assert_eq!(entries[2].size, Some(512));
441 }
442
443 #[test]
444 fn test_parse_ls_hidden_filter() {
445 let output = "\
446total 8
447-rw-r--r-- 1 user user 100 Jan 1 12:00 .hidden
448-rw-r--r-- 1 user user 200 Jan 1 12:00 visible
449";
450 let entries = parse_ls_output(output, false);
451 assert_eq!(entries.len(), 1);
452 assert_eq!(entries[0].name, "visible");
453
454 let entries = parse_ls_output(output, true);
455 assert_eq!(entries.len(), 2);
456 }
457
458 #[test]
459 fn test_parse_ls_symlink() {
460 let output = "\
461total 4
462lrwxrwxrwx 1 user user 11 Jan 1 12:00 link -> /etc/hosts
463";
464 let entries = parse_ls_output(output, true);
465 assert_eq!(entries.len(), 1);
466 assert_eq!(entries[0].name, "link");
467 }
468
469 #[test]
470 fn test_parse_ls_filename_with_spaces() {
471 let output = "\
472total 4
473-rw-r--r-- 1 user user 100 Jan 1 12:00 my file name.txt
474";
475 let entries = parse_ls_output(output, true);
476 assert_eq!(entries.len(), 1);
477 assert_eq!(entries[0].name, "my file name.txt");
478 }
479
480 #[test]
481 fn test_parse_ls_empty() {
482 let output = "total 0\n";
483 let entries = parse_ls_output(output, true);
484 assert!(entries.is_empty());
485 }
486
487 #[test]
492 fn test_parse_human_size() {
493 assert_eq!(parse_human_size("512"), 512);
494 assert_eq!(parse_human_size("1.0K"), 1024);
495 assert_eq!(parse_human_size("1.5M"), 1572864);
496 assert_eq!(parse_human_size("2.0G"), 2147483648);
497 }
498
499 #[test]
504 fn test_format_size() {
505 assert_eq!(format_size(0), "0 B");
506 assert_eq!(format_size(512), "512 B");
507 assert_eq!(format_size(1024), "1.0 KB");
508 assert_eq!(format_size(1536), "1.5 KB");
509 assert_eq!(format_size(1048576), "1.0 MB");
510 assert_eq!(format_size(1073741824), "1.0 GB");
511 }
512
513 #[test]
518 fn test_build_scp_args_upload() {
519 let args = build_scp_args(
520 "myhost",
521 BrowserPane::Local,
522 Path::new("/home/user/docs"),
523 "/remote/path/",
524 &["file.txt".to_string()],
525 false,
526 );
527 assert_eq!(args, vec![
528 "--",
529 "/home/user/docs/file.txt",
530 "myhost:/remote/path/",
531 ]);
532 }
533
534 #[test]
535 fn test_build_scp_args_download() {
536 let args = build_scp_args(
537 "myhost",
538 BrowserPane::Remote,
539 Path::new("/home/user/docs"),
540 "/remote/path",
541 &["file.txt".to_string()],
542 false,
543 );
544 assert_eq!(args, vec![
545 "--",
546 "myhost:/remote/path/file.txt",
547 "/home/user/docs",
548 ]);
549 }
550
551 #[test]
552 fn test_build_scp_args_spaces_in_path() {
553 let args = build_scp_args(
554 "myhost",
555 BrowserPane::Remote,
556 Path::new("/local"),
557 "/remote/my path",
558 &["my file.txt".to_string()],
559 false,
560 );
561 assert_eq!(args, vec![
563 "--",
564 "myhost:/remote/my path/my file.txt",
565 "/local",
566 ]);
567 }
568
569 #[test]
570 fn test_build_scp_args_with_dirs() {
571 let args = build_scp_args(
572 "myhost",
573 BrowserPane::Local,
574 Path::new("/local"),
575 "/remote/",
576 &["mydir".to_string()],
577 true,
578 );
579 assert_eq!(args[0], "-r");
580 }
581
582 #[test]
587 fn test_list_local_sorts_dirs_first() {
588 let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
589 let _ = std::fs::remove_dir_all(&base);
590 std::fs::create_dir_all(&base).unwrap();
591 std::fs::create_dir(base.join("zdir")).unwrap();
592 std::fs::write(base.join("afile.txt"), "hello").unwrap();
593 std::fs::write(base.join("bfile.txt"), "world").unwrap();
594
595 let entries = list_local(&base, true).unwrap();
596 assert_eq!(entries.len(), 3);
597 assert!(entries[0].is_dir);
598 assert_eq!(entries[0].name, "zdir");
599 assert_eq!(entries[1].name, "afile.txt");
600 assert_eq!(entries[2].name, "bfile.txt");
601
602 let _ = std::fs::remove_dir_all(&base);
603 }
604
605 #[test]
606 fn test_list_local_hidden() {
607 let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
608 let _ = std::fs::remove_dir_all(&base);
609 std::fs::create_dir_all(&base).unwrap();
610 std::fs::write(base.join(".hidden"), "").unwrap();
611 std::fs::write(base.join("visible"), "").unwrap();
612
613 let entries = list_local(&base, false).unwrap();
614 assert_eq!(entries.len(), 1);
615 assert_eq!(entries[0].name, "visible");
616
617 let entries = list_local(&base, true).unwrap();
618 assert_eq!(entries.len(), 2);
619
620 let _ = std::fs::remove_dir_all(&base);
621 }
622
623 #[test]
628 fn test_extract_scp_error_filters_warnings() {
629 let stderr = "\
630** WARNING: connection is not using a post-quantum key exchange algorithm.
631** This session may be vulnerable to \"store now, decrypt later\" attacks.
632** The server may need to be upgraded. See https://openssh.com/pq.html
633scp: '/root/file.rpm': No such file or directory";
634 assert_eq!(
635 extract_scp_error(stderr),
636 "scp: '/root/file.rpm': No such file or directory"
637 );
638 }
639
640 #[test]
641 fn test_extract_scp_error_keeps_plain_error() {
642 let stderr = "scp: /etc/shadow: Permission denied\n";
643 assert_eq!(extract_scp_error(stderr), "scp: /etc/shadow: Permission denied");
644 }
645
646 #[test]
647 fn test_extract_scp_error_empty() {
648 assert_eq!(extract_scp_error(""), "");
649 assert_eq!(extract_scp_error(" \n \n"), "");
650 }
651}