use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use crate::ssh_context::{OwnedSshContext, SshContext};
use ratatui::widgets::ListState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserSort {
Name,
Date,
DateAsc,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileEntry {
pub name: String,
pub is_dir: bool,
pub size: Option<u64>,
pub modified: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserPane {
Local,
Remote,
}
pub struct CopyRequest {
pub sources: Vec<String>,
pub source_pane: BrowserPane,
pub has_dirs: bool,
}
pub struct FileBrowserState {
pub alias: String,
pub askpass: Option<String>,
pub active_pane: BrowserPane,
pub local_path: PathBuf,
pub local_entries: Vec<FileEntry>,
pub local_list_state: ListState,
pub local_selected: HashSet<String>,
pub local_error: Option<String>,
pub remote_path: String,
pub remote_entries: Vec<FileEntry>,
pub remote_list_state: ListState,
pub remote_selected: HashSet<String>,
pub remote_error: Option<String>,
pub remote_loading: bool,
pub show_hidden: bool,
pub sort: BrowserSort,
pub confirm_copy: Option<CopyRequest>,
pub transferring: Option<String>,
pub transfer_error: Option<String>,
pub connection_recorded: bool,
}
pub fn list_local(
path: &Path,
show_hidden: bool,
sort: BrowserSort,
) -> anyhow::Result<Vec<FileEntry>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
let metadata = entry.metadata()?;
let is_dir = metadata.is_dir();
let size = if is_dir { None } else { Some(metadata.len()) };
let modified = metadata.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
});
entries.push(FileEntry {
name,
is_dir,
size,
modified,
});
}
sort_entries(&mut entries, sort);
Ok(entries)
}
pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
match sort {
BrowserSort::Name => {
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
})
});
}
BrowserSort::Date => {
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| {
b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
})
});
}
BrowserSort::DateAsc => {
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| {
a.modified
.unwrap_or(i64::MAX)
.cmp(&b.modified.unwrap_or(i64::MAX))
})
});
}
}
}
pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
let mut entries = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("total ") {
continue;
}
let mut parts: Vec<&str> = Vec::with_capacity(9);
let mut rest = line;
for _ in 0..8 {
rest = rest.trim_start();
if rest.is_empty() {
break;
}
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
parts.push(&rest[..end]);
rest = &rest[end..];
}
rest = rest.trim_start();
if !rest.is_empty() {
parts.push(rest);
}
if parts.len() < 9 {
continue;
}
let permissions = parts[0];
let is_dir = permissions.starts_with('d');
let name = parts[8];
if name.is_empty() {
continue;
}
if !show_hidden && name.starts_with('.') {
continue;
}
let size = if is_dir {
None
} else {
Some(parse_human_size(parts[4]))
};
let modified = parse_ls_date(parts[5], parts[6], parts[7]);
entries.push(FileEntry {
name: name.to_string(),
is_dir,
size,
modified,
});
}
sort_entries(&mut entries, sort);
entries
}
fn parse_human_size(s: &str) -> u64 {
let s = s.trim();
if s.is_empty() {
return 0;
}
let last = s.as_bytes()[s.len() - 1];
let multiplier = match last {
b'K' => 1024,
b'M' => 1024 * 1024,
b'G' => 1024 * 1024 * 1024,
b'T' => 1024u64 * 1024 * 1024 * 1024,
_ => 1,
};
let num_str = if multiplier > 1 { &s[..s.len() - 1] } else { s };
let num: f64 = num_str.parse().unwrap_or(0.0);
(num * multiplier as f64) as u64
}
fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
let month = match month_str {
"Jan" => 0,
"Feb" => 1,
"Mar" => 2,
"Apr" => 3,
"May" => 4,
"Jun" => 5,
"Jul" => 6,
"Aug" => 7,
"Sep" => 8,
"Oct" => 9,
"Nov" => 10,
"Dec" => 11,
_ => return None,
};
let day: i64 = day_str.parse().ok()?;
if !(1..=31).contains(&day) {
return None;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let now_year = epoch_to_year(now);
if time_or_year.contains(':') {
let mut parts = time_or_year.splitn(2, ':');
let hour: i64 = parts.next()?.parse().ok()?;
let min: i64 = parts.next()?.parse().ok()?;
let mut year = now_year;
let approx = approximate_epoch(year, month, day, hour, min);
if approx > now + 86400 {
year -= 1;
}
Some(approximate_epoch(year, month, day, hour, min))
} else {
let year: i64 = time_or_year.parse().ok()?;
if !(1970..=2100).contains(&year) {
return None;
}
Some(approximate_epoch(year, month, day, 0, 0))
}
}
fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
let y = year - 1970;
let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
days += month_days[month as usize];
if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
days += 1;
}
days += day - 1;
days * 86400 + hour * 3600 + min * 60
}
fn epoch_to_year(ts: i64) -> i64 {
let mut y = 1970 + ts / 31_557_600;
if approximate_epoch(y, 0, 1, 0, 0) > ts {
y -= 1;
} else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
y += 1;
}
y
}
fn is_leap_year(year: i64) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
pub fn format_relative_time(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let diff = now - ts;
if diff < 0 {
return format_short_date(ts);
}
if diff < 60 {
return "just now".to_string();
}
if diff < 3600 {
return format!("{}m ago", diff / 60);
}
if diff < 86400 {
return format!("{}h ago", diff / 3600);
}
if diff < 86400 * 30 {
return format!("{}d ago", diff / 86400);
}
format_short_date(ts)
}
fn format_short_date(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let now_year = epoch_to_year(now);
let ts_year = epoch_to_year(ts);
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
let feb = if is_leap_year(ts_year) { 29 } else { 28 };
let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut m = 0;
let mut remaining = day_of_year;
for (i, &len) in month_lengths.iter().enumerate() {
if remaining < len {
m = i;
break;
}
remaining -= len;
m = i + 1;
}
let m = m.min(11);
let d = remaining + 1;
if ts_year == now_year {
format!("{} {:>2}", months[m], d)
} else {
format!("{} {}", months[m], ts_year)
}
}
fn shell_escape(path: &str) -> String {
crate::snippet::shell_escape(path)
}
pub fn get_remote_home(
alias: &str,
config_path: &Path,
askpass: Option<&str>,
bw_session: Option<&str>,
has_active_tunnel: bool,
) -> anyhow::Result<String> {
let result = crate::snippet::run_snippet(
alias,
config_path,
"pwd",
askpass,
bw_session,
true,
has_active_tunnel,
)?;
if result.status.success() {
Ok(result.stdout.trim().to_string())
} else {
let msg = filter_ssh_warnings(result.stderr.trim());
if msg.is_empty() {
anyhow::bail!("Failed to connect.")
} else {
anyhow::bail!("{}", msg)
}
}
}
pub fn fetch_remote_listing(
ctx: &SshContext<'_>,
remote_path: &str,
show_hidden: bool,
sort: BrowserSort,
) -> Result<Vec<FileEntry>, String> {
let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
let result = crate::snippet::run_snippet(
ctx.alias,
ctx.config_path,
&command,
ctx.askpass,
ctx.bw_session,
true,
ctx.has_tunnel,
);
match result {
Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
Ok(r) => {
let msg = filter_ssh_warnings(r.stderr.trim());
if msg.is_empty() {
Err(format!(
"ls exited with code {}.",
r.status.code().unwrap_or(1)
))
} else {
Err(msg)
}
}
Err(e) => Err(e.to_string()),
}
}
pub fn spawn_remote_listing<F>(
ctx: OwnedSshContext,
remote_path: String,
show_hidden: bool,
sort: BrowserSort,
send: F,
) where
F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
{
std::thread::spawn(move || {
let borrowed = SshContext {
alias: &ctx.alias,
config_path: &ctx.config_path,
askpass: ctx.askpass.as_deref(),
bw_session: ctx.bw_session.as_deref(),
has_tunnel: ctx.has_tunnel,
};
let listing = fetch_remote_listing(&borrowed, &remote_path, show_hidden, sort);
send(ctx.alias, remote_path, listing);
});
}
pub struct ScpResult {
pub status: ExitStatus,
pub stderr_output: String,
}
pub fn run_scp(
alias: &str,
config_path: &Path,
askpass: Option<&str>,
bw_session: Option<&str>,
has_active_tunnel: bool,
scp_args: &[String],
) -> anyhow::Result<ScpResult> {
let mut cmd = Command::new("scp");
cmd.arg("-F").arg(config_path);
if has_active_tunnel {
cmd.arg("-o").arg("ClearAllForwardings=yes");
}
for arg in scp_args {
cmd.arg(arg);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
if askpass.is_some() {
crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
}
if let Some(token) = bw_session {
cmd.env("BW_SESSION", token);
}
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ScpResult {
status: output.status,
stderr_output,
})
}
pub fn filter_ssh_warnings(stderr: &str) -> String {
stderr
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with("** ")
&& !trimmed.starts_with("Warning:")
&& !trimmed.contains("see https://")
&& !trimmed.contains("See https://")
&& !trimmed.starts_with("The server may need")
&& !trimmed.starts_with("This session may be")
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn build_scp_args(
alias: &str,
source_pane: BrowserPane,
local_path: &Path,
remote_path: &str,
filenames: &[String],
has_dirs: bool,
) -> Vec<String> {
let mut args = Vec::new();
if has_dirs {
args.push("-r".to_string());
}
args.push("--".to_string());
match source_pane {
BrowserPane::Local => {
for name in filenames {
args.push(local_path.join(name).to_string_lossy().to_string());
}
let dest = format!("{}:{}", alias, remote_path);
args.push(dest);
}
BrowserPane::Remote => {
let base = remote_path.trim_end_matches('/');
for name in filenames {
let rpath = format!("{}/{}", base, name);
args.push(format!("{}:{}", alias, rpath));
}
args.push(local_path.to_string_lossy().to_string());
}
}
args
}
pub fn format_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 * 1024 {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
} else if bytes >= 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
#[cfg(test)]
#[path = "file_browser_tests.rs"]
mod tests;