1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10 Name,
11 Date,
12 DateAsc,
13}
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18 pub name: String,
19 pub is_dir: bool,
20 pub size: Option<u64>,
21 pub modified: Option<i64>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28 Local,
29 Remote,
30}
31
32pub struct CopyRequest {
34 pub sources: Vec<String>,
35 pub source_pane: BrowserPane,
36 pub has_dirs: bool,
37}
38
39pub struct FileBrowserState {
41 pub alias: String,
42 pub askpass: Option<String>,
43 pub active_pane: BrowserPane,
44 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 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 pub show_hidden: bool,
59 pub sort: BrowserSort,
60 pub confirm_copy: Option<CopyRequest>,
62 pub transferring: Option<String>,
64 pub transfer_error: Option<String>,
66 pub connection_recorded: bool,
68}
69
70pub 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
103pub 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 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 a.modified
128 .unwrap_or(i64::MAX)
129 .cmp(&b.modified.unwrap_or(i64::MAX))
130 })
131 });
132 }
133 }
134}
135
136pub 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 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 if name.is_empty() {
172 continue;
173 }
174 if !show_hidden && name.starts_with('.') {
175 continue;
176 }
177 let size = if is_dir {
179 None
180 } else {
181 Some(parse_human_size(parts[4]))
182 };
183 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
196fn 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
215fn 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 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 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 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
268fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270 let y = year - 1970;
272 let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275 days += month_days[month as usize];
276 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
284fn 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
299pub 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 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
326fn 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 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
364fn shell_escape(path: &str) -> String {
366 crate::snippet::shell_escape(path)
367}
368
369pub 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#[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#[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
468pub struct ScpResult {
470 pub status: ExitStatus,
471 pub stderr_output: String,
472}
473
474pub 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
522pub 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
541pub 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 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 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
584pub 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;