Skip to main content

purple_ssh/
file_browser.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7/// Sort mode for file browser panes.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10    Name,
11    Date,
12    DateAsc,
13}
14
15/// A file or directory entry in the browser.
16#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18    pub name: String,
19    pub is_dir: bool,
20    pub size: Option<u64>,
21    /// Modification time as Unix timestamp (seconds since epoch).
22    pub modified: Option<i64>,
23}
24
25/// Which pane is active.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28    Local,
29    Remote,
30}
31
32/// Pending copy operation awaiting confirmation.
33pub struct CopyRequest {
34    pub sources: Vec<String>,
35    pub source_pane: BrowserPane,
36    pub has_dirs: bool,
37}
38
39/// State for the dual-pane file browser overlay.
40pub struct FileBrowserState {
41    pub alias: String,
42    pub askpass: Option<String>,
43    pub active_pane: BrowserPane,
44    // Local
45    pub local_path: PathBuf,
46    pub local_entries: Vec<FileEntry>,
47    pub local_list_state: ListState,
48    pub local_selected: HashSet<String>,
49    pub local_error: Option<String>,
50    // Remote
51    pub remote_path: String,
52    pub remote_entries: Vec<FileEntry>,
53    pub remote_list_state: ListState,
54    pub remote_selected: HashSet<String>,
55    pub remote_error: Option<String>,
56    pub remote_loading: bool,
57    // Options
58    pub show_hidden: bool,
59    pub sort: BrowserSort,
60    // Copy confirmation
61    pub confirm_copy: Option<CopyRequest>,
62    // Transfer in progress
63    pub transferring: Option<String>,
64    // Transfer error (shown as dismissible dialog)
65    pub transfer_error: Option<String>,
66    // Whether the initial remote connection has been recorded in history
67    pub connection_recorded: bool,
68}
69
70/// List local directory entries.
71/// Sorts: directories first, then by name or date. Filters dotfiles based on show_hidden.
72pub fn list_local(
73    path: &Path,
74    show_hidden: bool,
75    sort: BrowserSort,
76) -> anyhow::Result<Vec<FileEntry>> {
77    let mut entries = Vec::new();
78    for entry in std::fs::read_dir(path)? {
79        let entry = entry?;
80        let name = entry.file_name().to_string_lossy().to_string();
81        if !show_hidden && name.starts_with('.') {
82            continue;
83        }
84        let metadata = entry.metadata()?;
85        let is_dir = metadata.is_dir();
86        let size = if is_dir { None } else { Some(metadata.len()) };
87        let modified = metadata.modified().ok().and_then(|t| {
88            t.duration_since(std::time::UNIX_EPOCH)
89                .ok()
90                .map(|d| d.as_secs() as i64)
91        });
92        entries.push(FileEntry {
93            name,
94            is_dir,
95            size,
96            modified,
97        });
98    }
99    sort_entries(&mut entries, sort);
100    Ok(entries)
101}
102
103/// Sort file entries: directories first, then by the chosen mode.
104pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
105    match sort {
106        BrowserSort::Name => {
107            entries.sort_by(|a, b| {
108                b.is_dir.cmp(&a.is_dir).then_with(|| {
109                    a.name
110                        .to_ascii_lowercase()
111                        .cmp(&b.name.to_ascii_lowercase())
112                })
113            });
114        }
115        BrowserSort::Date => {
116            entries.sort_by(|a, b| {
117                b.is_dir.cmp(&a.is_dir).then_with(|| {
118                    // Newest first: reverse order
119                    b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
120                })
121            });
122        }
123        BrowserSort::DateAsc => {
124            entries.sort_by(|a, b| {
125                b.is_dir.cmp(&a.is_dir).then_with(|| {
126                    // Oldest first; unknown dates sort to the end
127                    a.modified
128                        .unwrap_or(i64::MAX)
129                        .cmp(&b.modified.unwrap_or(i64::MAX))
130                })
131            });
132        }
133    }
134}
135
136/// Parse `ls -lhAL` output into FileEntry list.
137/// With -L, symlinks are dereferenced so their target type is shown directly.
138/// Recognizes directories via 'd' permission prefix. Skips the "total" line.
139/// Broken symlinks are omitted by ls -L (they cannot be transferred anyway).
140pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
141    let mut entries = Vec::new();
142    for line in output.lines() {
143        let line = line.trim();
144        if line.is_empty() || line.starts_with("total ") {
145            continue;
146        }
147        // ls -l format: permissions links owner group size month day time name
148        // Split on whitespace runs, taking 9 fields (last gets the rest including spaces)
149        let mut parts: Vec<&str> = Vec::with_capacity(9);
150        let mut rest = line;
151        for _ in 0..8 {
152            rest = rest.trim_start();
153            if rest.is_empty() {
154                break;
155            }
156            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
157            parts.push(&rest[..end]);
158            rest = &rest[end..];
159        }
160        rest = rest.trim_start();
161        if !rest.is_empty() {
162            parts.push(rest);
163        }
164        if parts.len() < 9 {
165            continue;
166        }
167        let permissions = parts[0];
168        let is_dir = permissions.starts_with('d');
169        let name = parts[8];
170        // Skip empty names
171        if name.is_empty() {
172            continue;
173        }
174        if !show_hidden && name.starts_with('.') {
175            continue;
176        }
177        // Parse human-readable size (e.g. "1.1K", "4.0M", "512")
178        let size = if is_dir {
179            None
180        } else {
181            Some(parse_human_size(parts[4]))
182        };
183        // Parse date from month/day/time-or-year (parts[5..=7])
184        let modified = parse_ls_date(parts[5], parts[6], parts[7]);
185        entries.push(FileEntry {
186            name: name.to_string(),
187            is_dir,
188            size,
189            modified,
190        });
191    }
192    sort_entries(&mut entries, sort);
193    entries
194}
195
196/// Parse a human-readable size string like "1.1K", "4.0M", "512" into bytes.
197fn parse_human_size(s: &str) -> u64 {
198    let s = s.trim();
199    if s.is_empty() {
200        return 0;
201    }
202    let last = s.as_bytes()[s.len() - 1];
203    let multiplier = match last {
204        b'K' => 1024,
205        b'M' => 1024 * 1024,
206        b'G' => 1024 * 1024 * 1024,
207        b'T' => 1024u64 * 1024 * 1024 * 1024,
208        _ => 1,
209    };
210    let num_str = if multiplier > 1 { &s[..s.len() - 1] } else { s };
211    let num: f64 = num_str.parse().unwrap_or(0.0);
212    (num * multiplier as f64) as u64
213}
214
215/// Parse the date fields from `ls -l` with `LC_ALL=C`.
216/// Recent files: "Jan 1 12:34" (month day HH:MM).
217/// Old files: "Jan 1 2024" (month day year).
218/// Returns approximate Unix timestamp or None if unparseable.
219fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
220    let month = match month_str {
221        "Jan" => 0,
222        "Feb" => 1,
223        "Mar" => 2,
224        "Apr" => 3,
225        "May" => 4,
226        "Jun" => 5,
227        "Jul" => 6,
228        "Aug" => 7,
229        "Sep" => 8,
230        "Oct" => 9,
231        "Nov" => 10,
232        "Dec" => 11,
233        _ => return None,
234    };
235    let day: i64 = day_str.parse().ok()?;
236    if !(1..=31).contains(&day) {
237        return None;
238    }
239
240    let now = std::time::SystemTime::now()
241        .duration_since(std::time::UNIX_EPOCH)
242        .unwrap_or_default()
243        .as_secs() as i64;
244    let now_year = epoch_to_year(now);
245
246    if time_or_year.contains(':') {
247        // Recent format: "HH:MM"
248        let mut parts = time_or_year.splitn(2, ':');
249        let hour: i64 = parts.next()?.parse().ok()?;
250        let min: i64 = parts.next()?.parse().ok()?;
251        // Determine year: if month/day is in the future, it's last year
252        let mut year = now_year;
253        let approx = approximate_epoch(year, month, day, hour, min);
254        if approx > now + 86400 {
255            year -= 1;
256        }
257        Some(approximate_epoch(year, month, day, hour, min))
258    } else {
259        // Old format: "2024" (year)
260        let year: i64 = time_or_year.parse().ok()?;
261        if !(1970..=2100).contains(&year) {
262            return None;
263        }
264        Some(approximate_epoch(year, month, day, 0, 0))
265    }
266}
267
268/// Rough Unix timestamp from date components (no leap second precision needed).
269fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270    // Days from 1970-01-01 to start of year
271    let y = year - 1970;
272    let mut days = y * 365 + (y + 1) / 4; // approximate leap years
273    // Days to start of month (non-leap approximation, close enough for sorting)
274    let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275    days += month_days[month as usize];
276    // Add leap day if applicable
277    if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
278        days += 1;
279    }
280    days += day - 1;
281    days * 86400 + hour * 3600 + min * 60
282}
283
284/// Convert epoch seconds to a year (correctly handles year boundaries).
285fn epoch_to_year(ts: i64) -> i64 {
286    let mut y = 1970 + ts / 31_557_600;
287    if approximate_epoch(y, 0, 1, 0, 0) > ts {
288        y -= 1;
289    } else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
290        y += 1;
291    }
292    y
293}
294
295fn is_leap_year(year: i64) -> bool {
296    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
297}
298
299/// Format a Unix timestamp as a relative or short date string.
300/// Returns strings like "2m ago", "3h ago", "5d ago", "Jan 15", "Mar 2024".
301pub fn format_relative_time(ts: i64) -> String {
302    let now = std::time::SystemTime::now()
303        .duration_since(std::time::UNIX_EPOCH)
304        .unwrap_or_default()
305        .as_secs() as i64;
306    let diff = now - ts;
307    if diff < 0 {
308        // Future timestamp (clock skew), just show date
309        return format_short_date(ts);
310    }
311    if diff < 60 {
312        return "just now".to_string();
313    }
314    if diff < 3600 {
315        return format!("{}m ago", diff / 60);
316    }
317    if diff < 86400 {
318        return format!("{}h ago", diff / 3600);
319    }
320    if diff < 86400 * 30 {
321        return format!("{}d ago", diff / 86400);
322    }
323    format_short_date(ts)
324}
325
326/// Format a timestamp as "Mon DD" (same year) or "Mon YYYY" (different year).
327fn format_short_date(ts: i64) -> String {
328    let now = std::time::SystemTime::now()
329        .duration_since(std::time::UNIX_EPOCH)
330        .unwrap_or_default()
331        .as_secs() as i64;
332    let now_year = epoch_to_year(now);
333    let ts_year = epoch_to_year(ts);
334
335    let months = [
336        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
337    ];
338
339    // Approximate month and day from day-of-year
340    let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
341    let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
342    let feb = if is_leap_year(ts_year) { 29 } else { 28 };
343    let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
344    let mut m = 0;
345    let mut remaining = day_of_year;
346    for (i, &len) in month_lengths.iter().enumerate() {
347        if remaining < len {
348            m = i;
349            break;
350        }
351        remaining -= len;
352        m = i + 1;
353    }
354    let m = m.min(11);
355    let d = remaining + 1;
356
357    if ts_year == now_year {
358        format!("{} {:>2}", months[m], d)
359    } else {
360        format!("{} {}", months[m], ts_year)
361    }
362}
363
364/// Shell-escape a path with single quotes.
365fn shell_escape(path: &str) -> String {
366    crate::snippet::shell_escape(path)
367}
368
369/// Get the remote home directory via `pwd`.
370pub fn get_remote_home(
371    alias: &str,
372    config_path: &Path,
373    askpass: Option<&str>,
374    bw_session: Option<&str>,
375    has_active_tunnel: bool,
376) -> anyhow::Result<String> {
377    let result = crate::snippet::run_snippet(
378        alias,
379        config_path,
380        "pwd",
381        askpass,
382        bw_session,
383        true,
384        has_active_tunnel,
385    )?;
386    if result.status.success() {
387        Ok(result.stdout.trim().to_string())
388    } else {
389        let msg = filter_ssh_warnings(result.stderr.trim());
390        if msg.is_empty() {
391            anyhow::bail!("Failed to connect.")
392        } else {
393            anyhow::bail!("{}", msg)
394        }
395    }
396}
397
398/// Fetch remote directory listing synchronously (used by spawn_remote_listing).
399#[allow(clippy::too_many_arguments)]
400pub fn fetch_remote_listing(
401    alias: &str,
402    config_path: &Path,
403    remote_path: &str,
404    show_hidden: bool,
405    sort: BrowserSort,
406    askpass: Option<&str>,
407    bw_session: Option<&str>,
408    has_tunnel: bool,
409) -> Result<Vec<FileEntry>, String> {
410    let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
411    let result = crate::snippet::run_snippet(
412        alias,
413        config_path,
414        &command,
415        askpass,
416        bw_session,
417        true,
418        has_tunnel,
419    );
420    match result {
421        Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
422        Ok(r) => {
423            let msg = filter_ssh_warnings(r.stderr.trim());
424            if msg.is_empty() {
425                Err(format!(
426                    "ls exited with code {}.",
427                    r.status.code().unwrap_or(1)
428                ))
429            } else {
430                Err(msg)
431            }
432        }
433        Err(e) => Err(e.to_string()),
434    }
435}
436
437/// Spawn background thread for remote directory listing.
438/// Sends result back via the provided sender function.
439#[allow(clippy::too_many_arguments)]
440pub fn spawn_remote_listing<F>(
441    alias: String,
442    config_path: PathBuf,
443    remote_path: String,
444    show_hidden: bool,
445    sort: BrowserSort,
446    askpass: Option<String>,
447    bw_session: Option<String>,
448    has_tunnel: bool,
449    send: F,
450) where
451    F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
452{
453    std::thread::spawn(move || {
454        let listing = fetch_remote_listing(
455            &alias,
456            &config_path,
457            &remote_path,
458            show_hidden,
459            sort,
460            askpass.as_deref(),
461            bw_session.as_deref(),
462            has_tunnel,
463        );
464        send(alias, remote_path, listing);
465    });
466}
467
468/// Result of an scp transfer.
469pub struct ScpResult {
470    pub status: ExitStatus,
471    pub stderr_output: String,
472}
473
474/// Run scp in the background with captured stderr for error reporting.
475/// Stderr is piped and captured so errors can be extracted. Progress percentage
476/// is not available because scp only outputs progress to a TTY, not to a pipe.
477/// Stdin is null (askpass handles authentication). Stdout is null (scp has no
478/// meaningful stdout output).
479pub fn run_scp(
480    alias: &str,
481    config_path: &Path,
482    askpass: Option<&str>,
483    bw_session: Option<&str>,
484    has_active_tunnel: bool,
485    scp_args: &[String],
486) -> anyhow::Result<ScpResult> {
487    let mut cmd = Command::new("scp");
488    cmd.arg("-F").arg(config_path);
489
490    if has_active_tunnel {
491        cmd.arg("-o").arg("ClearAllForwardings=yes");
492    }
493
494    for arg in scp_args {
495        cmd.arg(arg);
496    }
497
498    cmd.stdin(Stdio::null())
499        .stdout(Stdio::null())
500        .stderr(Stdio::piped());
501
502    if askpass.is_some() {
503        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
504    }
505
506    if let Some(token) = bw_session {
507        cmd.env("BW_SESSION", token);
508    }
509
510    let output = cmd
511        .output()
512        .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
513
514    let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
515
516    Ok(ScpResult {
517        status: output.status,
518        stderr_output,
519    })
520}
521
522/// Filter SSH warning noise from stderr, keeping only actionable error lines.
523/// Strips lines like "** WARNING: connection is not using a post-quantum key exchange".
524pub fn filter_ssh_warnings(stderr: &str) -> String {
525    stderr
526        .lines()
527        .filter(|line| {
528            let trimmed = line.trim();
529            !trimmed.is_empty()
530                && !trimmed.starts_with("** ")
531                && !trimmed.starts_with("Warning:")
532                && !trimmed.contains("see https://")
533                && !trimmed.contains("See https://")
534                && !trimmed.starts_with("The server may need")
535                && !trimmed.starts_with("This session may be")
536        })
537        .collect::<Vec<_>>()
538        .join("\n")
539}
540
541/// Build scp arguments for a file transfer.
542/// Returns the args to pass after `scp -F <config>`.
543///
544/// Remote paths are NOT shell-escaped because scp is invoked via Command::arg()
545/// which bypasses the shell entirely. The colon in `alias:path` is the only
546/// special character scp interprets. Paths with spaces, globbing chars etc. are
547/// passed through literally by the OS exec layer.
548pub fn build_scp_args(
549    alias: &str,
550    source_pane: BrowserPane,
551    local_path: &Path,
552    remote_path: &str,
553    filenames: &[String],
554    has_dirs: bool,
555) -> Vec<String> {
556    let mut args = Vec::new();
557    if has_dirs {
558        args.push("-r".to_string());
559    }
560    args.push("--".to_string());
561
562    match source_pane {
563        // Upload: local files -> remote
564        BrowserPane::Local => {
565            for name in filenames {
566                args.push(local_path.join(name).to_string_lossy().to_string());
567            }
568            let dest = format!("{}:{}", alias, remote_path);
569            args.push(dest);
570        }
571        // Download: remote files -> local
572        BrowserPane::Remote => {
573            let base = remote_path.trim_end_matches('/');
574            for name in filenames {
575                let rpath = format!("{}/{}", base, name);
576                args.push(format!("{}:{}", alias, rpath));
577            }
578            args.push(local_path.to_string_lossy().to_string());
579        }
580    }
581    args
582}
583
584/// Format a file size in human-readable form.
585pub fn format_size(bytes: u64) -> String {
586    if bytes >= 1024 * 1024 * 1024 {
587        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
588    } else if bytes >= 1024 * 1024 {
589        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
590    } else if bytes >= 1024 {
591        format!("{:.1} KB", bytes as f64 / 1024.0)
592    } else {
593        format!("{} B", bytes)
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    // =========================================================================
602    // shell_escape
603    // =========================================================================
604
605    #[test]
606    fn test_shell_escape_simple() {
607        assert_eq!(shell_escape("/home/user"), "'/home/user'");
608    }
609
610    #[test]
611    fn test_shell_escape_with_single_quote() {
612        assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
613    }
614
615    #[test]
616    fn test_shell_escape_with_spaces() {
617        assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
618    }
619
620    // =========================================================================
621    // parse_ls_output
622    // =========================================================================
623
624    #[test]
625    fn test_parse_ls_basic() {
626        let output = "\
627total 24
628drwxr-xr-x  2 user user 4096 Jan  1 12:00 subdir
629-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
630-rw-r--r--  1 user user 1.1K Jan  1 12:00 big.log
631";
632        let entries = parse_ls_output(output, true, BrowserSort::Name);
633        assert_eq!(entries.len(), 3);
634        assert_eq!(entries[0].name, "subdir");
635        assert!(entries[0].is_dir);
636        assert_eq!(entries[0].size, None);
637        // Files sorted alphabetically after dirs
638        assert_eq!(entries[1].name, "big.log");
639        assert!(!entries[1].is_dir);
640        assert_eq!(entries[1].size, Some(1126)); // 1.1 * 1024
641        assert_eq!(entries[2].name, "file.txt");
642        assert!(!entries[2].is_dir);
643        assert_eq!(entries[2].size, Some(512));
644    }
645
646    #[test]
647    fn test_parse_ls_hidden_filter() {
648        let output = "\
649total 8
650-rw-r--r--  1 user user  100 Jan  1 12:00 .hidden
651-rw-r--r--  1 user user  200 Jan  1 12:00 visible
652";
653        let entries = parse_ls_output(output, false, BrowserSort::Name);
654        assert_eq!(entries.len(), 1);
655        assert_eq!(entries[0].name, "visible");
656
657        let entries = parse_ls_output(output, true, BrowserSort::Name);
658        assert_eq!(entries.len(), 2);
659    }
660
661    #[test]
662    fn test_parse_ls_symlink_to_file_dereferenced() {
663        // With -L, symlink to file appears as regular file
664        let output = "\
665total 4
666-rw-r--r--  1 user user   11 Jan  1 12:00 link
667";
668        let entries = parse_ls_output(output, true, BrowserSort::Name);
669        assert_eq!(entries.len(), 1);
670        assert_eq!(entries[0].name, "link");
671        assert!(!entries[0].is_dir);
672    }
673
674    #[test]
675    fn test_parse_ls_symlink_to_dir_dereferenced() {
676        // With -L, symlink to directory appears as directory
677        let output = "\
678total 4
679drwxr-xr-x  3 user user 4096 Jan  1 12:00 link
680";
681        let entries = parse_ls_output(output, true, BrowserSort::Name);
682        assert_eq!(entries.len(), 1);
683        assert_eq!(entries[0].name, "link");
684        assert!(entries[0].is_dir);
685    }
686
687    #[test]
688    fn test_parse_ls_filename_with_spaces() {
689        let output = "\
690total 4
691-rw-r--r--  1 user user  100 Jan  1 12:00 my file name.txt
692";
693        let entries = parse_ls_output(output, true, BrowserSort::Name);
694        assert_eq!(entries.len(), 1);
695        assert_eq!(entries[0].name, "my file name.txt");
696    }
697
698    #[test]
699    fn test_parse_ls_empty() {
700        let output = "total 0\n";
701        let entries = parse_ls_output(output, true, BrowserSort::Name);
702        assert!(entries.is_empty());
703    }
704
705    // =========================================================================
706    // parse_human_size
707    // =========================================================================
708
709    #[test]
710    fn test_parse_human_size() {
711        assert_eq!(parse_human_size("512"), 512);
712        assert_eq!(parse_human_size("1.0K"), 1024);
713        assert_eq!(parse_human_size("1.5M"), 1572864);
714        assert_eq!(parse_human_size("2.0G"), 2147483648);
715    }
716
717    // =========================================================================
718    // format_size
719    // =========================================================================
720
721    #[test]
722    fn test_format_size() {
723        assert_eq!(format_size(0), "0 B");
724        assert_eq!(format_size(512), "512 B");
725        assert_eq!(format_size(1024), "1.0 KB");
726        assert_eq!(format_size(1536), "1.5 KB");
727        assert_eq!(format_size(1048576), "1.0 MB");
728        assert_eq!(format_size(1073741824), "1.0 GB");
729    }
730
731    // =========================================================================
732    // build_scp_args
733    // =========================================================================
734
735    #[test]
736    fn test_build_scp_args_upload() {
737        let args = build_scp_args(
738            "myhost",
739            BrowserPane::Local,
740            Path::new("/home/user/docs"),
741            "/remote/path/",
742            &["file.txt".to_string()],
743            false,
744        );
745        assert_eq!(
746            args,
747            vec!["--", "/home/user/docs/file.txt", "myhost:/remote/path/",]
748        );
749    }
750
751    #[test]
752    fn test_build_scp_args_download() {
753        let args = build_scp_args(
754            "myhost",
755            BrowserPane::Remote,
756            Path::new("/home/user/docs"),
757            "/remote/path",
758            &["file.txt".to_string()],
759            false,
760        );
761        assert_eq!(
762            args,
763            vec!["--", "myhost:/remote/path/file.txt", "/home/user/docs",]
764        );
765    }
766
767    #[test]
768    fn test_build_scp_args_spaces_in_path() {
769        let args = build_scp_args(
770            "myhost",
771            BrowserPane::Remote,
772            Path::new("/local"),
773            "/remote/my path",
774            &["my file.txt".to_string()],
775            false,
776        );
777        // No shell escaping: Command::arg() passes paths literally
778        assert_eq!(
779            args,
780            vec!["--", "myhost:/remote/my path/my file.txt", "/local",]
781        );
782    }
783
784    #[test]
785    fn test_build_scp_args_with_dirs() {
786        let args = build_scp_args(
787            "myhost",
788            BrowserPane::Local,
789            Path::new("/local"),
790            "/remote/",
791            &["mydir".to_string()],
792            true,
793        );
794        assert_eq!(args[0], "-r");
795    }
796
797    // =========================================================================
798    // list_local
799    // =========================================================================
800
801    #[test]
802    fn test_list_local_sorts_dirs_first() {
803        let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
804        let _ = std::fs::remove_dir_all(&base);
805        std::fs::create_dir_all(&base).unwrap();
806        std::fs::create_dir(base.join("zdir")).unwrap();
807        std::fs::write(base.join("afile.txt"), "hello").unwrap();
808        std::fs::write(base.join("bfile.txt"), "world").unwrap();
809
810        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
811        assert_eq!(entries.len(), 3);
812        assert!(entries[0].is_dir);
813        assert_eq!(entries[0].name, "zdir");
814        assert_eq!(entries[1].name, "afile.txt");
815        assert_eq!(entries[2].name, "bfile.txt");
816
817        let _ = std::fs::remove_dir_all(&base);
818    }
819
820    #[test]
821    fn test_list_local_hidden() {
822        let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
823        let _ = std::fs::remove_dir_all(&base);
824        std::fs::create_dir_all(&base).unwrap();
825        std::fs::write(base.join(".hidden"), "").unwrap();
826        std::fs::write(base.join("visible"), "").unwrap();
827
828        let entries = list_local(&base, false, BrowserSort::Name).unwrap();
829        assert_eq!(entries.len(), 1);
830        assert_eq!(entries[0].name, "visible");
831
832        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
833        assert_eq!(entries.len(), 2);
834
835        let _ = std::fs::remove_dir_all(&base);
836    }
837
838    // =========================================================================
839    // filter_ssh_warnings
840    // =========================================================================
841
842    #[test]
843    fn test_filter_ssh_warnings_filters_warnings() {
844        let stderr = "\
845** WARNING: connection is not using a post-quantum key exchange algorithm.
846** This session may be vulnerable to \"store now, decrypt later\" attacks.
847** The server may need to be upgraded. See https://openssh.com/pq.html
848scp: '/root/file.rpm': No such file or directory";
849        assert_eq!(
850            filter_ssh_warnings(stderr),
851            "scp: '/root/file.rpm': No such file or directory"
852        );
853    }
854
855    #[test]
856    fn test_filter_ssh_warnings_keeps_plain_error() {
857        let stderr = "scp: /etc/shadow: Permission denied\n";
858        assert_eq!(
859            filter_ssh_warnings(stderr),
860            "scp: /etc/shadow: Permission denied"
861        );
862    }
863
864    #[test]
865    fn test_filter_ssh_warnings_empty() {
866        assert_eq!(filter_ssh_warnings(""), "");
867        assert_eq!(filter_ssh_warnings("  \n  \n"), "");
868    }
869
870    #[test]
871    fn test_filter_ssh_warnings_warning_prefix() {
872        let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
873        assert_eq!(
874            filter_ssh_warnings(stderr),
875            "Permission denied (publickey)."
876        );
877    }
878
879    #[test]
880    fn test_filter_ssh_warnings_lowercase_see_https() {
881        let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
882        assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
883    }
884
885    #[test]
886    fn test_filter_ssh_warnings_only_warnings() {
887        let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n** This session may be vulnerable to \"store now, decrypt later\" attacks.\n** The server may need to be upgraded. See https://openssh.com/pq.html";
888        assert_eq!(filter_ssh_warnings(stderr), "");
889    }
890
891    // =========================================================================
892    // approximate_epoch (known dates)
893    // =========================================================================
894
895    #[test]
896    fn test_approximate_epoch_known_dates() {
897        // 2024-01-01 00:00 UTC = 1704067200
898        let ts = approximate_epoch(2024, 0, 1, 0, 0);
899        assert_eq!(ts, 1704067200);
900        // 2000-01-01 00:00 UTC = 946684800
901        let ts = approximate_epoch(2000, 0, 1, 0, 0);
902        assert_eq!(ts, 946684800);
903        // 1970-01-01 00:00 UTC = 0
904        assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
905    }
906
907    #[test]
908    fn test_approximate_epoch_leap_year() {
909        // 2024-02-29 should differ from 2024-03-01 by 86400
910        let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
911        let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
912        assert_eq!(mar01 - feb29, 86400);
913    }
914
915    // =========================================================================
916    // epoch_to_year
917    // =========================================================================
918
919    #[test]
920    fn test_epoch_to_year() {
921        assert_eq!(epoch_to_year(0), 1970);
922        // 2023-01-01 00:00 UTC = 1672531200
923        assert_eq!(epoch_to_year(1672531200), 2023);
924        // 2024-01-01 00:00 UTC = 1704067200
925        assert_eq!(epoch_to_year(1704067200), 2024);
926        // 2024-12-31 23:59:59
927        assert_eq!(epoch_to_year(1735689599), 2024);
928        // 2025-01-01 00:00:00
929        assert_eq!(epoch_to_year(1735689600), 2025);
930    }
931
932    // =========================================================================
933    // parse_ls_date
934    // =========================================================================
935
936    #[test]
937    fn test_parse_ls_date_recent_format() {
938        // "Jan 15 12:34" - should return a timestamp
939        let ts = parse_ls_date("Jan", "15", "12:34");
940        assert!(ts.is_some());
941        let ts = ts.unwrap();
942        // Should be within the last year
943        let now = std::time::SystemTime::now()
944            .duration_since(std::time::UNIX_EPOCH)
945            .unwrap()
946            .as_secs() as i64;
947        assert!(ts <= now + 86400);
948        assert!(ts > now - 366 * 86400);
949    }
950
951    #[test]
952    fn test_parse_ls_date_old_format() {
953        let ts = parse_ls_date("Mar", "5", "2023");
954        assert!(ts.is_some());
955        let ts = ts.unwrap();
956        // Should be in 2023
957        assert_eq!(epoch_to_year(ts), 2023);
958    }
959
960    #[test]
961    fn test_parse_ls_date_invalid_month() {
962        assert!(parse_ls_date("Foo", "1", "12:00").is_none());
963    }
964
965    #[test]
966    fn test_parse_ls_date_invalid_day() {
967        assert!(parse_ls_date("Jan", "0", "12:00").is_none());
968        assert!(parse_ls_date("Jan", "32", "12:00").is_none());
969    }
970
971    #[test]
972    fn test_parse_ls_date_invalid_year() {
973        assert!(parse_ls_date("Jan", "1", "1969").is_none());
974    }
975
976    // =========================================================================
977    // format_relative_time
978    // =========================================================================
979
980    #[test]
981    fn test_format_relative_time_ranges() {
982        let now = std::time::SystemTime::now()
983            .duration_since(std::time::UNIX_EPOCH)
984            .unwrap()
985            .as_secs() as i64;
986        assert_eq!(format_relative_time(now), "just now");
987        assert_eq!(format_relative_time(now - 30), "just now");
988        assert_eq!(format_relative_time(now - 120), "2m ago");
989        assert_eq!(format_relative_time(now - 7200), "2h ago");
990        assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
991    }
992
993    #[test]
994    fn test_format_relative_time_old_date() {
995        // A date far in the past should show short date format
996        let old = approximate_epoch(2020, 5, 15, 0, 0);
997        let result = format_relative_time(old);
998        assert!(
999            result.contains("2020"),
1000            "Expected year in '{}' for old date",
1001            result
1002        );
1003    }
1004
1005    #[test]
1006    fn test_format_relative_time_future() {
1007        let now = std::time::SystemTime::now()
1008            .duration_since(std::time::UNIX_EPOCH)
1009            .unwrap()
1010            .as_secs() as i64;
1011        // Future timestamp should not panic and should show date
1012        let result = format_relative_time(now + 86400 * 30);
1013        assert!(!result.is_empty());
1014    }
1015
1016    // =========================================================================
1017    // format_short_date
1018    // =========================================================================
1019
1020    #[test]
1021    fn test_format_short_date_different_year() {
1022        let ts = approximate_epoch(2020, 2, 15, 0, 0); // Mar 15 2020
1023        let result = format_short_date(ts);
1024        assert!(result.contains("2020"), "Expected year in '{}'", result);
1025        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1026    }
1027
1028    #[test]
1029    fn test_format_short_date_leap_year() {
1030        // Mar 1 2024 (leap year, different year) should show "Mar 2024"
1031        let ts = approximate_epoch(2024, 2, 1, 0, 0);
1032        let result = format_short_date(ts);
1033        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1034        assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1035        // Verify Feb 29 and Mar 1 are distinct days (86400 apart)
1036        let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1037        let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1038        let feb29_date = format_short_date(feb29);
1039        let mar01_date = format_short_date(mar01);
1040        assert!(
1041            feb29_date.starts_with("Feb"),
1042            "Expected Feb in '{}'",
1043            feb29_date
1044        );
1045        assert!(
1046            mar01_date.starts_with("Mar"),
1047            "Expected Mar in '{}'",
1048            mar01_date
1049        );
1050    }
1051
1052    // =========================================================================
1053    // sort_entries (date mode)
1054    // =========================================================================
1055
1056    #[test]
1057    fn test_sort_entries_date_dirs_first_newest_first() {
1058        let mut entries = vec![
1059            FileEntry {
1060                name: "old.txt".into(),
1061                is_dir: false,
1062                size: Some(100),
1063                modified: Some(1000),
1064            },
1065            FileEntry {
1066                name: "new.txt".into(),
1067                is_dir: false,
1068                size: Some(200),
1069                modified: Some(3000),
1070            },
1071            FileEntry {
1072                name: "mid.txt".into(),
1073                is_dir: false,
1074                size: Some(150),
1075                modified: Some(2000),
1076            },
1077            FileEntry {
1078                name: "adir".into(),
1079                is_dir: true,
1080                size: None,
1081                modified: Some(500),
1082            },
1083        ];
1084        sort_entries(&mut entries, BrowserSort::Date);
1085        assert!(entries[0].is_dir);
1086        assert_eq!(entries[0].name, "adir");
1087        assert_eq!(entries[1].name, "new.txt");
1088        assert_eq!(entries[2].name, "mid.txt");
1089        assert_eq!(entries[3].name, "old.txt");
1090    }
1091
1092    #[test]
1093    fn test_sort_entries_name_mode() {
1094        let mut entries = vec![
1095            FileEntry {
1096                name: "zebra.txt".into(),
1097                is_dir: false,
1098                size: Some(100),
1099                modified: Some(3000),
1100            },
1101            FileEntry {
1102                name: "alpha.txt".into(),
1103                is_dir: false,
1104                size: Some(200),
1105                modified: Some(1000),
1106            },
1107            FileEntry {
1108                name: "mydir".into(),
1109                is_dir: true,
1110                size: None,
1111                modified: Some(2000),
1112            },
1113        ];
1114        sort_entries(&mut entries, BrowserSort::Name);
1115        assert!(entries[0].is_dir);
1116        assert_eq!(entries[1].name, "alpha.txt");
1117        assert_eq!(entries[2].name, "zebra.txt");
1118    }
1119
1120    // =========================================================================
1121    // parse_ls_output with modified field
1122    // =========================================================================
1123
1124    #[test]
1125    fn test_parse_ls_output_populates_modified() {
1126        let output = "\
1127total 4
1128-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
1129";
1130        let entries = parse_ls_output(output, true, BrowserSort::Name);
1131        assert_eq!(entries.len(), 1);
1132        assert!(
1133            entries[0].modified.is_some(),
1134            "modified should be populated"
1135        );
1136    }
1137
1138    #[test]
1139    fn test_parse_ls_output_date_sort() {
1140        // Use year format to avoid ambiguity with current date
1141        let output = "\
1142total 12
1143-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1144-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1145-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1146";
1147        let entries = parse_ls_output(output, true, BrowserSort::Date);
1148        assert_eq!(entries.len(), 3);
1149        // Should be sorted newest first (2023 > 2022 > 2020)
1150        assert_eq!(entries[0].name, "new.txt");
1151        assert_eq!(entries[1].name, "mid.txt");
1152        assert_eq!(entries[2].name, "old.txt");
1153    }
1154
1155    // =========================================================================
1156    // list_local with modified field
1157    // =========================================================================
1158
1159    #[test]
1160    fn test_list_local_populates_modified() {
1161        let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1162        let _ = std::fs::remove_dir_all(&base);
1163        std::fs::create_dir_all(&base).unwrap();
1164        std::fs::write(base.join("test.txt"), "hello").unwrap();
1165
1166        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1167        assert_eq!(entries.len(), 1);
1168        assert!(
1169            entries[0].modified.is_some(),
1170            "modified should be populated for local files"
1171        );
1172
1173        let _ = std::fs::remove_dir_all(&base);
1174    }
1175
1176    // =========================================================================
1177    // epoch_to_year boundary
1178    // =========================================================================
1179
1180    #[test]
1181    fn test_epoch_to_year_2100_boundary() {
1182        let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1183        assert_eq!(epoch_to_year(ts_2100), 2100);
1184        assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1185        let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1186        assert_eq!(epoch_to_year(mid_2100), 2100);
1187    }
1188
1189    // =========================================================================
1190    // parse_ls_date edge cases
1191    // =========================================================================
1192
1193    #[test]
1194    fn test_parse_ls_date_midnight() {
1195        let ts = parse_ls_date("Jan", "1", "00:00");
1196        assert!(ts.is_some(), "00:00 should parse successfully");
1197        let ts = ts.unwrap();
1198        let now = std::time::SystemTime::now()
1199            .duration_since(std::time::UNIX_EPOCH)
1200            .unwrap()
1201            .as_secs() as i64;
1202        assert!(ts <= now + 86400);
1203        assert!(ts > now - 366 * 86400);
1204    }
1205
1206    // =========================================================================
1207    // sort_entries edge cases
1208    // =========================================================================
1209
1210    #[test]
1211    fn test_sort_entries_date_with_none_modified() {
1212        let mut entries = vec![
1213            FileEntry {
1214                name: "known.txt".into(),
1215                is_dir: false,
1216                size: Some(100),
1217                modified: Some(5000),
1218            },
1219            FileEntry {
1220                name: "unknown.txt".into(),
1221                is_dir: false,
1222                size: Some(200),
1223                modified: None,
1224            },
1225            FileEntry {
1226                name: "recent.txt".into(),
1227                is_dir: false,
1228                size: Some(300),
1229                modified: Some(9000),
1230            },
1231        ];
1232        sort_entries(&mut entries, BrowserSort::Date);
1233        assert_eq!(entries[0].name, "recent.txt");
1234        assert_eq!(entries[1].name, "known.txt");
1235        assert_eq!(entries[2].name, "unknown.txt");
1236    }
1237
1238    #[test]
1239    fn test_sort_entries_date_asc_oldest_first() {
1240        let mut entries = vec![
1241            FileEntry {
1242                name: "old.txt".into(),
1243                is_dir: false,
1244                size: Some(100),
1245                modified: Some(1000),
1246            },
1247            FileEntry {
1248                name: "new.txt".into(),
1249                is_dir: false,
1250                size: Some(200),
1251                modified: Some(3000),
1252            },
1253            FileEntry {
1254                name: "mid.txt".into(),
1255                is_dir: false,
1256                size: Some(150),
1257                modified: Some(2000),
1258            },
1259            FileEntry {
1260                name: "adir".into(),
1261                is_dir: true,
1262                size: None,
1263                modified: Some(500),
1264            },
1265        ];
1266        sort_entries(&mut entries, BrowserSort::DateAsc);
1267        assert!(entries[0].is_dir);
1268        assert_eq!(entries[0].name, "adir");
1269        assert_eq!(entries[1].name, "old.txt");
1270        assert_eq!(entries[2].name, "mid.txt");
1271        assert_eq!(entries[3].name, "new.txt");
1272    }
1273
1274    #[test]
1275    fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1276        let mut entries = vec![
1277            FileEntry {
1278                name: "known.txt".into(),
1279                is_dir: false,
1280                size: Some(100),
1281                modified: Some(5000),
1282            },
1283            FileEntry {
1284                name: "unknown.txt".into(),
1285                is_dir: false,
1286                size: Some(200),
1287                modified: None,
1288            },
1289            FileEntry {
1290                name: "old.txt".into(),
1291                is_dir: false,
1292                size: Some(300),
1293                modified: Some(1000),
1294            },
1295        ];
1296        sort_entries(&mut entries, BrowserSort::DateAsc);
1297        assert_eq!(entries[0].name, "old.txt");
1298        assert_eq!(entries[1].name, "known.txt");
1299        assert_eq!(entries[2].name, "unknown.txt"); // None sorts to end
1300    }
1301
1302    #[test]
1303    fn test_parse_ls_output_date_asc_sort() {
1304        let output = "\
1305total 12
1306-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1307-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1308-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1309";
1310        let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1311        assert_eq!(entries.len(), 3);
1312        // Should be sorted oldest first (2020 < 2022 < 2023)
1313        assert_eq!(entries[0].name, "old.txt");
1314        assert_eq!(entries[1].name, "mid.txt");
1315        assert_eq!(entries[2].name, "new.txt");
1316    }
1317
1318    #[test]
1319    fn test_sort_entries_date_multiple_dirs() {
1320        let mut entries = vec![
1321            FileEntry {
1322                name: "old_dir".into(),
1323                is_dir: true,
1324                size: None,
1325                modified: Some(1000),
1326            },
1327            FileEntry {
1328                name: "new_dir".into(),
1329                is_dir: true,
1330                size: None,
1331                modified: Some(3000),
1332            },
1333            FileEntry {
1334                name: "mid_dir".into(),
1335                is_dir: true,
1336                size: None,
1337                modified: Some(2000),
1338            },
1339            FileEntry {
1340                name: "file.txt".into(),
1341                is_dir: false,
1342                size: Some(100),
1343                modified: Some(5000),
1344            },
1345        ];
1346        sort_entries(&mut entries, BrowserSort::Date);
1347        assert!(entries[0].is_dir);
1348        assert_eq!(entries[0].name, "new_dir");
1349        assert_eq!(entries[1].name, "mid_dir");
1350        assert_eq!(entries[2].name, "old_dir");
1351        assert_eq!(entries[3].name, "file.txt");
1352    }
1353
1354    // =========================================================================
1355    // format_relative_time boundaries
1356    // =========================================================================
1357
1358    #[test]
1359    fn test_format_relative_time_exactly_60s() {
1360        let now = std::time::SystemTime::now()
1361            .duration_since(std::time::UNIX_EPOCH)
1362            .unwrap()
1363            .as_secs() as i64;
1364        assert_eq!(format_relative_time(now - 60), "1m ago");
1365        assert_eq!(format_relative_time(now - 59), "just now");
1366    }
1367
1368    // =========================================================================
1369    // parse_ls_output date sort with dirs
1370    // =========================================================================
1371
1372    #[test]
1373    fn test_parse_ls_output_date_sort_with_dirs() {
1374        let output = "\
1375total 16
1376drwxr-xr-x  2 user user 4096 Jan  1  2020 old_dir
1377-rw-r--r--  1 user user  200 Jun 15  2023 new_file.txt
1378drwxr-xr-x  2 user user 4096 Dec  1  2023 new_dir
1379-rw-r--r--  1 user user  100 Mar  5  2022 old_file.txt
1380";
1381        let entries = parse_ls_output(output, true, BrowserSort::Date);
1382        assert_eq!(entries.len(), 4);
1383        assert!(entries[0].is_dir);
1384        assert_eq!(entries[0].name, "new_dir");
1385        assert!(entries[1].is_dir);
1386        assert_eq!(entries[1].name, "old_dir");
1387        assert_eq!(entries[2].name, "new_file.txt");
1388        assert_eq!(entries[3].name, "old_file.txt");
1389    }
1390}