use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Clear, Gauge, List, ListItem, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState,
},
Frame,
};
use crate::app::{
AppAction, FileManagerPopup, FileManagerView, FilePanelView, FmPanel, FormField, ViewState,
};
use crate::ssh::client::Host;
pub fn render(frame: &mut Frame, area: Rect, state: &crate::app::AppState, view: &ViewState) {
let fm = &view.file_manager;
let (panel_constraint, preview_constraint) = if area.height >= 30 {
(Constraint::Percentage(65), Constraint::Percentage(35))
} else {
(Constraint::Min(3), Constraint::Length(0))
};
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), panel_constraint, preview_constraint])
.split(area);
let hints_area = vert[0];
let panels_area = vert[1];
let preview_area = vert[2];
let horiz = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(panels_area);
let local_area = horiz[0];
let remote_area = horiz[1];
render_hints_header(frame, hints_area, &view.theme);
let local_cwd = if fm.local.cwd.is_empty() {
"~".to_string()
} else {
fm.local.cwd.clone()
};
let remote_title = match &fm.connected_host {
Some(h) => format!("REMOTE {} — {}", h, fm.remote.cwd),
None => "REMOTE (not connected)".to_string(),
};
let local_active = fm.active_panel == FmPanel::Local;
render_panel(
frame,
local_area,
&fm.local,
&format!("LOCAL {local_cwd}"),
local_active,
&view.theme,
);
render_panel(
frame,
remote_area,
&fm.remote,
&remote_title,
!local_active,
&view.theme,
);
if preview_area.height > 0 {
render_preview_zone(frame, preview_area, fm, &view.theme);
}
if let Some(popup) = &fm.popup {
render_fm_popup(frame, area, popup, state, view);
}
}
#[derive(Clone, Copy)]
enum PopupKind {
HostPicker,
DeleteConfirm,
MkDir,
Rename,
TransferProgress,
}
fn popup_kind(view: &ViewState) -> Option<PopupKind> {
match &view.file_manager.popup {
Some(FileManagerPopup::HostPicker { .. }) => Some(PopupKind::HostPicker),
Some(FileManagerPopup::DeleteConfirm { .. }) => Some(PopupKind::DeleteConfirm),
Some(FileManagerPopup::MkDir(_)) => Some(PopupKind::MkDir),
Some(FileManagerPopup::Rename { .. }) => Some(PopupKind::Rename),
Some(FileManagerPopup::TransferProgress { .. }) => Some(PopupKind::TransferProgress),
None => None,
}
}
pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option<AppAction> {
if let Some(kind) = popup_kind(view) {
return handle_popup_input(key, kind, view);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(AppAction::FmNavDown),
KeyCode::Char('k') | KeyCode::Up => Some(AppAction::FmNavUp),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => Some(AppAction::FmEnterDir),
KeyCode::Char('h') | KeyCode::Left => Some(AppAction::FmNavUp),
KeyCode::Backspace => Some(AppAction::FmParentDir),
KeyCode::Char(' ') => Some(AppAction::FmMarkFile),
KeyCode::Tab => Some(AppAction::FmSwitchPanel),
KeyCode::Char('c') => Some(AppAction::FmCopy),
KeyCode::Char('p') => Some(AppAction::FmPaste),
KeyCode::Char('D') => Some(AppAction::FmOpenDeleteConfirm),
KeyCode::Char('n') => Some(AppAction::FmOpenMkDir),
KeyCode::Char('R') => Some(AppAction::FmOpenRename),
KeyCode::Char('H') => Some(AppAction::FmOpenHostPicker),
KeyCode::Esc => Some(AppAction::FmClosePopup),
_ => None,
}
}
fn render_hints_header(frame: &mut Frame, area: Rect, theme: &crate::ui::theme::Theme) {
let hints = Line::from(vec![
Span::styled(
" hjkl",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Navigate", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"Tab",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Switch", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"Space",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Mark", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"c",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Copy", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"p",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Paste", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"n",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":MkDir", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"Shift+R",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Rename", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"Shift+D",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Delete", Style::default().fg(theme.text_muted)),
Span::raw(" "),
Span::styled(
"Shift+H",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Host", Style::default().fg(theme.text_muted)),
]);
frame.render_widget(Paragraph::new(hints), area);
}
fn handle_popup_input(key: KeyEvent, kind: PopupKind, view: &mut ViewState) -> Option<AppAction> {
match kind {
PopupKind::HostPicker => handle_host_picker_input(key, view),
PopupKind::DeleteConfirm => handle_delete_confirm_input(key),
PopupKind::MkDir => handle_text_input_popup(key, view, TextPopupKind::MkDir),
PopupKind::Rename => handle_text_input_popup(key, view, TextPopupKind::Rename),
PopupKind::TransferProgress => {
if key.code == KeyCode::Esc {
Some(AppAction::FmClosePopup)
} else {
None
}
}
}
}
fn handle_host_picker_input(key: KeyEvent, view: &mut ViewState) -> Option<AppAction> {
let cursor = match &view.file_manager.popup {
Some(FileManagerPopup::HostPicker { cursor }) => *cursor,
_ => return None,
};
match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(AppAction::FmHostPickerNav(1)),
KeyCode::Char('k') | KeyCode::Up => Some(AppAction::FmHostPickerNav(-1)),
KeyCode::Enter => Some(AppAction::FmHostPickerSelect(cursor)),
KeyCode::Esc => Some(AppAction::FmClosePopup),
_ => None,
}
}
fn handle_delete_confirm_input(key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => Some(AppAction::FmConfirmDelete),
KeyCode::Char('n') | KeyCode::Esc => Some(AppAction::FmClosePopup),
_ => None,
}
}
#[derive(Clone, Copy)]
enum TextPopupKind {
MkDir,
Rename,
}
fn handle_text_input_popup(
key: KeyEvent,
view: &mut ViewState,
kind: TextPopupKind,
) -> Option<AppAction> {
let field = match &mut view.file_manager.popup {
Some(FileManagerPopup::MkDir(f)) => f,
Some(FileManagerPopup::Rename { field, .. }) => field,
_ => return None,
};
match key.code {
KeyCode::Char(c) => {
field.insert_char(c);
None
}
KeyCode::Backspace => {
field.backspace();
None
}
KeyCode::Enter => {
let value = field.value.trim().to_string();
view.file_manager.popup = None;
if value.is_empty() {
None
} else {
match kind {
TextPopupKind::MkDir => Some(AppAction::FmConfirmMkDir(value)),
TextPopupKind::Rename => Some(AppAction::FmConfirmRename(value)),
}
}
}
KeyCode::Esc => {
view.file_manager.popup = None;
None
}
_ => None,
}
}
fn render_panel(
frame: &mut Frame,
area: Rect,
panel: &FilePanelView,
title: &str,
is_active: bool,
theme: &crate::ui::theme::Theme,
) {
let border_color = if is_active {
Color::Cyan
} else {
Color::DarkGray
};
let block = Block::default()
.title(format!(" {} ", title))
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let visible_rows = inner.height as usize;
let mut scroll = panel.scroll.get();
let cursor = panel.cursor;
if cursor < scroll {
scroll = cursor;
} else if cursor >= scroll + visible_rows {
scroll = cursor.saturating_sub(visible_rows - 1);
}
panel.scroll.set(scroll);
let items: Vec<ListItem> = panel
.entries
.iter()
.enumerate()
.skip(scroll)
.take(visible_rows)
.map(|(original_idx, entry)| {
let is_cursor = original_idx == cursor;
let is_marked = panel.marked.contains(&entry.path);
let icon = if entry.is_dir { "[DIR]" } else { "[ ]" };
let icon_color = if entry.is_dir {
Color::Cyan
} else {
Color::Gray
};
let cursor_prefix = if is_cursor { "▶ " } else { " " };
let mark_str = if is_marked { "●" } else { " " };
let size_str = if entry.is_dir || entry.name == ".." {
String::new()
} else {
format_size(entry.size)
};
let name_width = inner
.width
.saturating_sub(2 + 2 + 5 + 1 + size_str.len() as u16 + 1)
as usize;
let name_display: String = if entry.name.chars().count() > name_width {
let truncated = entry
.name
.char_indices()
.nth(name_width.saturating_sub(1))
.map(|(i, _)| &entry.name[..i])
.unwrap_or(&entry.name);
format!("{}…", truncated)
} else {
format!("{:<width$}", entry.name, width = name_width)
};
let mut spans = vec![
Span::styled(
cursor_prefix,
if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
Span::styled(mark_str, Style::default().fg(theme.text_warning)),
Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
Span::styled(
name_display,
if is_marked {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if entry.is_dir {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.text_secondary)
},
),
];
if !size_str.is_empty() {
spans.push(Span::styled(
format!(" {}", size_str),
Style::default().fg(theme.text_muted),
));
}
let item = ListItem::new(Line::from(spans));
if is_cursor {
item.style(Style::default().bg(theme.selected_bg))
} else {
item
}
})
.collect();
let list = List::new(items);
frame.render_widget(list, inner);
if panel.entries.len() > visible_rows {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let mut scrollbar_state = ScrollbarState::new(panel.entries.len()).position(scroll);
let scrollbar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: area.height.saturating_sub(2),
};
frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
}
if panel.entries.is_empty() {
let hint = if panel.cwd.is_empty() {
"(loading…)"
} else {
"(empty)"
};
frame.render_widget(
Paragraph::new(Span::styled(
hint,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)),
inner,
);
}
}
fn render_preview_zone(
frame: &mut Frame,
area: Rect,
fm: &FileManagerView,
theme: &crate::ui::theme::Theme,
) {
if let Some(FileManagerPopup::TransferProgress {
filename,
done,
total,
..
}) = &fm.popup
{
render_transfer_progress(frame, area, filename, *done, *total, theme);
return;
}
let block = Block::default()
.title(" Preview ")
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.text_muted));
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(content) = &fm.preview_content {
let path_hint = fm
.preview_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string())
})
.unwrap_or_default();
let max_line_width = inner.width.saturating_sub(3) as usize; let sanitized_lines = sanitize_preview_content(
content,
max_line_width,
inner.height.saturating_sub(1) as usize,
);
let lines: Vec<Line> = std::iter::once(Line::from(Span::styled(
format!(" {} ─────", path_hint),
Style::default().fg(theme.accent),
)))
.chain(sanitized_lines.into_iter().map(|l| {
Line::from(Span::styled(
format!(" {}", l),
Style::default().fg(theme.text_secondary),
))
}))
.collect();
frame.render_widget(Paragraph::new(lines), inner);
} else {
frame.render_widget(
Paragraph::new(Span::styled(
" (no preview — select a text file)",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)),
inner,
);
}
}
fn render_transfer_progress(
frame: &mut Frame,
area: Rect,
filename: &str,
done: u64,
total: u64,
theme: &crate::ui::theme::Theme,
) {
let block = Block::default()
.title(" Transfer Progress ")
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.text_success));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 2 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {}", filename),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
rows[0],
);
let percent = if total > 0 {
((done as f64 / total as f64) * 100.0) as u16
} else {
0
};
let label = if total > 0 {
format!(
" {} / {} ({}%)",
format_size(done),
format_size(total),
percent
)
} else {
format!(" {} transferred…", format_size(done))
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(theme.text_success).bg(Color::DarkGray))
.percent(percent)
.label(label);
frame.render_widget(gauge, rows[1]);
}
fn render_fm_popup(
frame: &mut Frame,
area: Rect,
popup: &FileManagerPopup,
state: &crate::app::AppState,
view: &ViewState,
) {
let theme = &view.theme;
match popup {
FileManagerPopup::HostPicker { cursor } => {
render_fm_host_picker(frame, area, &state.hosts, *cursor, theme);
}
FileManagerPopup::DeleteConfirm { paths } => {
render_fm_delete_confirm(frame, area, paths, theme);
}
FileManagerPopup::MkDir(field) => {
render_fm_text_input(frame, area, " New Directory ", field, theme);
}
FileManagerPopup::Rename {
field,
original_name,
} => {
let title = format!(" Rename '{}' ", original_name);
render_fm_text_input(frame, area, &title, field, theme);
}
FileManagerPopup::TransferProgress { .. } => {
}
}
}
fn render_fm_host_picker(
frame: &mut Frame,
area: Rect,
hosts: &[Host],
cursor: usize,
theme: &crate::ui::theme::Theme,
) {
let popup_area = crate::ui::popup::centred_rect(60, 70, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Connect Remote Panel — Select Host ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if hosts.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(
" No hosts configured. Add a host from the Dashboard (1).",
Style::default().fg(theme.text_muted),
)),
inner,
);
return;
}
let list_height = inner.height.saturating_sub(1) as usize;
let list_area = Rect {
height: inner.height.saturating_sub(1),
..inner
};
let hint_area = Rect {
y: inner.y + inner.height.saturating_sub(1),
height: 1,
..inner
};
let offset = if cursor >= list_height {
cursor - list_height + 1
} else {
0
};
let items: Vec<ListItem> = hosts
.iter()
.enumerate()
.skip(offset)
.take(list_height)
.map(|(i, h)| {
let is_cursor = i == cursor;
let name_span = Span::styled(
format!(" {} ", h.name),
if is_cursor {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_primary)
},
);
let host_span = Span::styled(
format!("{}@{}", h.user, h.hostname),
Style::default().fg(theme.text_muted),
);
ListItem::new(Line::from(vec![name_span, host_span]))
})
.collect();
frame.render_widget(List::new(items), list_area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" j/k",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(":navigate ", Style::default().fg(theme.text_muted)),
Span::styled(
"Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(":connect ", Style::default().fg(theme.text_muted)),
Span::styled(
"Esc",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(":cancel", Style::default().fg(theme.text_muted)),
])),
hint_area,
);
}
fn render_fm_text_input(
frame: &mut Frame,
area: Rect,
title: &str,
field: &FormField,
theme: &crate::ui::theme::Theme,
) {
let popup_area = crate::ui::popup::centred_rect(60, 20, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(title)
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.text_success));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.height < 2 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
let display = format!(" {}|{} ", before, after);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
display,
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
))),
rows[0],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(":confirm ", Style::default().fg(theme.text_muted)),
Span::styled(
"Esc",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(":cancel", Style::default().fg(theme.text_muted)),
])),
rows[1],
);
}
fn render_fm_delete_confirm(
frame: &mut Frame,
area: Rect,
paths: &[String],
theme: &crate::ui::theme::Theme,
) {
let popup_area = crate::ui::popup::centred_rect(60, 35, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Confirm Delete ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.text_error));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.height < 2 {
return;
}
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
" Delete the following items?",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
let max_show = inner.height.saturating_sub(4) as usize;
for path in paths.iter().take(max_show) {
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
lines.push(Line::from(Span::styled(
format!(" • {}", name),
Style::default().fg(theme.text_warning),
)));
}
if paths.len() > max_show {
lines.push(Line::from(Span::styled(
format!(" … and {} more", paths.len() - max_show),
Style::default().fg(theme.text_muted),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" This cannot be undone.",
Style::default().fg(theme.text_muted),
)));
let hint_row = Rect {
y: inner.y + inner.height.saturating_sub(1),
height: 1,
..inner
};
let content_area = Rect {
height: inner.height.saturating_sub(1),
..inner
};
frame.render_widget(Paragraph::new(lines), content_area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
"y",
Style::default()
.fg(theme.text_error)
.add_modifier(Modifier::BOLD),
),
Span::styled(":Yes — delete ", Style::default().fg(theme.text_muted)),
Span::styled(
"n / Esc",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(":No", Style::default().fg(theme.text_muted)),
])),
hint_row,
);
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1}G", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}M", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}K", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
fn sanitize_preview_content(content: &str, max_width: usize, max_lines: usize) -> Vec<String> {
content
.lines()
.take(max_lines)
.map(|line| {
let sanitized: String = line
.chars()
.map(|c| {
if c == '\t' {
" "
} else if c.is_control() {
"�"
} else if c.is_ascii_graphic() || c == ' ' {
return c.to_string();
} else {
"�"
}
.to_string()
})
.collect();
if sanitized.chars().count() > max_width {
let truncated: String = sanitized
.chars()
.take(max_width.saturating_sub(1))
.collect();
format!("{}…", truncated)
} else {
sanitized
}
})
.collect()
}