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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BrowserSort {
12 Name,
13 Date,
14 DateAsc,
15}
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct FileEntry {
20 pub name: String,
21 pub is_dir: bool,
22 pub size: Option<u64>,
23 pub modified: Option<i64>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BrowserPane {
30 Local,
31 Remote,
32}
33
34pub struct CopyRequest {
36 pub sources: Vec<String>,
37 pub source_pane: BrowserPane,
38 pub has_dirs: bool,
39}
40
41pub struct FileBrowserSession {
43 pub alias: String,
44 pub askpass: Option<String>,
45 pub active_pane: BrowserPane,
46 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 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 pub show_hidden: bool,
61 pub sort: BrowserSort,
62 pub confirm_copy: Option<CopyRequest>,
64 pub transferring: Option<String>,
66 pub transfer_error: Option<String>,
68 pub connection_recorded: bool,
70}
71
72pub 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
105pub 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 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 a.modified
130 .unwrap_or(i64::MAX)
131 .cmp(&b.modified.unwrap_or(i64::MAX))
132 })
133 });
134 }
135 }
136}
137
138pub 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 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 if name.is_empty() {
174 continue;
175 }
176 if !show_hidden && name.starts_with('.') {
177 continue;
178 }
179 let size = if is_dir {
181 None
182 } else {
183 Some(parse_human_size(parts[4]))
184 };
185 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
198fn 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
217fn 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 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 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 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
270fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
272 let y = year - 1970;
274 let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
277 days += month_days[month as usize];
278 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
286fn 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
301pub 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 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
328fn 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 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
366fn shell_escape(path: &str) -> String {
368 crate::snippet::shell_escape(path)
369}
370
371pub 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
386pub 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
429pub 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
446pub 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
476pub struct ScpResult {
478 pub status: ExitStatus,
479 pub stderr_output: String,
480}
481
482pub(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 crate::security_key::configure(&mut cmd, ctx.security_key);
525
526 #[cfg(unix)]
529 unsafe {
534 use std::os::unix::process::CommandExt;
535 cmd.pre_exec(|| {
536 libc::setpgid(0, 0);
537 Ok(())
538 });
539 }
540
541 cmd
542}
543
544pub fn run_scp(ctx: &SshContext<'_>, scp_args: &[String]) -> anyhow::Result<ScpResult> {
550 let alias = ctx.alias;
551 crate::runtime::helpers::ensure_vault_cert_for_alias(ctx.env, alias, ctx.config_path);
554
555 let mut cmd = build_scp_command(ctx, scp_args);
556
557 let mut child = cmd.spawn().map_err(|e| {
558 log::error!("[external] scp spawn failed: alias={alias}: {e}");
559 anyhow::anyhow!("Failed to run scp: {}", e)
560 })?;
561 let mut stderr_bytes = Vec::new();
563 if let Some(pipe) = child.stderr.take() {
564 let mut scan =
565 crate::security_key::RefusalScan::new(pipe, ctx.env.paths(), ctx.security_key);
566 let _ = std::io::Read::read_to_end(&mut scan, &mut stderr_bytes);
567 }
568 let status = child.wait().map_err(|e| {
569 log::error!("[external] scp wait failed: alias={alias}: {e}");
570 anyhow::anyhow!("Failed to run scp: {}", e)
571 })?;
572
573 let stderr_output = String::from_utf8_lossy(&stderr_bytes).to_string();
574
575 if !status.success() {
576 let scrubbed = filter_ssh_warnings(stderr_output.trim());
577 log::warn!(
578 "[external] scp transfer failed: alias={} exit={} stderr={}",
579 alias,
580 status.code().unwrap_or(-1),
581 if scrubbed.is_empty() {
582 "<empty>"
583 } else {
584 scrubbed.as_str()
585 },
586 );
587 }
588
589 Ok(ScpResult {
590 status,
591 stderr_output,
592 })
593}
594
595pub fn filter_ssh_warnings(stderr: &str) -> String {
598 stderr
599 .lines()
600 .filter(|line| {
601 let trimmed = line.trim();
602 !trimmed.is_empty()
603 && !trimmed.starts_with("** ")
604 && !trimmed.starts_with("Warning:")
605 && !trimmed.contains("see https://")
606 && !trimmed.contains("See https://")
607 && !trimmed.starts_with("The server may need")
608 && !trimmed.starts_with("This session may be")
609 })
610 .collect::<Vec<_>>()
611 .join("\n")
612}
613
614pub fn build_scp_args(
622 alias: &str,
623 source_pane: BrowserPane,
624 local_path: &Path,
625 remote_path: &str,
626 filenames: &[String],
627 has_dirs: bool,
628) -> Vec<String> {
629 let mut args = Vec::new();
630 if has_dirs {
631 args.push("-r".to_string());
632 }
633 args.push("--".to_string());
634
635 match source_pane {
636 BrowserPane::Local => {
638 for name in filenames {
639 args.push(local_path.join(name).to_string_lossy().to_string());
640 }
641 let dest = format!("{}:{}", alias, remote_path);
642 args.push(dest);
643 }
644 BrowserPane::Remote => {
646 let base = remote_path.trim_end_matches('/');
647 for name in filenames {
648 let rpath = format!("{}/{}", base, name);
649 args.push(format!("{}:{}", alias, rpath));
650 }
651 args.push(local_path.to_string_lossy().to_string());
652 }
653 }
654 args
655}
656
657pub fn format_size(bytes: u64) -> String {
659 if bytes >= 1024 * 1024 * 1024 {
660 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
661 } else if bytes >= 1024 * 1024 {
662 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
663 } else if bytes >= 1024 {
664 format!("{:.1} KB", bytes as f64 / 1024.0)
665 } else {
666 format!("{} B", bytes)
667 }
668}
669
670#[cfg(test)]
671#[path = "file_browser_tests.rs"]
672mod tests;