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