Skip to main content

purple_ssh/
file_browser.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7/// Sort mode for file browser panes.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10    Name,
11    Date,
12    DateAsc,
13}
14
15/// A file or directory entry in the browser.
16#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18    pub name: String,
19    pub is_dir: bool,
20    pub size: Option<u64>,
21    /// Modification time as Unix timestamp (seconds since epoch).
22    pub modified: Option<i64>,
23}
24
25/// Which pane is active.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28    Local,
29    Remote,
30}
31
32/// Pending copy operation awaiting confirmation.
33pub struct CopyRequest {
34    pub sources: Vec<String>,
35    pub source_pane: BrowserPane,
36    pub has_dirs: bool,
37}
38
39/// State for the dual-pane file browser overlay.
40pub struct FileBrowserState {
41    pub alias: String,
42    pub askpass: Option<String>,
43    pub active_pane: BrowserPane,
44    // Local
45    pub local_path: PathBuf,
46    pub local_entries: Vec<FileEntry>,
47    pub local_list_state: ListState,
48    pub local_selected: HashSet<String>,
49    pub local_error: Option<String>,
50    // Remote
51    pub remote_path: String,
52    pub remote_entries: Vec<FileEntry>,
53    pub remote_list_state: ListState,
54    pub remote_selected: HashSet<String>,
55    pub remote_error: Option<String>,
56    pub remote_loading: bool,
57    // Options
58    pub show_hidden: bool,
59    pub sort: BrowserSort,
60    // Copy confirmation
61    pub confirm_copy: Option<CopyRequest>,
62    // Transfer in progress
63    pub transferring: Option<String>,
64    // Transfer error (shown as dismissible dialog)
65    pub transfer_error: Option<String>,
66    // Whether the initial remote connection has been recorded in history
67    pub connection_recorded: bool,
68}
69
70/// List local directory entries.
71/// Sorts: directories first, then by name or date. Filters dotfiles based on show_hidden.
72pub fn list_local(
73    path: &Path,
74    show_hidden: bool,
75    sort: BrowserSort,
76) -> anyhow::Result<Vec<FileEntry>> {
77    let mut entries = Vec::new();
78    for entry in std::fs::read_dir(path)? {
79        let entry = entry?;
80        let name = entry.file_name().to_string_lossy().to_string();
81        if !show_hidden && name.starts_with('.') {
82            continue;
83        }
84        let metadata = entry.metadata()?;
85        let is_dir = metadata.is_dir();
86        let size = if is_dir { None } else { Some(metadata.len()) };
87        let modified = metadata.modified().ok().and_then(|t| {
88            t.duration_since(std::time::UNIX_EPOCH)
89                .ok()
90                .map(|d| d.as_secs() as i64)
91        });
92        entries.push(FileEntry {
93            name,
94            is_dir,
95            size,
96            modified,
97        });
98    }
99    sort_entries(&mut entries, sort);
100    Ok(entries)
101}
102
103/// Sort file entries: directories first, then by the chosen mode.
104pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
105    match sort {
106        BrowserSort::Name => {
107            entries.sort_by(|a, b| {
108                b.is_dir.cmp(&a.is_dir).then_with(|| {
109                    a.name
110                        .to_ascii_lowercase()
111                        .cmp(&b.name.to_ascii_lowercase())
112                })
113            });
114        }
115        BrowserSort::Date => {
116            entries.sort_by(|a, b| {
117                b.is_dir.cmp(&a.is_dir).then_with(|| {
118                    // Newest first: reverse order
119                    b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
120                })
121            });
122        }
123        BrowserSort::DateAsc => {
124            entries.sort_by(|a, b| {
125                b.is_dir.cmp(&a.is_dir).then_with(|| {
126                    // Oldest first; unknown dates sort to the end
127                    a.modified
128                        .unwrap_or(i64::MAX)
129                        .cmp(&b.modified.unwrap_or(i64::MAX))
130                })
131            });
132        }
133    }
134}
135
136/// Parse `ls -lhAL` output into FileEntry list.
137/// With -L, symlinks are dereferenced so their target type is shown directly.
138/// Recognizes directories via 'd' permission prefix. Skips the "total" line.
139/// Broken symlinks are omitted by ls -L (they cannot be transferred anyway).
140pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
141    let mut entries = Vec::new();
142    for line in output.lines() {
143        let line = line.trim();
144        if line.is_empty() || line.starts_with("total ") {
145            continue;
146        }
147        // ls -l format: permissions links owner group size month day time name
148        // Split on whitespace runs, taking 9 fields (last gets the rest including spaces)
149        let mut parts: Vec<&str> = Vec::with_capacity(9);
150        let mut rest = line;
151        for _ in 0..8 {
152            rest = rest.trim_start();
153            if rest.is_empty() {
154                break;
155            }
156            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
157            parts.push(&rest[..end]);
158            rest = &rest[end..];
159        }
160        rest = rest.trim_start();
161        if !rest.is_empty() {
162            parts.push(rest);
163        }
164        if parts.len() < 9 {
165            continue;
166        }
167        let permissions = parts[0];
168        let is_dir = permissions.starts_with('d');
169        let name = parts[8];
170        // Skip empty names
171        if name.is_empty() {
172            continue;
173        }
174        if !show_hidden && name.starts_with('.') {
175            continue;
176        }
177        // Parse human-readable size (e.g. "1.1K", "4.0M", "512")
178        let size = if is_dir {
179            None
180        } else {
181            Some(parse_human_size(parts[4]))
182        };
183        // Parse date from month/day/time-or-year (parts[5..=7])
184        let modified = parse_ls_date(parts[5], parts[6], parts[7]);
185        entries.push(FileEntry {
186            name: name.to_string(),
187            is_dir,
188            size,
189            modified,
190        });
191    }
192    sort_entries(&mut entries, sort);
193    entries
194}
195
196/// Parse a human-readable size string like "1.1K", "4.0M", "512" into bytes.
197fn parse_human_size(s: &str) -> u64 {
198    let s = s.trim();
199    if s.is_empty() {
200        return 0;
201    }
202    let last = s.as_bytes()[s.len() - 1];
203    let multiplier = match last {
204        b'K' => 1024,
205        b'M' => 1024 * 1024,
206        b'G' => 1024 * 1024 * 1024,
207        b'T' => 1024u64 * 1024 * 1024 * 1024,
208        _ => 1,
209    };
210    let num_str = if multiplier > 1 { &s[..s.len() - 1] } else { s };
211    let num: f64 = num_str.parse().unwrap_or(0.0);
212    (num * multiplier as f64) as u64
213}
214
215/// Parse the date fields from `ls -l` with `LC_ALL=C`.
216/// Recent files: "Jan 1 12:34" (month day HH:MM).
217/// Old files: "Jan 1 2024" (month day year).
218/// Returns approximate Unix timestamp or None if unparseable.
219fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
220    let month = match month_str {
221        "Jan" => 0,
222        "Feb" => 1,
223        "Mar" => 2,
224        "Apr" => 3,
225        "May" => 4,
226        "Jun" => 5,
227        "Jul" => 6,
228        "Aug" => 7,
229        "Sep" => 8,
230        "Oct" => 9,
231        "Nov" => 10,
232        "Dec" => 11,
233        _ => return None,
234    };
235    let day: i64 = day_str.parse().ok()?;
236    if !(1..=31).contains(&day) {
237        return None;
238    }
239
240    let now = std::time::SystemTime::now()
241        .duration_since(std::time::UNIX_EPOCH)
242        .unwrap_or_default()
243        .as_secs() as i64;
244    let now_year = epoch_to_year(now);
245
246    if time_or_year.contains(':') {
247        // Recent format: "HH:MM"
248        let mut parts = time_or_year.splitn(2, ':');
249        let hour: i64 = parts.next()?.parse().ok()?;
250        let min: i64 = parts.next()?.parse().ok()?;
251        // Determine year: if month/day is in the future, it's last year
252        let mut year = now_year;
253        let approx = approximate_epoch(year, month, day, hour, min);
254        if approx > now + 86400 {
255            year -= 1;
256        }
257        Some(approximate_epoch(year, month, day, hour, min))
258    } else {
259        // Old format: "2024" (year)
260        let year: i64 = time_or_year.parse().ok()?;
261        if !(1970..=2100).contains(&year) {
262            return None;
263        }
264        Some(approximate_epoch(year, month, day, 0, 0))
265    }
266}
267
268/// Rough Unix timestamp from date components (no leap second precision needed).
269fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270    // Days from 1970-01-01 to start of year
271    let y = year - 1970;
272    let mut days = y * 365 + (y + 1) / 4; // approximate leap years
273    // Days to start of month (non-leap approximation, close enough for sorting)
274    let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275    days += month_days[month as usize];
276    // Add leap day if applicable
277    if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
278        days += 1;
279    }
280    days += day - 1;
281    days * 86400 + hour * 3600 + min * 60
282}
283
284/// Convert epoch seconds to a year (correctly handles year boundaries).
285fn epoch_to_year(ts: i64) -> i64 {
286    let mut y = 1970 + ts / 31_557_600;
287    if approximate_epoch(y, 0, 1, 0, 0) > ts {
288        y -= 1;
289    } else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
290        y += 1;
291    }
292    y
293}
294
295fn is_leap_year(year: i64) -> bool {
296    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
297}
298
299/// Format a Unix timestamp as a relative or short date string.
300/// Returns strings like "2m ago", "3h ago", "5d ago", "Jan 15", "Mar 2024".
301pub fn format_relative_time(ts: i64) -> String {
302    let now = std::time::SystemTime::now()
303        .duration_since(std::time::UNIX_EPOCH)
304        .unwrap_or_default()
305        .as_secs() as i64;
306    let diff = now - ts;
307    if diff < 0 {
308        // Future timestamp (clock skew), just show date
309        return format_short_date(ts);
310    }
311    if diff < 60 {
312        return "just now".to_string();
313    }
314    if diff < 3600 {
315        return format!("{}m ago", diff / 60);
316    }
317    if diff < 86400 {
318        return format!("{}h ago", diff / 3600);
319    }
320    if diff < 86400 * 30 {
321        return format!("{}d ago", diff / 86400);
322    }
323    format_short_date(ts)
324}
325
326/// Format a timestamp as "Mon DD" (same year) or "Mon YYYY" (different year).
327fn format_short_date(ts: i64) -> String {
328    let now = std::time::SystemTime::now()
329        .duration_since(std::time::UNIX_EPOCH)
330        .unwrap_or_default()
331        .as_secs() as i64;
332    let now_year = epoch_to_year(now);
333    let ts_year = epoch_to_year(ts);
334
335    let months = [
336        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
337    ];
338
339    // Approximate month and day from day-of-year
340    let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
341    let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
342    let feb = if is_leap_year(ts_year) { 29 } else { 28 };
343    let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
344    let mut m = 0;
345    let mut remaining = day_of_year;
346    for (i, &len) in month_lengths.iter().enumerate() {
347        if remaining < len {
348            m = i;
349            break;
350        }
351        remaining -= len;
352        m = i + 1;
353    }
354    let m = m.min(11);
355    let d = remaining + 1;
356
357    if ts_year == now_year {
358        format!("{} {:>2}", months[m], d)
359    } else {
360        format!("{} {}", months[m], ts_year)
361    }
362}
363
364/// Shell-escape a path with single quotes.
365fn shell_escape(path: &str) -> String {
366    crate::snippet::shell_escape(path)
367}
368
369/// Get the remote home directory via `pwd`.
370pub fn get_remote_home(
371    alias: &str,
372    config_path: &Path,
373    askpass: Option<&str>,
374    bw_session: Option<&str>,
375    has_active_tunnel: bool,
376) -> anyhow::Result<String> {
377    let result = crate::snippet::run_snippet(
378        alias,
379        config_path,
380        "pwd",
381        askpass,
382        bw_session,
383        true,
384        has_active_tunnel,
385    )?;
386    if result.status.success() {
387        Ok(result.stdout.trim().to_string())
388    } else {
389        let msg = filter_ssh_warnings(result.stderr.trim());
390        if msg.is_empty() {
391            anyhow::bail!("Failed to connect.")
392        } else {
393            anyhow::bail!("{}", msg)
394        }
395    }
396}
397
398/// Fetch remote directory listing synchronously (used by spawn_remote_listing).
399#[allow(clippy::too_many_arguments)]
400pub fn fetch_remote_listing(
401    alias: &str,
402    config_path: &Path,
403    remote_path: &str,
404    show_hidden: bool,
405    sort: BrowserSort,
406    askpass: Option<&str>,
407    bw_session: Option<&str>,
408    has_tunnel: bool,
409) -> Result<Vec<FileEntry>, String> {
410    let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
411    let result = crate::snippet::run_snippet(
412        alias,
413        config_path,
414        &command,
415        askpass,
416        bw_session,
417        true,
418        has_tunnel,
419    );
420    match result {
421        Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
422        Ok(r) => {
423            let msg = filter_ssh_warnings(r.stderr.trim());
424            if msg.is_empty() {
425                Err(format!(
426                    "ls exited with code {}.",
427                    r.status.code().unwrap_or(1)
428                ))
429            } else {
430                Err(msg)
431            }
432        }
433        Err(e) => Err(e.to_string()),
434    }
435}
436
437/// Spawn background thread for remote directory listing.
438/// Sends result back via the provided sender function.
439#[allow(clippy::too_many_arguments)]
440pub fn spawn_remote_listing<F>(
441    alias: String,
442    config_path: PathBuf,
443    remote_path: String,
444    show_hidden: bool,
445    sort: BrowserSort,
446    askpass: Option<String>,
447    bw_session: Option<String>,
448    has_tunnel: bool,
449    send: F,
450) where
451    F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
452{
453    std::thread::spawn(move || {
454        let listing = fetch_remote_listing(
455            &alias,
456            &config_path,
457            &remote_path,
458            show_hidden,
459            sort,
460            askpass.as_deref(),
461            bw_session.as_deref(),
462            has_tunnel,
463        );
464        send(alias, remote_path, listing);
465    });
466}
467
468/// Result of an scp transfer.
469pub struct ScpResult {
470    pub status: ExitStatus,
471    pub stderr_output: String,
472}
473
474/// Run scp in the background with captured stderr for error reporting.
475/// Stderr is piped and captured so errors can be extracted. Progress percentage
476/// is not available because scp only outputs progress to a TTY, not to a pipe.
477/// Stdin is null (askpass handles authentication). Stdout is null (scp has no
478/// meaningful stdout output).
479pub fn run_scp(
480    alias: &str,
481    config_path: &Path,
482    askpass: Option<&str>,
483    bw_session: Option<&str>,
484    has_active_tunnel: bool,
485    scp_args: &[String],
486) -> anyhow::Result<ScpResult> {
487    let mut cmd = Command::new("scp");
488    cmd.arg("-F").arg(config_path);
489
490    if has_active_tunnel {
491        cmd.arg("-o").arg("ClearAllForwardings=yes");
492    }
493
494    for arg in scp_args {
495        cmd.arg(arg);
496    }
497
498    cmd.stdin(Stdio::null())
499        .stdout(Stdio::null())
500        .stderr(Stdio::piped());
501
502    if askpass.is_some() {
503        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
504    }
505
506    if let Some(token) = bw_session {
507        cmd.env("BW_SESSION", token);
508    }
509
510    let output = cmd
511        .output()
512        .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
513
514    let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
515
516    Ok(ScpResult {
517        status: output.status,
518        stderr_output,
519    })
520}
521
522/// Filter SSH warning noise from stderr, keeping only actionable error lines.
523/// Strips lines like "** WARNING: connection is not using a post-quantum key exchange".
524pub fn filter_ssh_warnings(stderr: &str) -> String {
525    stderr
526        .lines()
527        .filter(|line| {
528            let trimmed = line.trim();
529            !trimmed.is_empty()
530                && !trimmed.starts_with("** ")
531                && !trimmed.starts_with("Warning:")
532                && !trimmed.contains("see https://")
533                && !trimmed.contains("See https://")
534                && !trimmed.starts_with("The server may need")
535                && !trimmed.starts_with("This session may be")
536        })
537        .collect::<Vec<_>>()
538        .join("\n")
539}
540
541/// Build scp arguments for a file transfer.
542/// Returns the args to pass after `scp -F <config>`.
543///
544/// Remote paths are NOT shell-escaped because scp is invoked via Command::arg()
545/// which bypasses the shell entirely. The colon in `alias:path` is the only
546/// special character scp interprets. Paths with spaces, globbing chars etc. are
547/// passed through literally by the OS exec layer.
548pub fn build_scp_args(
549    alias: &str,
550    source_pane: BrowserPane,
551    local_path: &Path,
552    remote_path: &str,
553    filenames: &[String],
554    has_dirs: bool,
555) -> Vec<String> {
556    let mut args = Vec::new();
557    if has_dirs {
558        args.push("-r".to_string());
559    }
560    args.push("--".to_string());
561
562    match source_pane {
563        // Upload: local files -> remote
564        BrowserPane::Local => {
565            for name in filenames {
566                args.push(local_path.join(name).to_string_lossy().to_string());
567            }
568            let dest = format!("{}:{}", alias, remote_path);
569            args.push(dest);
570        }
571        // Download: remote files -> local
572        BrowserPane::Remote => {
573            let base = remote_path.trim_end_matches('/');
574            for name in filenames {
575                let rpath = format!("{}/{}", base, name);
576                args.push(format!("{}:{}", alias, rpath));
577            }
578            args.push(local_path.to_string_lossy().to_string());
579        }
580    }
581    args
582}
583
584/// Format a file size in human-readable form.
585pub fn format_size(bytes: u64) -> String {
586    if bytes >= 1024 * 1024 * 1024 {
587        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
588    } else if bytes >= 1024 * 1024 {
589        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
590    } else if bytes >= 1024 {
591        format!("{:.1} KB", bytes as f64 / 1024.0)
592    } else {
593        format!("{} B", bytes)
594    }
595}
596
597#[cfg(test)]
598#[path = "file_browser_tests.rs"]
599mod tests;