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: /path -> '/path'
346/// Internal single quotes escaped as '\''
347fn shell_escape(path: &str) -> String {
348    format!("'{}'", path.replace('\'', "'\\''"))
349}
350
351/// Get the remote home directory via `pwd`.
352pub fn get_remote_home(
353    alias: &str,
354    config_path: &Path,
355    askpass: Option<&str>,
356    bw_session: Option<&str>,
357    has_active_tunnel: bool,
358) -> anyhow::Result<String> {
359    let result = crate::snippet::run_snippet(
360        alias,
361        config_path,
362        "pwd",
363        askpass,
364        bw_session,
365        true,
366        has_active_tunnel,
367    )?;
368    if result.status.success() {
369        Ok(result.stdout.trim().to_string())
370    } else {
371        let msg = filter_ssh_warnings(result.stderr.trim());
372        if msg.is_empty() {
373            anyhow::bail!("Failed to connect.")
374        } else {
375            anyhow::bail!("{}", msg)
376        }
377    }
378}
379
380/// Fetch remote directory listing synchronously (used by spawn_remote_listing).
381#[allow(clippy::too_many_arguments)]
382pub fn fetch_remote_listing(
383    alias: &str,
384    config_path: &Path,
385    remote_path: &str,
386    show_hidden: bool,
387    sort: BrowserSort,
388    askpass: Option<&str>,
389    bw_session: Option<&str>,
390    has_tunnel: bool,
391) -> Result<Vec<FileEntry>, String> {
392    let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
393    let result = crate::snippet::run_snippet(
394        alias,
395        config_path,
396        &command,
397        askpass,
398        bw_session,
399        true,
400        has_tunnel,
401    );
402    match result {
403        Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
404        Ok(r) => {
405            let msg = filter_ssh_warnings(r.stderr.trim());
406            if msg.is_empty() {
407                Err(format!("ls exited with code {}.", r.status.code().unwrap_or(1)))
408            } else {
409                Err(msg)
410            }
411        }
412        Err(e) => Err(e.to_string()),
413    }
414}
415
416/// Spawn background thread for remote directory listing.
417/// Sends result back via the provided sender function.
418#[allow(clippy::too_many_arguments)]
419pub fn spawn_remote_listing<F>(
420    alias: String,
421    config_path: PathBuf,
422    remote_path: String,
423    show_hidden: bool,
424    sort: BrowserSort,
425    askpass: Option<String>,
426    bw_session: Option<String>,
427    has_tunnel: bool,
428    send: F,
429) where
430    F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
431{
432    std::thread::spawn(move || {
433        let listing = fetch_remote_listing(
434            &alias,
435            &config_path,
436            &remote_path,
437            show_hidden,
438            sort,
439            askpass.as_deref(),
440            bw_session.as_deref(),
441            has_tunnel,
442        );
443        send(alias, remote_path, listing);
444    });
445}
446
447/// Result of an scp transfer.
448pub struct ScpResult {
449    pub status: ExitStatus,
450    pub stderr_output: String,
451}
452
453/// Run scp in the background with captured stderr for error reporting.
454/// Stderr is piped and captured so errors can be extracted. Progress percentage
455/// is not available because scp only outputs progress to a TTY, not to a pipe.
456/// Stdin is null (askpass handles authentication). Stdout is null (scp has no
457/// meaningful stdout output).
458pub fn run_scp(
459    alias: &str,
460    config_path: &Path,
461    askpass: Option<&str>,
462    bw_session: Option<&str>,
463    has_active_tunnel: bool,
464    scp_args: &[String],
465) -> anyhow::Result<ScpResult> {
466    let mut cmd = Command::new("scp");
467    cmd.arg("-F").arg(config_path);
468
469    if has_active_tunnel {
470        cmd.arg("-o").arg("ClearAllForwardings=yes");
471    }
472
473    for arg in scp_args {
474        cmd.arg(arg);
475    }
476
477    cmd.stdin(Stdio::null())
478        .stdout(Stdio::null())
479        .stderr(Stdio::piped());
480
481    if askpass.is_some() {
482        let exe = std::env::current_exe()
483            .ok()
484            .map(|p| p.to_string_lossy().to_string())
485            .or_else(|| std::env::args().next())
486            .unwrap_or_else(|| "purple".to_string());
487        cmd.env("SSH_ASKPASS", &exe)
488            .env("SSH_ASKPASS_REQUIRE", "prefer")
489            .env("PURPLE_ASKPASS_MODE", "1")
490            .env("PURPLE_HOST_ALIAS", alias)
491            .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
492    }
493
494    if let Some(token) = bw_session {
495        cmd.env("BW_SESSION", token);
496    }
497
498    let output = cmd
499        .output()
500        .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
501
502    let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
503
504    Ok(ScpResult { status: output.status, stderr_output })
505}
506
507/// Filter SSH warning noise from stderr, keeping only actionable error lines.
508/// Strips lines like "** WARNING: connection is not using a post-quantum key exchange".
509pub fn filter_ssh_warnings(stderr: &str) -> String {
510    stderr
511        .lines()
512        .filter(|line| {
513            let trimmed = line.trim();
514            !trimmed.is_empty()
515                && !trimmed.starts_with("** ")
516                && !trimmed.starts_with("Warning:")
517                && !trimmed.contains("see https://")
518                && !trimmed.contains("See https://")
519                && !trimmed.starts_with("The server may need")
520                && !trimmed.starts_with("This session may be")
521        })
522        .collect::<Vec<_>>()
523        .join("\n")
524}
525
526/// Build scp arguments for a file transfer.
527/// Returns the args to pass after `scp -F <config>`.
528///
529/// Remote paths are NOT shell-escaped because scp is invoked via Command::arg()
530/// which bypasses the shell entirely. The colon in `alias:path` is the only
531/// special character scp interprets. Paths with spaces, globbing chars etc. are
532/// passed through literally by the OS exec layer.
533pub fn build_scp_args(
534    alias: &str,
535    source_pane: BrowserPane,
536    local_path: &Path,
537    remote_path: &str,
538    filenames: &[String],
539    has_dirs: bool,
540) -> Vec<String> {
541    let mut args = Vec::new();
542    if has_dirs {
543        args.push("-r".to_string());
544    }
545    args.push("--".to_string());
546
547    match source_pane {
548        // Upload: local files -> remote
549        BrowserPane::Local => {
550            for name in filenames {
551                args.push(local_path.join(name).to_string_lossy().to_string());
552            }
553            let dest = format!("{}:{}", alias, remote_path);
554            args.push(dest);
555        }
556        // Download: remote files -> local
557        BrowserPane::Remote => {
558            let base = remote_path.trim_end_matches('/');
559            for name in filenames {
560                let rpath = format!("{}/{}", base, name);
561                args.push(format!("{}:{}", alias, rpath));
562            }
563            args.push(local_path.to_string_lossy().to_string());
564        }
565    }
566    args
567}
568
569/// Format a file size in human-readable form.
570pub fn format_size(bytes: u64) -> String {
571    if bytes >= 1024 * 1024 * 1024 {
572        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
573    } else if bytes >= 1024 * 1024 {
574        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
575    } else if bytes >= 1024 {
576        format!("{:.1} KB", bytes as f64 / 1024.0)
577    } else {
578        format!("{} B", bytes)
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    // =========================================================================
587    // shell_escape
588    // =========================================================================
589
590    #[test]
591    fn test_shell_escape_simple() {
592        assert_eq!(shell_escape("/home/user"), "'/home/user'");
593    }
594
595    #[test]
596    fn test_shell_escape_with_single_quote() {
597        assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
598    }
599
600    #[test]
601    fn test_shell_escape_with_spaces() {
602        assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
603    }
604
605    // =========================================================================
606    // parse_ls_output
607    // =========================================================================
608
609    #[test]
610    fn test_parse_ls_basic() {
611        let output = "\
612total 24
613drwxr-xr-x  2 user user 4096 Jan  1 12:00 subdir
614-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
615-rw-r--r--  1 user user 1.1K Jan  1 12:00 big.log
616";
617        let entries = parse_ls_output(output, true, BrowserSort::Name);
618        assert_eq!(entries.len(), 3);
619        assert_eq!(entries[0].name, "subdir");
620        assert!(entries[0].is_dir);
621        assert_eq!(entries[0].size, None);
622        // Files sorted alphabetically after dirs
623        assert_eq!(entries[1].name, "big.log");
624        assert!(!entries[1].is_dir);
625        assert_eq!(entries[1].size, Some(1126)); // 1.1 * 1024
626        assert_eq!(entries[2].name, "file.txt");
627        assert!(!entries[2].is_dir);
628        assert_eq!(entries[2].size, Some(512));
629    }
630
631    #[test]
632    fn test_parse_ls_hidden_filter() {
633        let output = "\
634total 8
635-rw-r--r--  1 user user  100 Jan  1 12:00 .hidden
636-rw-r--r--  1 user user  200 Jan  1 12:00 visible
637";
638        let entries = parse_ls_output(output, false, BrowserSort::Name);
639        assert_eq!(entries.len(), 1);
640        assert_eq!(entries[0].name, "visible");
641
642        let entries = parse_ls_output(output, true, BrowserSort::Name);
643        assert_eq!(entries.len(), 2);
644    }
645
646    #[test]
647    fn test_parse_ls_symlink_to_file_dereferenced() {
648        // With -L, symlink to file appears as regular file
649        let output = "\
650total 4
651-rw-r--r--  1 user user   11 Jan  1 12:00 link
652";
653        let entries = parse_ls_output(output, true, BrowserSort::Name);
654        assert_eq!(entries.len(), 1);
655        assert_eq!(entries[0].name, "link");
656        assert!(!entries[0].is_dir);
657    }
658
659    #[test]
660    fn test_parse_ls_symlink_to_dir_dereferenced() {
661        // With -L, symlink to directory appears as directory
662        let output = "\
663total 4
664drwxr-xr-x  3 user user 4096 Jan  1 12:00 link
665";
666        let entries = parse_ls_output(output, true, BrowserSort::Name);
667        assert_eq!(entries.len(), 1);
668        assert_eq!(entries[0].name, "link");
669        assert!(entries[0].is_dir);
670    }
671
672    #[test]
673    fn test_parse_ls_filename_with_spaces() {
674        let output = "\
675total 4
676-rw-r--r--  1 user user  100 Jan  1 12:00 my file name.txt
677";
678        let entries = parse_ls_output(output, true, BrowserSort::Name);
679        assert_eq!(entries.len(), 1);
680        assert_eq!(entries[0].name, "my file name.txt");
681    }
682
683    #[test]
684    fn test_parse_ls_empty() {
685        let output = "total 0\n";
686        let entries = parse_ls_output(output, true, BrowserSort::Name);
687        assert!(entries.is_empty());
688    }
689
690    // =========================================================================
691    // parse_human_size
692    // =========================================================================
693
694    #[test]
695    fn test_parse_human_size() {
696        assert_eq!(parse_human_size("512"), 512);
697        assert_eq!(parse_human_size("1.0K"), 1024);
698        assert_eq!(parse_human_size("1.5M"), 1572864);
699        assert_eq!(parse_human_size("2.0G"), 2147483648);
700    }
701
702    // =========================================================================
703    // format_size
704    // =========================================================================
705
706    #[test]
707    fn test_format_size() {
708        assert_eq!(format_size(0), "0 B");
709        assert_eq!(format_size(512), "512 B");
710        assert_eq!(format_size(1024), "1.0 KB");
711        assert_eq!(format_size(1536), "1.5 KB");
712        assert_eq!(format_size(1048576), "1.0 MB");
713        assert_eq!(format_size(1073741824), "1.0 GB");
714    }
715
716    // =========================================================================
717    // build_scp_args
718    // =========================================================================
719
720    #[test]
721    fn test_build_scp_args_upload() {
722        let args = build_scp_args(
723            "myhost",
724            BrowserPane::Local,
725            Path::new("/home/user/docs"),
726            "/remote/path/",
727            &["file.txt".to_string()],
728            false,
729        );
730        assert_eq!(args, vec![
731            "--",
732            "/home/user/docs/file.txt",
733            "myhost:/remote/path/",
734        ]);
735    }
736
737    #[test]
738    fn test_build_scp_args_download() {
739        let args = build_scp_args(
740            "myhost",
741            BrowserPane::Remote,
742            Path::new("/home/user/docs"),
743            "/remote/path",
744            &["file.txt".to_string()],
745            false,
746        );
747        assert_eq!(args, vec![
748            "--",
749            "myhost:/remote/path/file.txt",
750            "/home/user/docs",
751        ]);
752    }
753
754    #[test]
755    fn test_build_scp_args_spaces_in_path() {
756        let args = build_scp_args(
757            "myhost",
758            BrowserPane::Remote,
759            Path::new("/local"),
760            "/remote/my path",
761            &["my file.txt".to_string()],
762            false,
763        );
764        // No shell escaping: Command::arg() passes paths literally
765        assert_eq!(args, vec![
766            "--",
767            "myhost:/remote/my path/my file.txt",
768            "/local",
769        ]);
770    }
771
772    #[test]
773    fn test_build_scp_args_with_dirs() {
774        let args = build_scp_args(
775            "myhost",
776            BrowserPane::Local,
777            Path::new("/local"),
778            "/remote/",
779            &["mydir".to_string()],
780            true,
781        );
782        assert_eq!(args[0], "-r");
783    }
784
785    // =========================================================================
786    // list_local
787    // =========================================================================
788
789    #[test]
790    fn test_list_local_sorts_dirs_first() {
791        let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
792        let _ = std::fs::remove_dir_all(&base);
793        std::fs::create_dir_all(&base).unwrap();
794        std::fs::create_dir(base.join("zdir")).unwrap();
795        std::fs::write(base.join("afile.txt"), "hello").unwrap();
796        std::fs::write(base.join("bfile.txt"), "world").unwrap();
797
798        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
799        assert_eq!(entries.len(), 3);
800        assert!(entries[0].is_dir);
801        assert_eq!(entries[0].name, "zdir");
802        assert_eq!(entries[1].name, "afile.txt");
803        assert_eq!(entries[2].name, "bfile.txt");
804
805        let _ = std::fs::remove_dir_all(&base);
806    }
807
808    #[test]
809    fn test_list_local_hidden() {
810        let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
811        let _ = std::fs::remove_dir_all(&base);
812        std::fs::create_dir_all(&base).unwrap();
813        std::fs::write(base.join(".hidden"), "").unwrap();
814        std::fs::write(base.join("visible"), "").unwrap();
815
816        let entries = list_local(&base, false, BrowserSort::Name).unwrap();
817        assert_eq!(entries.len(), 1);
818        assert_eq!(entries[0].name, "visible");
819
820        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
821        assert_eq!(entries.len(), 2);
822
823        let _ = std::fs::remove_dir_all(&base);
824    }
825
826    // =========================================================================
827    // filter_ssh_warnings
828    // =========================================================================
829
830    #[test]
831    fn test_filter_ssh_warnings_filters_warnings() {
832        let stderr = "\
833** WARNING: connection is not using a post-quantum key exchange algorithm.
834** This session may be vulnerable to \"store now, decrypt later\" attacks.
835** The server may need to be upgraded. See https://openssh.com/pq.html
836scp: '/root/file.rpm': No such file or directory";
837        assert_eq!(
838            filter_ssh_warnings(stderr),
839            "scp: '/root/file.rpm': No such file or directory"
840        );
841    }
842
843    #[test]
844    fn test_filter_ssh_warnings_keeps_plain_error() {
845        let stderr = "scp: /etc/shadow: Permission denied\n";
846        assert_eq!(filter_ssh_warnings(stderr), "scp: /etc/shadow: Permission denied");
847    }
848
849    #[test]
850    fn test_filter_ssh_warnings_empty() {
851        assert_eq!(filter_ssh_warnings(""), "");
852        assert_eq!(filter_ssh_warnings("  \n  \n"), "");
853    }
854
855    #[test]
856    fn test_filter_ssh_warnings_warning_prefix() {
857        let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
858        assert_eq!(filter_ssh_warnings(stderr), "Permission denied (publickey).");
859    }
860
861    #[test]
862    fn test_filter_ssh_warnings_lowercase_see_https() {
863        let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
864        assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
865    }
866
867    #[test]
868    fn test_filter_ssh_warnings_only_warnings() {
869        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";
870        assert_eq!(filter_ssh_warnings(stderr), "");
871    }
872
873    // =========================================================================
874    // approximate_epoch (known dates)
875    // =========================================================================
876
877    #[test]
878    fn test_approximate_epoch_known_dates() {
879        // 2024-01-01 00:00 UTC = 1704067200
880        let ts = approximate_epoch(2024, 0, 1, 0, 0);
881        assert_eq!(ts, 1704067200);
882        // 2000-01-01 00:00 UTC = 946684800
883        let ts = approximate_epoch(2000, 0, 1, 0, 0);
884        assert_eq!(ts, 946684800);
885        // 1970-01-01 00:00 UTC = 0
886        assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
887    }
888
889    #[test]
890    fn test_approximate_epoch_leap_year() {
891        // 2024-02-29 should differ from 2024-03-01 by 86400
892        let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
893        let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
894        assert_eq!(mar01 - feb29, 86400);
895    }
896
897    // =========================================================================
898    // epoch_to_year
899    // =========================================================================
900
901    #[test]
902    fn test_epoch_to_year() {
903        assert_eq!(epoch_to_year(0), 1970);
904        // 2023-01-01 00:00 UTC = 1672531200
905        assert_eq!(epoch_to_year(1672531200), 2023);
906        // 2024-01-01 00:00 UTC = 1704067200
907        assert_eq!(epoch_to_year(1704067200), 2024);
908        // 2024-12-31 23:59:59
909        assert_eq!(epoch_to_year(1735689599), 2024);
910        // 2025-01-01 00:00:00
911        assert_eq!(epoch_to_year(1735689600), 2025);
912    }
913
914    // =========================================================================
915    // parse_ls_date
916    // =========================================================================
917
918    #[test]
919    fn test_parse_ls_date_recent_format() {
920        // "Jan 15 12:34" - should return a timestamp
921        let ts = parse_ls_date("Jan", "15", "12:34");
922        assert!(ts.is_some());
923        let ts = ts.unwrap();
924        // Should be within the last year
925        let now = std::time::SystemTime::now()
926            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
927        assert!(ts <= now + 86400);
928        assert!(ts > now - 366 * 86400);
929    }
930
931    #[test]
932    fn test_parse_ls_date_old_format() {
933        let ts = parse_ls_date("Mar", "5", "2023");
934        assert!(ts.is_some());
935        let ts = ts.unwrap();
936        // Should be in 2023
937        assert_eq!(epoch_to_year(ts), 2023);
938    }
939
940    #[test]
941    fn test_parse_ls_date_invalid_month() {
942        assert!(parse_ls_date("Foo", "1", "12:00").is_none());
943    }
944
945    #[test]
946    fn test_parse_ls_date_invalid_day() {
947        assert!(parse_ls_date("Jan", "0", "12:00").is_none());
948        assert!(parse_ls_date("Jan", "32", "12:00").is_none());
949    }
950
951    #[test]
952    fn test_parse_ls_date_invalid_year() {
953        assert!(parse_ls_date("Jan", "1", "1969").is_none());
954    }
955
956    // =========================================================================
957    // format_relative_time
958    // =========================================================================
959
960    #[test]
961    fn test_format_relative_time_ranges() {
962        let now = std::time::SystemTime::now()
963            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
964        assert_eq!(format_relative_time(now), "just now");
965        assert_eq!(format_relative_time(now - 30), "just now");
966        assert_eq!(format_relative_time(now - 120), "2m ago");
967        assert_eq!(format_relative_time(now - 7200), "2h ago");
968        assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
969    }
970
971    #[test]
972    fn test_format_relative_time_old_date() {
973        // A date far in the past should show short date format
974        let old = approximate_epoch(2020, 5, 15, 0, 0);
975        let result = format_relative_time(old);
976        assert!(result.contains("2020"), "Expected year in '{}' for old date", result);
977    }
978
979    #[test]
980    fn test_format_relative_time_future() {
981        let now = std::time::SystemTime::now()
982            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
983        // Future timestamp should not panic and should show date
984        let result = format_relative_time(now + 86400 * 30);
985        assert!(!result.is_empty());
986    }
987
988    // =========================================================================
989    // format_short_date
990    // =========================================================================
991
992    #[test]
993    fn test_format_short_date_different_year() {
994        let ts = approximate_epoch(2020, 2, 15, 0, 0); // Mar 15 2020
995        let result = format_short_date(ts);
996        assert!(result.contains("2020"), "Expected year in '{}'", result);
997        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
998    }
999
1000    #[test]
1001    fn test_format_short_date_leap_year() {
1002        // Mar 1 2024 (leap year, different year) should show "Mar 2024"
1003        let ts = approximate_epoch(2024, 2, 1, 0, 0);
1004        let result = format_short_date(ts);
1005        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1006        assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1007        // Verify Feb 29 and Mar 1 are distinct days (86400 apart)
1008        let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1009        let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1010        let feb29_date = format_short_date(feb29);
1011        let mar01_date = format_short_date(mar01);
1012        assert!(feb29_date.starts_with("Feb"), "Expected Feb in '{}'", feb29_date);
1013        assert!(mar01_date.starts_with("Mar"), "Expected Mar in '{}'", mar01_date);
1014    }
1015
1016    // =========================================================================
1017    // sort_entries (date mode)
1018    // =========================================================================
1019
1020    #[test]
1021    fn test_sort_entries_date_dirs_first_newest_first() {
1022        let mut entries = vec![
1023            FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1024            FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1025            FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1026            FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1027        ];
1028        sort_entries(&mut entries, BrowserSort::Date);
1029        assert!(entries[0].is_dir);
1030        assert_eq!(entries[0].name, "adir");
1031        assert_eq!(entries[1].name, "new.txt");
1032        assert_eq!(entries[2].name, "mid.txt");
1033        assert_eq!(entries[3].name, "old.txt");
1034    }
1035
1036    #[test]
1037    fn test_sort_entries_name_mode() {
1038        let mut entries = vec![
1039            FileEntry { name: "zebra.txt".into(), is_dir: false, size: Some(100), modified: Some(3000) },
1040            FileEntry { name: "alpha.txt".into(), is_dir: false, size: Some(200), modified: Some(1000) },
1041            FileEntry { name: "mydir".into(), is_dir: true, size: None, modified: Some(2000) },
1042        ];
1043        sort_entries(&mut entries, BrowserSort::Name);
1044        assert!(entries[0].is_dir);
1045        assert_eq!(entries[1].name, "alpha.txt");
1046        assert_eq!(entries[2].name, "zebra.txt");
1047    }
1048
1049    // =========================================================================
1050    // parse_ls_output with modified field
1051    // =========================================================================
1052
1053    #[test]
1054    fn test_parse_ls_output_populates_modified() {
1055        let output = "\
1056total 4
1057-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
1058";
1059        let entries = parse_ls_output(output, true, BrowserSort::Name);
1060        assert_eq!(entries.len(), 1);
1061        assert!(entries[0].modified.is_some(), "modified should be populated");
1062    }
1063
1064    #[test]
1065    fn test_parse_ls_output_date_sort() {
1066        // Use year format to avoid ambiguity with current date
1067        let output = "\
1068total 12
1069-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1070-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1071-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1072";
1073        let entries = parse_ls_output(output, true, BrowserSort::Date);
1074        assert_eq!(entries.len(), 3);
1075        // Should be sorted newest first (2023 > 2022 > 2020)
1076        assert_eq!(entries[0].name, "new.txt");
1077        assert_eq!(entries[1].name, "mid.txt");
1078        assert_eq!(entries[2].name, "old.txt");
1079    }
1080
1081    // =========================================================================
1082    // list_local with modified field
1083    // =========================================================================
1084
1085    #[test]
1086    fn test_list_local_populates_modified() {
1087        let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1088        let _ = std::fs::remove_dir_all(&base);
1089        std::fs::create_dir_all(&base).unwrap();
1090        std::fs::write(base.join("test.txt"), "hello").unwrap();
1091
1092        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1093        assert_eq!(entries.len(), 1);
1094        assert!(entries[0].modified.is_some(), "modified should be populated for local files");
1095
1096        let _ = std::fs::remove_dir_all(&base);
1097    }
1098
1099    // =========================================================================
1100    // epoch_to_year boundary
1101    // =========================================================================
1102
1103    #[test]
1104    fn test_epoch_to_year_2100_boundary() {
1105        let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1106        assert_eq!(epoch_to_year(ts_2100), 2100);
1107        assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1108        let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1109        assert_eq!(epoch_to_year(mid_2100), 2100);
1110    }
1111
1112    // =========================================================================
1113    // parse_ls_date edge cases
1114    // =========================================================================
1115
1116    #[test]
1117    fn test_parse_ls_date_midnight() {
1118        let ts = parse_ls_date("Jan", "1", "00:00");
1119        assert!(ts.is_some(), "00:00 should parse successfully");
1120        let ts = ts.unwrap();
1121        let now = std::time::SystemTime::now()
1122            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1123        assert!(ts <= now + 86400);
1124        assert!(ts > now - 366 * 86400);
1125    }
1126
1127    // =========================================================================
1128    // sort_entries edge cases
1129    // =========================================================================
1130
1131    #[test]
1132    fn test_sort_entries_date_with_none_modified() {
1133        let mut entries = vec![
1134            FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1135            FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1136            FileEntry { name: "recent.txt".into(), is_dir: false, size: Some(300), modified: Some(9000) },
1137        ];
1138        sort_entries(&mut entries, BrowserSort::Date);
1139        assert_eq!(entries[0].name, "recent.txt");
1140        assert_eq!(entries[1].name, "known.txt");
1141        assert_eq!(entries[2].name, "unknown.txt");
1142    }
1143
1144    #[test]
1145    fn test_sort_entries_date_asc_oldest_first() {
1146        let mut entries = vec![
1147            FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1148            FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1149            FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1150            FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1151        ];
1152        sort_entries(&mut entries, BrowserSort::DateAsc);
1153        assert!(entries[0].is_dir);
1154        assert_eq!(entries[0].name, "adir");
1155        assert_eq!(entries[1].name, "old.txt");
1156        assert_eq!(entries[2].name, "mid.txt");
1157        assert_eq!(entries[3].name, "new.txt");
1158    }
1159
1160    #[test]
1161    fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1162        let mut entries = vec![
1163            FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1164            FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1165            FileEntry { name: "old.txt".into(), is_dir: false, size: Some(300), modified: Some(1000) },
1166        ];
1167        sort_entries(&mut entries, BrowserSort::DateAsc);
1168        assert_eq!(entries[0].name, "old.txt");
1169        assert_eq!(entries[1].name, "known.txt");
1170        assert_eq!(entries[2].name, "unknown.txt"); // None sorts to end
1171    }
1172
1173    #[test]
1174    fn test_parse_ls_output_date_asc_sort() {
1175        let output = "\
1176total 12
1177-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1178-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1179-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1180";
1181        let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1182        assert_eq!(entries.len(), 3);
1183        // Should be sorted oldest first (2020 < 2022 < 2023)
1184        assert_eq!(entries[0].name, "old.txt");
1185        assert_eq!(entries[1].name, "mid.txt");
1186        assert_eq!(entries[2].name, "new.txt");
1187    }
1188
1189    #[test]
1190    fn test_sort_entries_date_multiple_dirs() {
1191        let mut entries = vec![
1192            FileEntry { name: "old_dir".into(), is_dir: true, size: None, modified: Some(1000) },
1193            FileEntry { name: "new_dir".into(), is_dir: true, size: None, modified: Some(3000) },
1194            FileEntry { name: "mid_dir".into(), is_dir: true, size: None, modified: Some(2000) },
1195            FileEntry { name: "file.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1196        ];
1197        sort_entries(&mut entries, BrowserSort::Date);
1198        assert!(entries[0].is_dir);
1199        assert_eq!(entries[0].name, "new_dir");
1200        assert_eq!(entries[1].name, "mid_dir");
1201        assert_eq!(entries[2].name, "old_dir");
1202        assert_eq!(entries[3].name, "file.txt");
1203    }
1204
1205    // =========================================================================
1206    // format_relative_time boundaries
1207    // =========================================================================
1208
1209    #[test]
1210    fn test_format_relative_time_exactly_60s() {
1211        let now = std::time::SystemTime::now()
1212            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1213        assert_eq!(format_relative_time(now - 60), "1m ago");
1214        assert_eq!(format_relative_time(now - 59), "just now");
1215    }
1216
1217    // =========================================================================
1218    // parse_ls_output date sort with dirs
1219    // =========================================================================
1220
1221    #[test]
1222    fn test_parse_ls_output_date_sort_with_dirs() {
1223        let output = "\
1224total 16
1225drwxr-xr-x  2 user user 4096 Jan  1  2020 old_dir
1226-rw-r--r--  1 user user  200 Jun 15  2023 new_file.txt
1227drwxr-xr-x  2 user user 4096 Dec  1  2023 new_dir
1228-rw-r--r--  1 user user  100 Mar  5  2022 old_file.txt
1229";
1230        let entries = parse_ls_output(output, true, BrowserSort::Date);
1231        assert_eq!(entries.len(), 4);
1232        assert!(entries[0].is_dir);
1233        assert_eq!(entries[0].name, "new_dir");
1234        assert!(entries[1].is_dir);
1235        assert_eq!(entries[1].name, "old_dir");
1236        assert_eq!(entries[2].name, "new_file.txt");
1237        assert_eq!(entries[3].name, "old_file.txt");
1238    }
1239}