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