Add file operations and UI components for file management application
This commit is contained in:
413
src/app.rs
Normal file
413
src/app.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::file_ops::{self, FileEntry};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Focus {
|
||||
Sidebar,
|
||||
FileView,
|
||||
PathInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ClipboardOp {
|
||||
Copy,
|
||||
Cut,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClipboardEntry {
|
||||
pub paths: Vec<PathBuf>,
|
||||
pub op: ClipboardOp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ContextMenuItem {
|
||||
Copy,
|
||||
Cut,
|
||||
Paste,
|
||||
Rename,
|
||||
Properties,
|
||||
NewFile,
|
||||
NewDir,
|
||||
Delete,
|
||||
AddFavorite,
|
||||
RemoveFavorite(String),
|
||||
Separator,
|
||||
NewSubmenu,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextMenu {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub items: Vec<ContextMenuItem>,
|
||||
pub selected: usize,
|
||||
pub show_new_submenu: bool,
|
||||
pub sub_selected: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DialogMode {
|
||||
None,
|
||||
Rename(String),
|
||||
NewFile(String),
|
||||
NewDir(String),
|
||||
Properties(PropertiesInfo),
|
||||
Error(String),
|
||||
Confirm(String, ConfirmAction),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConfirmAction {
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PropertiesInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub file_count: u64,
|
||||
pub permissions: String,
|
||||
pub owner_user: String,
|
||||
pub owner_group: String,
|
||||
pub is_multi: bool,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub current_path: PathBuf,
|
||||
pub entries: Vec<FileEntry>,
|
||||
pub selected_indices: Vec<usize>,
|
||||
pub scroll_offset: usize,
|
||||
pub sidebar_scroll: usize,
|
||||
pub focus: Focus,
|
||||
pub config: Config,
|
||||
pub clipboard: Option<ClipboardEntry>,
|
||||
pub context_menu: Option<ContextMenu>,
|
||||
pub dialog: DialogMode,
|
||||
pub path_input: String,
|
||||
pub path_input_cursor: usize,
|
||||
pub show_hidden: bool,
|
||||
pub status_message: Option<String>,
|
||||
pub last_click_entry: Option<(usize, std::time::Instant)>,
|
||||
pub sidebar_selected: Option<usize>,
|
||||
pub rename_original: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let config = Config::load();
|
||||
let start_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let entries = file_ops::read_dir(&start_path);
|
||||
let path_input = start_path.to_string_lossy().to_string();
|
||||
|
||||
Self {
|
||||
current_path: start_path,
|
||||
entries,
|
||||
selected_indices: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
sidebar_scroll: 0,
|
||||
focus: Focus::FileView,
|
||||
config,
|
||||
clipboard: None,
|
||||
context_menu: None,
|
||||
dialog: DialogMode::None,
|
||||
path_input,
|
||||
path_input_cursor: 0,
|
||||
show_hidden: false,
|
||||
status_message: None,
|
||||
last_click_entry: None,
|
||||
sidebar_selected: None,
|
||||
rename_original: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate_to(&mut self, path: PathBuf) {
|
||||
if path.is_dir() {
|
||||
self.current_path = path.clone();
|
||||
self.entries = file_ops::read_dir(&path);
|
||||
if !self.show_hidden {
|
||||
self.entries.retain(|e| !e.is_hidden);
|
||||
}
|
||||
self.selected_indices.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.path_input = path.to_string_lossy().to_string();
|
||||
self.path_input_cursor = self.path_input.len();
|
||||
self.context_menu = None;
|
||||
} else {
|
||||
self.set_status(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
self.entries = file_ops::read_dir(&self.current_path.clone());
|
||||
if !self.show_hidden {
|
||||
self.entries.retain(|e| !e.is_hidden);
|
||||
}
|
||||
// Keep valid selections
|
||||
let count = self.entries.len();
|
||||
self.selected_indices.retain(|&i| i < count);
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, msg: String) {
|
||||
self.status_message = Some(msg);
|
||||
}
|
||||
|
||||
pub fn navigate_up(&mut self) {
|
||||
if let Some(parent) = self.current_path.parent() {
|
||||
let p = parent.to_path_buf();
|
||||
self.navigate_to(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_selection(&mut self, index: usize) {
|
||||
if self.selected_indices.contains(&index) {
|
||||
self.selected_indices.retain(|&i| i != index);
|
||||
} else {
|
||||
self.selected_indices.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_only(&mut self, index: usize) {
|
||||
self.selected_indices = vec![index];
|
||||
}
|
||||
|
||||
pub fn select_all(&mut self) {
|
||||
self.selected_indices = (0..self.entries.len()).collect();
|
||||
}
|
||||
|
||||
pub fn copy_selected(&mut self) {
|
||||
let paths: Vec<PathBuf> = self
|
||||
.selected_indices
|
||||
.iter()
|
||||
.filter_map(|&i| self.entries.get(i))
|
||||
.map(|e| e.path.clone())
|
||||
.collect();
|
||||
if !paths.is_empty() {
|
||||
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Copy });
|
||||
self.set_status("Copied to clipboard".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cut_selected(&mut self) {
|
||||
let paths: Vec<PathBuf> = self
|
||||
.selected_indices
|
||||
.iter()
|
||||
.filter_map(|&i| self.entries.get(i))
|
||||
.map(|e| e.path.clone())
|
||||
.collect();
|
||||
if !paths.is_empty() {
|
||||
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Cut });
|
||||
self.set_status("Cut to clipboard".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste into given destination directory
|
||||
pub fn paste_into(&mut self, dst: &std::path::Path) {
|
||||
let Some(clipboard) = self.clipboard.clone() else {
|
||||
self.set_status("Nothing in clipboard".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for src in &clipboard.paths {
|
||||
let result = match clipboard.op {
|
||||
ClipboardOp::Copy => file_ops::copy_entry(src, dst),
|
||||
ClipboardOp::Cut => file_ops::move_entry(src, dst),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
if clipboard.op == ClipboardOp::Cut {
|
||||
self.clipboard = None;
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
self.set_status("Paste successful".to_string());
|
||||
} else {
|
||||
self.set_status(format!("Errors: {}", errors.join(", ")));
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
pub fn delete_selected(&mut self) {
|
||||
let paths: Vec<PathBuf> = self
|
||||
.selected_indices
|
||||
.iter()
|
||||
.filter_map(|&i| self.entries.get(i))
|
||||
.map(|e| e.path.clone())
|
||||
.collect();
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for path in paths {
|
||||
if let Err(e) = file_ops::delete_entry(&path) {
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
self.selected_indices.clear();
|
||||
if errors.is_empty() {
|
||||
self.set_status("Deleted".to_string());
|
||||
} else {
|
||||
self.set_status(format!("Delete errors: {}", errors.join(", ")));
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
pub fn rename_entry(&mut self, entry_path: &std::path::Path, new_name: &str) {
|
||||
let new_name = new_name.trim();
|
||||
if new_name.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Prevent path traversal
|
||||
if new_name.contains('/') || new_name.contains('\\') || new_name == ".." || new_name == "." {
|
||||
self.set_status("Invalid name".to_string());
|
||||
return;
|
||||
}
|
||||
let new_path = entry_path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join(new_name);
|
||||
if let Err(e) = std::fs::rename(entry_path, &new_path) {
|
||||
self.set_status(format!("Rename error: {}", e));
|
||||
} else {
|
||||
self.set_status(format!("Renamed to {}", new_name));
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
pub fn create_file(&mut self, name: &str) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { return; }
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
self.set_status("Invalid filename".to_string());
|
||||
return;
|
||||
}
|
||||
let path = self.current_path.join(name);
|
||||
if let Err(e) = std::fs::File::create(&path) {
|
||||
self.set_status(format!("Error: {}", e));
|
||||
} else {
|
||||
self.set_status(format!("Created file {}", name));
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
pub fn create_dir(&mut self, name: &str) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { return; }
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
self.set_status("Invalid directory name".to_string());
|
||||
return;
|
||||
}
|
||||
let path = self.current_path.join(name);
|
||||
if let Err(e) = std::fs::create_dir(&path) {
|
||||
self.set_status(format!("Error: {}", e));
|
||||
} else {
|
||||
self.set_status(format!("Created directory {}", name));
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
pub fn compute_properties(&mut self) {
|
||||
if self.selected_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_multi = self.selected_indices.len() > 1;
|
||||
|
||||
if is_multi {
|
||||
let mut total_size: u64 = 0;
|
||||
let mut total_files: u64 = 0;
|
||||
for &i in &self.selected_indices {
|
||||
if let Some(e) = self.entries.get(i) {
|
||||
let (s, c) = file_ops::recursive_info(&e.path);
|
||||
total_size += s;
|
||||
total_files += c;
|
||||
}
|
||||
}
|
||||
self.dialog = DialogMode::Properties(PropertiesInfo {
|
||||
name: format!("{} items selected", self.selected_indices.len()),
|
||||
path: self.current_path.to_string_lossy().to_string(),
|
||||
size: total_size,
|
||||
file_count: total_files,
|
||||
permissions: String::from("N/A"),
|
||||
owner_user: String::from("N/A"),
|
||||
owner_group: String::from("N/A"),
|
||||
is_multi: true,
|
||||
});
|
||||
} else {
|
||||
let i = self.selected_indices[0];
|
||||
if let Some(e) = self.entries.get(i) {
|
||||
let (size, file_count) = file_ops::recursive_info(&e.path);
|
||||
let permissions = file_ops::file_permissions(&e.path);
|
||||
let (owner_user, owner_group) = file_ops::file_owner(&e.path);
|
||||
self.dialog = DialogMode::Properties(PropertiesInfo {
|
||||
name: e.name.clone(),
|
||||
path: e.path.to_string_lossy().to_string(),
|
||||
size,
|
||||
file_count,
|
||||
permissions,
|
||||
owner_user,
|
||||
owner_group,
|
||||
is_multi: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_context_menu(&self, x: u16, y: u16, is_sidebar_fav: bool, fav_path: Option<String>) -> ContextMenu {
|
||||
let has_selection = !self.selected_indices.is_empty();
|
||||
let has_clipboard = self.clipboard.is_some();
|
||||
let can_paste = has_clipboard && (self.selected_target_dir().is_some() || true);
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if is_sidebar_fav {
|
||||
if let Some(path) = fav_path {
|
||||
items.push(ContextMenuItem::RemoveFavorite(path));
|
||||
}
|
||||
} else {
|
||||
if has_selection {
|
||||
items.push(ContextMenuItem::Copy);
|
||||
items.push(ContextMenuItem::Cut);
|
||||
}
|
||||
if can_paste {
|
||||
items.push(ContextMenuItem::Paste);
|
||||
}
|
||||
if has_selection {
|
||||
items.push(ContextMenuItem::Separator);
|
||||
items.push(ContextMenuItem::Rename);
|
||||
items.push(ContextMenuItem::Properties);
|
||||
items.push(ContextMenuItem::Separator);
|
||||
items.push(ContextMenuItem::Delete);
|
||||
}
|
||||
items.push(ContextMenuItem::Separator);
|
||||
items.push(ContextMenuItem::NewSubmenu);
|
||||
items.push(ContextMenuItem::AddFavorite);
|
||||
}
|
||||
|
||||
ContextMenu { x, y, items, selected: 0, show_new_submenu: false, sub_selected: None }
|
||||
}
|
||||
|
||||
/// Returns target directory for paste: selected dir or current path
|
||||
pub fn selected_target_dir(&self) -> Option<PathBuf> {
|
||||
if self.selected_indices.len() == 1 {
|
||||
let i = self.selected_indices[0];
|
||||
if let Some(e) = self.entries.get(i) {
|
||||
if e.is_dir() {
|
||||
return Some(e.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(self.current_path.clone())
|
||||
}
|
||||
|
||||
pub fn visible_entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
48
src/config.rs
Normal file
48
src/config.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct Config {
|
||||
pub favorites: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("tui-fm").join("config.json"))
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
if let Some(path) = Self::config_path() {
|
||||
if let Ok(data) = std::fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<Config>(&data) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
Config::default()
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
if let Some(path) = Self::config_path() {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(&path, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_favorite(&mut self, path: &str) {
|
||||
let s = path.to_string();
|
||||
if !self.favorites.contains(&s) {
|
||||
self.favorites.push(s);
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_favorite(&mut self, path: &str) {
|
||||
self.favorites.retain(|f| f != path);
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
593
src/events.rs
Normal file
593
src/events.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{
|
||||
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind,
|
||||
};
|
||||
use ratatui::{backend::Backend, Terminal};
|
||||
|
||||
use crate::app::{App, ConfirmAction, ContextMenuItem, DialogMode, Focus};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(50))? {
|
||||
let ev = event::read()?;
|
||||
if handle_event(app, ev) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the app should quit
|
||||
fn handle_event(app: &mut App, ev: Event) -> bool {
|
||||
match ev {
|
||||
Event::Key(key) => handle_key(app, key),
|
||||
Event::Mouse(mouse) => {
|
||||
handle_mouse(app, mouse);
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
|
||||
// Close context menu on any key
|
||||
if app.context_menu.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => { app.context_menu = None; return false; }
|
||||
KeyCode::Up => {
|
||||
if let Some(ref mut m) = app.context_menu {
|
||||
if m.selected > 0 { m.selected -= 1; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Some(ref mut m) = app.context_menu {
|
||||
let max = m.items.len().saturating_sub(1);
|
||||
if m.selected < max { m.selected += 1; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
execute_context_menu(app);
|
||||
return false;
|
||||
}
|
||||
_ => { app.context_menu = None; return false; }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dialogs
|
||||
match &app.dialog.clone() {
|
||||
DialogMode::Rename(current) => {
|
||||
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||
if let Some(path) = app.rename_original.clone() {
|
||||
app.rename_entry(&path, &s);
|
||||
}
|
||||
app.rename_original = None;
|
||||
});
|
||||
}
|
||||
DialogMode::NewFile(current) => {
|
||||
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||
app.create_file(&s);
|
||||
});
|
||||
}
|
||||
DialogMode::NewDir(current) => {
|
||||
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
|
||||
app.create_dir(&s);
|
||||
});
|
||||
}
|
||||
DialogMode::Properties(_) | DialogMode::Error(_) => {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) {
|
||||
app.dialog = DialogMode::None;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
DialogMode::Confirm(_, action) => {
|
||||
let action = action.clone();
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Enter => {
|
||||
app.dialog = DialogMode::None;
|
||||
match action {
|
||||
ConfirmAction::Delete => app.delete_selected(),
|
||||
}
|
||||
}
|
||||
_ => { app.dialog = DialogMode::None; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
DialogMode::None => {}
|
||||
}
|
||||
|
||||
// Path input mode
|
||||
if app.focus == Focus::PathInput {
|
||||
match key.code {
|
||||
KeyCode::Esc => { app.focus = Focus::FileView; }
|
||||
KeyCode::Enter => {
|
||||
let p = std::path::PathBuf::from(&app.path_input);
|
||||
app.navigate_to(p);
|
||||
app.focus = Focus::FileView;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if app.path_input_cursor > 0 {
|
||||
let cursor = app.path_input_cursor;
|
||||
app.path_input.remove(cursor - 1);
|
||||
app.path_input_cursor -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if app.path_input_cursor > 0 { app.path_input_cursor -= 1; }
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if app.path_input_cursor < app.path_input.len() { app.path_input_cursor += 1; }
|
||||
}
|
||||
KeyCode::Home => { app.path_input_cursor = 0; }
|
||||
KeyCode::End => { app.path_input_cursor = app.path_input.len(); }
|
||||
KeyCode::Char(c) => {
|
||||
let cursor = app.path_input_cursor;
|
||||
app.path_input.insert(cursor, c);
|
||||
app.path_input_cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Global keys
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('c') => { app.copy_selected(); return false; }
|
||||
KeyCode::Char('x') => { app.cut_selected(); return false; }
|
||||
KeyCode::Char('v') => {
|
||||
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
|
||||
app.paste_into(&dst.clone());
|
||||
return false;
|
||||
}
|
||||
KeyCode::Char('a') => { app.select_all(); return false; }
|
||||
KeyCode::Char('h') => {
|
||||
app.show_hidden = !app.show_hidden;
|
||||
app.refresh();
|
||||
return false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Tab => {
|
||||
app.focus = match app.focus {
|
||||
Focus::Sidebar => Focus::FileView,
|
||||
Focus::FileView => Focus::PathInput,
|
||||
Focus::PathInput => Focus::Sidebar,
|
||||
};
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.navigate_up();
|
||||
}
|
||||
KeyCode::F(2) => {
|
||||
if let Some(&i) = app.selected_indices.first() {
|
||||
if let Some(e) = app.entries.get(i) {
|
||||
app.rename_original = Some(e.path.clone());
|
||||
app.dialog = DialogMode::Rename(e.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if !app.selected_indices.is_empty() {
|
||||
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
||||
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.focus == Focus::FileView {
|
||||
if let Some(&i) = app.selected_indices.first() {
|
||||
if let Some(e) = app.entries.get(i).cloned() {
|
||||
if e.is_dir() {
|
||||
app.navigate_to(e.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if app.focus == Focus::FileView {
|
||||
scroll_selection(app, -1);
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if app.focus == Focus::FileView {
|
||||
scroll_selection(app, 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn scroll_selection(app: &mut App, delta: i32) {
|
||||
let count = app.entries.len();
|
||||
if count == 0 { return; }
|
||||
|
||||
let current = app.selected_indices.first().copied().unwrap_or(0) as i32;
|
||||
let new_idx = (current + delta).max(0).min(count as i32 - 1) as usize;
|
||||
app.selected_indices = vec![new_idx];
|
||||
|
||||
// Ensure visible
|
||||
if new_idx < app.scroll_offset {
|
||||
app.scroll_offset = new_idx;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_text_dialog_key<F>(
|
||||
app: &mut App,
|
||||
key: crossterm::event::KeyEvent,
|
||||
current: String,
|
||||
on_confirm: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce(&mut App, String),
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.dialog = DialogMode::None;
|
||||
app.rename_original = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let s = current.clone();
|
||||
app.dialog = DialogMode::None;
|
||||
on_confirm(app, s);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut s = current;
|
||||
s.pop();
|
||||
update_dialog_text(app, s);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
let mut s = current;
|
||||
s.push(c);
|
||||
update_dialog_text(app, s);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn update_dialog_text(app: &mut App, new_text: String) {
|
||||
match &app.dialog {
|
||||
DialogMode::Rename(_) => app.dialog = DialogMode::Rename(new_text),
|
||||
DialogMode::NewFile(_) => app.dialog = DialogMode::NewFile(new_text),
|
||||
DialogMode::NewDir(_) => app.dialog = DialogMode::NewDir(new_text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_context_menu(app: &mut App) {
|
||||
let menu = app.context_menu.clone();
|
||||
if let Some(m) = menu {
|
||||
let item = m.items.get(m.selected).cloned();
|
||||
app.context_menu = None;
|
||||
if let Some(item) = item {
|
||||
match item {
|
||||
ContextMenuItem::Copy => app.copy_selected(),
|
||||
ContextMenuItem::Cut => app.cut_selected(),
|
||||
ContextMenuItem::Paste => {
|
||||
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
|
||||
app.paste_into(&dst.clone());
|
||||
}
|
||||
ContextMenuItem::Rename => {
|
||||
if let Some(&i) = app.selected_indices.first() {
|
||||
if let Some(e) = app.entries.get(i) {
|
||||
app.rename_original = Some(e.path.clone());
|
||||
app.dialog = DialogMode::Rename(e.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
ContextMenuItem::Properties => app.compute_properties(),
|
||||
ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()),
|
||||
ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(String::new()),
|
||||
ContextMenuItem::Delete => {
|
||||
if !app.selected_indices.is_empty() {
|
||||
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
|
||||
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
|
||||
}
|
||||
}
|
||||
ContextMenuItem::AddFavorite => {
|
||||
let path = app.current_path.to_string_lossy().to_string();
|
||||
app.config.add_favorite(&path);
|
||||
app.set_status("Added to favorites".to_string());
|
||||
}
|
||||
ContextMenuItem::RemoveFavorite(path) => {
|
||||
app.config.remove_favorite(&path);
|
||||
app.set_status("Removed from favorites".to_string());
|
||||
}
|
||||
ContextMenuItem::NewSubmenu => {
|
||||
// Toggle submenu - handled in UI
|
||||
}
|
||||
ContextMenuItem::Separator => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollDown => handle_scroll(app, mouse.column, mouse.row, 3),
|
||||
MouseEventKind::ScrollUp => handle_scroll(app, mouse.column, mouse.row, -3),
|
||||
MouseEventKind::Down(MouseButton::Left) => handle_left_click(app, mouse.column, mouse.row),
|
||||
MouseEventKind::Down(MouseButton::Right) => handle_right_click(app, mouse.column, mouse.row),
|
||||
MouseEventKind::Moved => handle_mouse_move(app, mouse.column, mouse.row),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_scroll(app: &mut App, col: u16, _row: u16, delta: i32) {
|
||||
if col < 20 {
|
||||
// Sidebar scroll
|
||||
let roots_len = crate::file_ops::get_root_dirs().len();
|
||||
let favs_len = app.config.favorites.len();
|
||||
let total = favs_len + roots_len + 5; // headings+padding
|
||||
let max = total.saturating_sub(10);
|
||||
let new_off = (app.sidebar_scroll as i32 + delta).max(0).min(max as i32) as usize;
|
||||
app.sidebar_scroll = new_off;
|
||||
} else {
|
||||
// File view scroll
|
||||
let count = app.entries.len();
|
||||
let new_off = (app.scroll_offset as i32 + delta).max(0).min(count as i32) as usize;
|
||||
app.scroll_offset = new_off;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit test: given terminal coords, determine which area and entry index was clicked
|
||||
pub fn hit_test_file_view(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<usize> {
|
||||
let area = layout.file_view;
|
||||
// File list starts after the header row (row+2 from top of area)
|
||||
if col < area.x || col >= area.x + area.width { return None; }
|
||||
if row < area.y + 2 || row >= area.y + area.height { return None; }
|
||||
let list_row = (row - area.y - 2) as usize;
|
||||
let idx = list_row + app.scroll_offset;
|
||||
if idx < app.entries.len() { Some(idx) } else { None }
|
||||
}
|
||||
|
||||
pub fn hit_test_sidebar(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<SidebarItem> {
|
||||
let area = layout.sidebar;
|
||||
if col < area.x || col >= area.x + area.width { return None; }
|
||||
// row must be inside the inner content area (skip top border at area.y)
|
||||
if row <= area.y || row >= area.y + area.height { return None; }
|
||||
|
||||
// Map terminal row → list item index, accounting for top border and scroll
|
||||
let list_idx = app.sidebar_scroll + (row - area.y - 1) as usize;
|
||||
|
||||
let favs_len = app.config.favorites.len();
|
||||
// When empty, "(inga)" placeholder occupies 1 slot in the rendered list
|
||||
let display_favs = favs_len.max(1);
|
||||
let roots = crate::file_ops::get_root_dirs();
|
||||
|
||||
// idx 0 : [↑ Gå upp]
|
||||
if list_idx == 0 { return Some(SidebarItem::GoUp); }
|
||||
// idx 1 : FAVORITER heading
|
||||
if list_idx == 1 { return None; }
|
||||
// idx 2 .. 1+display_favs : favorites (or "(inga)" placeholder)
|
||||
if list_idx >= 2 && list_idx <= 1 + display_favs {
|
||||
let fav_idx = list_idx - 2;
|
||||
if fav_idx < favs_len {
|
||||
return Some(SidebarItem::Favorite(fav_idx, app.config.favorites[fav_idx].clone()));
|
||||
}
|
||||
return None; // "(inga)" placeholder – not clickable
|
||||
}
|
||||
// idx 2+display_favs : separator
|
||||
if list_idx == 2 + display_favs { return None; }
|
||||
// idx 3+display_favs : SÖKVÄGAR heading
|
||||
if list_idx == 3 + display_favs { return None; }
|
||||
// idx 4+display_favs .. 3+display_favs+roots_len : root dirs
|
||||
let roots_start = 4 + display_favs;
|
||||
if list_idx >= roots_start && list_idx < roots_start + roots.len() {
|
||||
let idx = list_idx - roots_start;
|
||||
return Some(SidebarItem::Root(roots[idx].clone()));
|
||||
}
|
||||
// idx roots_start+roots_len : separator
|
||||
// idx roots_start+roots_len+1 : [+ Favorit]
|
||||
if list_idx == roots_start + roots.len() + 1 {
|
||||
return Some(SidebarItem::AddFavorite);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub enum SidebarItem {
|
||||
Favorite(usize, String),
|
||||
Root(std::path::PathBuf),
|
||||
AddFavorite,
|
||||
GoUp,
|
||||
}
|
||||
|
||||
fn handle_left_click(app: &mut App, col: u16, row: u16) {
|
||||
const MENU_W: u16 = 24;
|
||||
const SUB_W: u16 = 18;
|
||||
const SUB_H: u16 = 4;
|
||||
|
||||
if let Some(m) = app.context_menu.clone() {
|
||||
let mx = m.x;
|
||||
let my = m.y;
|
||||
let items_len = m.items.len() as u16;
|
||||
let menu_height = items_len + 2; // top + bottom border
|
||||
|
||||
let in_main = col >= mx && col < mx + MENU_W
|
||||
&& row >= my && row < my + menu_height;
|
||||
|
||||
// Check if click lands in the submenu (only visible when NewSubmenu is highlighted)
|
||||
let ns_idx = m.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu));
|
||||
let in_sub = if let Some(ns_pos) = ns_idx {
|
||||
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
|
||||
let sub_y = my + 1 + ns_pos as u16;
|
||||
col >= sub_x && col < sub_x + SUB_W
|
||||
&& row > sub_y && row < sub_y + SUB_H // > to skip top border row
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if in_sub {
|
||||
let ns_pos = ns_idx.unwrap();
|
||||
let sub_y = my + 1 + ns_pos as u16;
|
||||
// skip the top border row of the submenu box
|
||||
if row <= sub_y { app.context_menu = None; return; }
|
||||
let sub_item = row.saturating_sub(sub_y + 1) as usize;
|
||||
app.context_menu = None;
|
||||
match sub_item {
|
||||
0 => app.dialog = DialogMode::NewFile(String::new()),
|
||||
1 => app.dialog = DialogMode::NewDir(String::new()),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if in_main {
|
||||
// Items live between the two border rows
|
||||
if row > my && row <= my + items_len {
|
||||
let item_row = (row - my - 1) as usize;
|
||||
if let Some(ref mut menu) = app.context_menu {
|
||||
menu.selected = item_row.min(menu.items.len().saturating_sub(1));
|
||||
}
|
||||
execute_context_menu(app);
|
||||
} else {
|
||||
// Clicked on a border row – dismiss
|
||||
app.context_menu = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicked outside everything – dismiss
|
||||
app.context_menu = None;
|
||||
}
|
||||
|
||||
let layout = ui::compute_layout(app);
|
||||
|
||||
// Check path input area
|
||||
let pi = layout.path_input;
|
||||
if row == pi.y && col >= pi.x && col < pi.x + pi.width {
|
||||
app.focus = Focus::PathInput;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check sidebar
|
||||
if col < layout.sidebar.x + layout.sidebar.width {
|
||||
app.focus = Focus::Sidebar;
|
||||
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
|
||||
match item {
|
||||
SidebarItem::Favorite(_, path) => {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
app.navigate_to(p);
|
||||
}
|
||||
SidebarItem::Root(p) => {
|
||||
app.navigate_to(p);
|
||||
}
|
||||
SidebarItem::AddFavorite => {
|
||||
let path = app.current_path.to_string_lossy().to_string();
|
||||
app.config.add_favorite(&path);
|
||||
app.set_status("Added to favorites".to_string());
|
||||
}
|
||||
SidebarItem::GoUp => {
|
||||
app.navigate_up();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file view
|
||||
app.focus = Focus::FileView;
|
||||
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
|
||||
let now = Instant::now();
|
||||
let is_double = if let Some((last_idx, last_time)) = app.last_click_entry {
|
||||
last_idx == idx && now.duration_since(last_time) < Duration::from_millis(400)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
app.last_click_entry = Some((idx, now));
|
||||
|
||||
if is_double {
|
||||
app.last_click_entry = None;
|
||||
if let Some(e) = app.entries.get(idx).cloned() {
|
||||
if e.is_dir() {
|
||||
app.navigate_to(e.path.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.select_only(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_right_click(app: &mut App, col: u16, row: u16) {
|
||||
let layout = ui::compute_layout(app);
|
||||
|
||||
// Right-click on sidebar favorite
|
||||
if col < layout.sidebar.x + layout.sidebar.width {
|
||||
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
|
||||
if let SidebarItem::Favorite(_, path) = item {
|
||||
let menu = app.build_context_menu(col, row, true, Some(path));
|
||||
app.context_menu = Some(menu);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Right-click on file
|
||||
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
|
||||
if !app.selected_indices.contains(&idx) {
|
||||
app.select_only(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let menu = app.build_context_menu(col, row, false, None);
|
||||
app.context_menu = Some(menu);
|
||||
}
|
||||
|
||||
fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
|
||||
const MENU_W: u16 = 24;
|
||||
const SUB_W: u16 = 18;
|
||||
|
||||
let Some(ref mut menu) = app.context_menu else { return; };
|
||||
|
||||
let mx = menu.x;
|
||||
let my = menu.y;
|
||||
let items_len = menu.items.len() as u16;
|
||||
|
||||
// Hovering over a main-menu item (inside the border)
|
||||
if col > mx && col < mx + MENU_W - 1
|
||||
&& row > my && row <= my + items_len
|
||||
{
|
||||
let item_idx = (row - my - 1) as usize;
|
||||
if item_idx < menu.items.len() {
|
||||
menu.selected = item_idx;
|
||||
menu.sub_selected = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When NewSubmenu is highlighted, check if the mouse moves into the flyout
|
||||
if let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu)) {
|
||||
if menu.selected == ns_pos {
|
||||
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
|
||||
let sub_y = my + 1 + ns_pos as u16;
|
||||
|
||||
if col >= sub_x && col < sub_x + SUB_W
|
||||
&& row >= sub_y && row < sub_y + 4
|
||||
{
|
||||
// Keep NewSubmenu highlighted and track which flyout row is hovered
|
||||
if row > sub_y && row < sub_y + 3 {
|
||||
menu.sub_selected = Some((row - sub_y - 1) as usize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/file_ops.rs
Normal file
267
src/file_ops.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FileType {
|
||||
Directory,
|
||||
File,
|
||||
Symlink,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub file_type: FileType,
|
||||
pub size: u64,
|
||||
pub is_hidden: bool,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match &self.file_type {
|
||||
FileType::Directory => "📁",
|
||||
FileType::Symlink => "🔗",
|
||||
FileType::File => {
|
||||
let ext = self
|
||||
.name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
match ext.as_str() {
|
||||
"rs" => "🦀",
|
||||
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "🖼",
|
||||
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
|
||||
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
|
||||
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
|
||||
"pdf" => "📕",
|
||||
"txt" | "md" | "log" => "📄",
|
||||
"json" | "yaml" | "yml" | "toml" | "xml" => "⚙",
|
||||
"sh" | "bash" | "zsh" | "fish" => "🐚",
|
||||
"py" => "🐍",
|
||||
"js" | "ts" => "📜",
|
||||
"c" | "cpp" | "h" | "hpp" => "🔧",
|
||||
"exe" | "bin" | "out" | "run" => "⚙",
|
||||
_ => "📎",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.file_type == FileType::Directory
|
||||
}
|
||||
|
||||
pub fn formatted_size(&self) -> String {
|
||||
if self.file_type == FileType::Directory {
|
||||
return String::from("<dir>");
|
||||
}
|
||||
format_size(self.size)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{} KB", bytes / KB)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_dir(path: &Path) -> Vec<FileEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let Ok(dir) = fs::read_dir(path) else {
|
||||
return entries;
|
||||
};
|
||||
|
||||
for entry in dir.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
let is_hidden = name.starts_with('.');
|
||||
|
||||
let (file_type, size) = if let Ok(meta) = entry.metadata() {
|
||||
let ft = if meta.is_symlink() {
|
||||
FileType::Symlink
|
||||
} else if meta.is_dir() {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
};
|
||||
(ft, meta.len())
|
||||
} else {
|
||||
(FileType::File, 0)
|
||||
};
|
||||
|
||||
entries.push(FileEntry { name, path, file_type, size, is_hidden });
|
||||
}
|
||||
|
||||
// Sort: dirs first, then files, both alphabetically
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.file_type, &b.file_type) {
|
||||
(FileType::Directory, FileType::Directory) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
(FileType::Directory, _) => std::cmp::Ordering::Less,
|
||||
(_, FileType::Directory) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
}
|
||||
});
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Recursively compute total size and file count of a path
|
||||
pub fn recursive_info(path: &Path) -> (u64, u64) {
|
||||
let mut total_size: u64 = 0;
|
||||
let mut file_count: u64 = 0;
|
||||
|
||||
if path.is_file() {
|
||||
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return (size, 1);
|
||||
}
|
||||
|
||||
if let Ok(dir) = fs::read_dir(path) {
|
||||
for entry in dir.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let (s, c) = recursive_info(&p);
|
||||
total_size += s;
|
||||
file_count += c;
|
||||
} else {
|
||||
total_size += p.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(total_size, file_count)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn file_permissions(path: &Path) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = path.metadata().map(|m| m.permissions().mode()).unwrap_or(0);
|
||||
format!("{:o}", mode & 0o777)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn file_permissions(_path: &Path) -> String {
|
||||
String::from("N/A")
|
||||
}
|
||||
|
||||
pub fn file_owner(path: &Path) -> (String, String) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let Ok(meta) = path.metadata() {
|
||||
let uid = meta.uid();
|
||||
let gid = meta.gid();
|
||||
let user_name = lookup_name("/etc/passwd", uid)
|
||||
.map(|n| format!("{} ({})", n, uid))
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
let group_name = lookup_name("/etc/group", gid)
|
||||
.map(|n| format!("{} ({})", n, gid))
|
||||
.unwrap_or_else(|| gid.to_string());
|
||||
return (user_name, group_name);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
(String::from("N/A"), String::from("N/A"))
|
||||
}
|
||||
|
||||
/// Parse /etc/passwd or /etc/group and return the name for a given numeric id.
|
||||
/// Both files share the same format: `name:x:id:...`
|
||||
#[cfg(unix)]
|
||||
fn lookup_name(db_file: &str, id: u32) -> Option<String> {
|
||||
let content = std::fs::read_to_string(db_file).ok()?;
|
||||
for line in content.lines() {
|
||||
let mut parts = line.splitn(4, ':');
|
||||
let name = parts.next()?;
|
||||
parts.next(); // password/placeholder
|
||||
let entry_id: u32 = parts.next()?.parse().ok()?;
|
||||
if entry_id == id {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Copy a file or directory recursively
|
||||
pub fn copy_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
||||
let name = src.file_name().ok_or("No filename")?;
|
||||
let dst = dst_dir.join(name);
|
||||
|
||||
if src.is_dir() {
|
||||
fs_extra::dir::copy(
|
||||
src,
|
||||
dst_dir,
|
||||
&fs_extra::dir::CopyOptions::new(),
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
fs::copy(src, &dst).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Move (cut/paste) a file or directory
|
||||
pub fn move_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
|
||||
let name = src.file_name().ok_or("No filename")?;
|
||||
let dst = dst_dir.join(name);
|
||||
|
||||
if src.is_dir() {
|
||||
fs_extra::dir::move_dir(
|
||||
src,
|
||||
dst_dir,
|
||||
&fs_extra::dir::CopyOptions::new(),
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
fs::rename(src, &dst).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_entry(path: &Path) -> Result<(), String> {
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_root_dirs() -> Vec<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let roots = ["/", "/home", "/etc", "/usr", "/var", "/opt", "/tmp"];
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let p = PathBuf::from(r);
|
||||
if p.exists() { Some(p) } else { None }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut drives = Vec::new();
|
||||
for letter in b'A'..=b'Z' {
|
||||
let d = format!("{}:\\", letter as char);
|
||||
let p = PathBuf::from(&d);
|
||||
if p.exists() {
|
||||
drives.push(p);
|
||||
}
|
||||
}
|
||||
drives
|
||||
}
|
||||
}
|
||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod file_ops;
|
||||
mod ui;
|
||||
mod events;
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
|
||||
use app::App;
|
||||
use events::run_app;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new();
|
||||
let res = run_app(&mut terminal, &mut app);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
eprintln!("Error: {:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
614
src/ui.rs
Normal file
614
src/ui.rs
Normal file
@@ -0,0 +1,614 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, Borders, Cell, Clear, List, ListItem, Paragraph,
|
||||
Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||
Table, TableState, Wrap,
|
||||
},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, ContextMenuItem, DialogMode, Focus};
|
||||
use crate::file_ops::{self, format_size};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LayoutAreas {
|
||||
pub sidebar: Rect,
|
||||
pub file_view: Rect,
|
||||
pub status_bar: Rect,
|
||||
pub path_input: Rect,
|
||||
}
|
||||
|
||||
/// Compute the layout areas for hit testing (without a frame)
|
||||
pub fn compute_layout(_app: &App) -> LayoutAreas {
|
||||
// We use a dummy size; in real rendering ratatui provides the actual size.
|
||||
// For hit-testing, we just need approximate positions.
|
||||
// We'll use the terminal size from crossterm.
|
||||
let (width, height) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||
let full = Rect::new(0, 0, width, height);
|
||||
|
||||
split_layout(full)
|
||||
}
|
||||
|
||||
fn split_layout(area: Rect) -> LayoutAreas {
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let content_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(22),
|
||||
Constraint::Min(10),
|
||||
])
|
||||
.split(main_chunks[0]);
|
||||
|
||||
let bottom_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(10),
|
||||
])
|
||||
.split(main_chunks[1]);
|
||||
|
||||
LayoutAreas {
|
||||
sidebar: content_chunks[0],
|
||||
file_view: content_chunks[1],
|
||||
status_bar: main_chunks[1],
|
||||
path_input: bottom_chunks[0],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
let area = f.area();
|
||||
let layout = split_layout(area);
|
||||
|
||||
draw_sidebar(f, app, layout.sidebar);
|
||||
draw_file_view(f, app, layout.file_view);
|
||||
draw_bottom_bar(f, app, layout.status_bar);
|
||||
|
||||
if let Some(ref menu) = app.context_menu.clone() {
|
||||
draw_context_menu(f, app, menu.clone());
|
||||
}
|
||||
|
||||
match &app.dialog.clone() {
|
||||
DialogMode::Rename(text) => draw_input_dialog(f, "Byt namn", text, area),
|
||||
DialogMode::NewFile(text) => draw_input_dialog(f, "Ny fil - Ange namn:", text, area),
|
||||
DialogMode::NewDir(text) => draw_input_dialog(f, "Ny mapp - Ange namn:", text, area),
|
||||
DialogMode::Properties(info) => draw_properties(f, info.clone(), area),
|
||||
DialogMode::Error(msg) => draw_error_dialog(f, msg, area),
|
||||
DialogMode::Confirm(msg, _) => draw_confirm_dialog(f, msg, area),
|
||||
DialogMode::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let focused = app.focus == Focus::Sidebar;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Navigering ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style);
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let mut items: Vec<ListItem> = Vec::new();
|
||||
|
||||
// Up-level button
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(" ↑ Gå upp", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
|
||||
])));
|
||||
|
||||
// Favorites section
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("FAVORITER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
])));
|
||||
|
||||
if app.config.favorites.is_empty() {
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(" (inga)", Style::default().fg(Color::DarkGray)),
|
||||
])));
|
||||
} else {
|
||||
for fav in &app.config.favorites {
|
||||
let name = std::path::Path::new(fav)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| fav.clone());
|
||||
let is_current = fav == &app.current_path.to_string_lossy().to_string();
|
||||
let style = if is_current {
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!(" ★ {}", name), style),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
|
||||
])));
|
||||
|
||||
// Root directories
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("SÖKVÄGAR", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
])));
|
||||
|
||||
let roots = file_ops::get_root_dirs();
|
||||
for root in &roots {
|
||||
let label = root.to_string_lossy();
|
||||
let is_current = root == &app.current_path;
|
||||
let style = if is_current {
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!(" 📁 {}", label), style),
|
||||
])));
|
||||
}
|
||||
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
|
||||
])));
|
||||
|
||||
// Add favorite button
|
||||
items.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(" [+ Favorit]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||
])));
|
||||
|
||||
// Apply scroll offset
|
||||
let visible: Vec<ListItem> = items
|
||||
.into_iter()
|
||||
.skip(app.sidebar_scroll)
|
||||
.take(inner.height as usize)
|
||||
.collect();
|
||||
let total_sidebar = visible.len() + app.sidebar_scroll;
|
||||
|
||||
let list = List::new(visible);
|
||||
f.render_widget(list, inner);
|
||||
|
||||
// Scrollbar for sidebar
|
||||
if total_sidebar > inner.height as usize {
|
||||
let mut sb_state = ScrollbarState::new(total_sidebar).position(app.sidebar_scroll);
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None)
|
||||
.thumb_symbol("█");
|
||||
f.render_stateful_widget(
|
||||
scrollbar,
|
||||
area.inner(Margin { vertical: 1, horizontal: 0 }),
|
||||
&mut sb_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let focused = app.focus == Focus::FileView;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
// Build rows from ALL entries (TableState offset handles which rows are visible)
|
||||
let rows: Vec<Row> = app
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| {
|
||||
let is_selected = app.selected_indices.contains(&idx);
|
||||
let name_cell = format!("{} {}", entry.icon(), entry.name);
|
||||
let size_cell = entry.formatted_size();
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD)
|
||||
} else if entry.is_dir() {
|
||||
Style::default().fg(Color::LightBlue)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(name_cell),
|
||||
Cell::from(size_cell).style(Style::default().fg(if is_selected { Color::White } else { Color::DarkGray })),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Namn"),
|
||||
Cell::from("Storlek"),
|
||||
])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | Modifier::UNDERLINED))
|
||||
.height(1);
|
||||
|
||||
let path_title = format!(" {} ", app.current_path.to_string_lossy());
|
||||
let block = Block::default()
|
||||
.title(path_title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style);
|
||||
|
||||
// Column widths: name takes remaining space, size is fixed
|
||||
let widths = [Constraint::Min(10), Constraint::Length(10)];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(block)
|
||||
.column_spacing(1);
|
||||
|
||||
let mut table_state = TableState::default();
|
||||
*table_state.offset_mut() = app.scroll_offset;
|
||||
|
||||
f.render_stateful_widget(table, area, &mut table_state);
|
||||
|
||||
// Scrollbar — rendered over the right border of the block
|
||||
let total = app.entries.len();
|
||||
let visible = area.height.saturating_sub(3) as usize; // -2 borders -1 header
|
||||
if total > visible {
|
||||
let mut sb_state = ScrollbarState::new(total).position(app.scroll_offset);
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_symbol("█");
|
||||
f.render_stateful_widget(
|
||||
scrollbar,
|
||||
area.inner(Margin { vertical: 1, horizontal: 0 }),
|
||||
&mut sb_state,
|
||||
);
|
||||
}
|
||||
|
||||
// Selection count badge
|
||||
if !app.selected_indices.is_empty() {
|
||||
let sel_text = format!(" {} markerade ", app.selected_indices.len());
|
||||
let inner = Block::default().borders(Borders::ALL).inner(area);
|
||||
let badge_area = Rect::new(
|
||||
inner.x + inner.width.saturating_sub(sel_text.len() as u16 + 2),
|
||||
inner.y,
|
||||
sel_text.len() as u16 + 2,
|
||||
1,
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(sel_text).style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)),
|
||||
badge_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||
let focused = app.focus == Focus::PathInput;
|
||||
|
||||
let outer_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
});
|
||||
|
||||
let inner = outer_block.inner(area);
|
||||
f.render_widget(outer_block, area);
|
||||
|
||||
// Left: status message or hint
|
||||
let hint = app
|
||||
.status_message
|
||||
.as_deref()
|
||||
.unwrap_or("Tab=fokus Backspace=upp Ctrl+C/X/V=kopiera/klipp/klistra q=avsluta");
|
||||
|
||||
let hint_width = (inner.width / 3).min(hint.len() as u16 + 2);
|
||||
let hint_area = Rect::new(inner.x, inner.y, hint_width, inner.height);
|
||||
f.render_widget(
|
||||
Paragraph::new(hint)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.wrap(Wrap { trim: true }),
|
||||
hint_area,
|
||||
);
|
||||
|
||||
// Right: path input
|
||||
let input_x = inner.x + hint_width + 1;
|
||||
let input_width = inner.width.saturating_sub(hint_width + 1);
|
||||
if input_width < 4 { return; }
|
||||
|
||||
let input_area = Rect::new(input_x, inner.y, input_width, inner.height);
|
||||
|
||||
let label = "Sökväg: ";
|
||||
let label_len = label.len() as u16;
|
||||
let cursor_pos = app.path_input_cursor;
|
||||
let avail = input_width.saturating_sub(label_len) as usize;
|
||||
|
||||
// Scroll the input text to show cursor
|
||||
let (display_text, cursor_in_display) =
|
||||
scroll_input_text(&app.path_input, cursor_pos, avail);
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(label, Style::default().fg(Color::Yellow)),
|
||||
];
|
||||
|
||||
if focused {
|
||||
// Render with cursor highlight
|
||||
let before = &display_text[..cursor_in_display.min(display_text.len())];
|
||||
let cursor_char = display_text
|
||||
.chars()
|
||||
.nth(cursor_in_display)
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let after = if cursor_in_display + 1 <= display_text.len() {
|
||||
&display_text[cursor_in_display + cursor_char.len()..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
spans.push(Span::styled(before, Style::default().fg(Color::White)));
|
||||
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(Color::White)));
|
||||
spans.push(Span::styled(after, Style::default().fg(Color::White)));
|
||||
} else {
|
||||
spans.push(Span::styled(&display_text, Style::default().fg(Color::White)));
|
||||
}
|
||||
|
||||
let para = Paragraph::new(Line::from(spans));
|
||||
f.render_widget(para, input_area);
|
||||
}
|
||||
|
||||
fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize) {
|
||||
let len = text.len();
|
||||
if len <= avail {
|
||||
return (text.to_string(), cursor);
|
||||
}
|
||||
// Scroll to show cursor
|
||||
let start = if cursor > avail { cursor - avail } else { 0 };
|
||||
let end = (start + avail).min(len);
|
||||
let slice = &text[start..end];
|
||||
let cursor_in_display = cursor.saturating_sub(start);
|
||||
(slice.to_string(), cursor_in_display)
|
||||
}
|
||||
|
||||
fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
|
||||
let items = &menu.items;
|
||||
if items.is_empty() { return; }
|
||||
|
||||
// Calculate menu dimensions
|
||||
let menu_width: u16 = 24;
|
||||
let menu_height = items.len() as u16 + 2;
|
||||
|
||||
let term_area = f.area();
|
||||
let x = menu.x.min(term_area.width.saturating_sub(menu_width));
|
||||
let y = menu.y.min(term_area.height.saturating_sub(menu_height));
|
||||
|
||||
let menu_area = Rect::new(x, y, menu_width, menu_height);
|
||||
|
||||
f.render_widget(Clear, menu_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Meny ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
|
||||
let inner = block.inner(menu_area);
|
||||
f.render_widget(block, menu_area);
|
||||
|
||||
let mut show_new_sub = false;
|
||||
let mut new_sub_row: u16 = 0;
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
|
||||
let is_sel = i == menu.selected;
|
||||
|
||||
let (text, style) = match item {
|
||||
ContextMenuItem::Copy => (" 📋 Kopiera ", Style::default()),
|
||||
ContextMenuItem::Cut => (" ✂ Klipp ut ", Style::default()),
|
||||
ContextMenuItem::Paste => (" 📌 Klistra in ", Style::default()),
|
||||
ContextMenuItem::Rename => (" ✏ Byt namn ", Style::default()),
|
||||
ContextMenuItem::Properties => (" ℹ Egenskaper ", Style::default()),
|
||||
ContextMenuItem::Delete => (" 🗑 Ta bort ", Style::default().fg(Color::Red)),
|
||||
ContextMenuItem::NewSubmenu => {
|
||||
if is_sel { show_new_sub = true; new_sub_row = inner.y + i as u16; }
|
||||
(" ➕ Ny ▶ ", Style::default().fg(Color::Green))
|
||||
}
|
||||
ContextMenuItem::AddFavorite => (" ★ Lägg till fav ", Style::default().fg(Color::Yellow)),
|
||||
ContextMenuItem::RemoveFavorite(_) => (" ✖ Ta bort favorit ", Style::default().fg(Color::Red)),
|
||||
ContextMenuItem::Separator => (" ──────────────── ", Style::default().fg(Color::DarkGray)),
|
||||
ContextMenuItem::NewFile => (" 📄 Ny fil ", Style::default().fg(Color::Green)),
|
||||
ContextMenuItem::NewDir => (" 📁 Ny mapp ", Style::default().fg(Color::Green)),
|
||||
};
|
||||
|
||||
let bg = if is_sel && !matches!(item, ContextMenuItem::Separator) {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
style
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(text).style(bg),
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw submenu for "Ny" if selected
|
||||
if show_new_sub {
|
||||
let sub_x = x + menu_width;
|
||||
let sub_area = Rect::new(sub_x.min(term_area.width.saturating_sub(18)), new_sub_row, 18, 4);
|
||||
f.render_widget(Clear, sub_area);
|
||||
|
||||
let sub_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Green));
|
||||
let sub_inner = sub_block.inner(sub_area);
|
||||
f.render_widget(sub_block, sub_area);
|
||||
|
||||
let sub_item0_style = if menu.sub_selected == Some(0) {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let sub_item1_style = if menu.sub_selected == Some(1) {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(" 📄 Ny fil ").style(sub_item0_style),
|
||||
Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1),
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(" 📁 Ny mapp ").style(sub_item1_style),
|
||||
Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) {
|
||||
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||
let dlg_height = 5u16;
|
||||
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||
|
||||
f.render_widget(Clear, dlg);
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = block.inner(dlg);
|
||||
f.render_widget(block, dlg);
|
||||
|
||||
// Input field with cursor
|
||||
let display = format!("{}_", current);
|
||||
f.render_widget(
|
||||
Paragraph::new(display)
|
||||
.style(Style::default().fg(Color::White)),
|
||||
Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new("Enter=bekräfta Esc=avbryt")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect) {
|
||||
let dlg_width = 56u16.min(area.width.saturating_sub(4));
|
||||
let dlg_height = 13u16;
|
||||
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||
|
||||
f.render_widget(Clear, dlg);
|
||||
let block = Block::default()
|
||||
.title(" Egenskaper ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = block.inner(dlg);
|
||||
f.render_widget(block, dlg);
|
||||
|
||||
let label_style = Style::default().fg(Color::Yellow);
|
||||
let value_style = Style::default().fg(Color::White);
|
||||
let hint_style = Style::default().fg(Color::DarkGray);
|
||||
let label_w = 14usize;
|
||||
let val_w = (inner.width as usize).saturating_sub(label_w + 2);
|
||||
|
||||
let rows: &[(&str, String)] = &[
|
||||
("Namn", info.name.clone()),
|
||||
("Sökväg", truncate_str(&info.path, val_w)),
|
||||
("Storlek", format_size(info.size).to_string()),
|
||||
("Filer", if info.is_multi || info.file_count > 0 { info.file_count.to_string() } else { String::from("-") }),
|
||||
("Rättigheter", info.permissions.clone()),
|
||||
("Ägare", info.owner_user.clone()),
|
||||
("Grupp", info.owner_group.clone()),
|
||||
];
|
||||
|
||||
for (i, (label, value)) in rows.iter().enumerate() {
|
||||
if i as u16 + 1 >= inner.height { break; }
|
||||
let row_y = inner.y + i as u16;
|
||||
// Label
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{:<width$}", label, width = label_w)).style(label_style),
|
||||
Rect::new(inner.x + 1, row_y, label_w as u16, 1),
|
||||
);
|
||||
// Value
|
||||
f.render_widget(
|
||||
Paragraph::new(value.as_str()).style(value_style),
|
||||
Rect::new(inner.x + 1 + label_w as u16, row_y, val_w as u16, 1),
|
||||
);
|
||||
}
|
||||
|
||||
// Hint at the bottom
|
||||
let hint_y = inner.y + inner.height.saturating_sub(1);
|
||||
f.render_widget(
|
||||
Paragraph::new("Stäng: Enter / Esc").style(hint_style),
|
||||
Rect::new(inner.x + 1, hint_y, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_error_dialog(f: &mut Frame, msg: &str, area: Rect) {
|
||||
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||
let dlg_height = 5u16;
|
||||
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||
|
||||
f.render_widget(Clear, dlg);
|
||||
let block = Block::default()
|
||||
.title(" Fel ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Red));
|
||||
let inner = block.inner(dlg);
|
||||
f.render_widget(block, dlg);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(msg).style(Style::default().fg(Color::Red)),
|
||||
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new("Stäng: Enter / Esc").style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_confirm_dialog(f: &mut Frame, msg: &str, area: Rect) {
|
||||
let dlg_width = 50u16.min(area.width.saturating_sub(4));
|
||||
let dlg_height = 5u16;
|
||||
let x = (area.width.saturating_sub(dlg_width)) / 2;
|
||||
let y = (area.height.saturating_sub(dlg_height)) / 2;
|
||||
let dlg = Rect::new(x, y, dlg_width, dlg_height);
|
||||
|
||||
f.render_widget(Clear, dlg);
|
||||
let block = Block::default()
|
||||
.title(" Bekräfta ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
let inner = block.inner(dlg);
|
||||
f.render_widget(block, dlg);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(msg).style(Style::default().fg(Color::White)),
|
||||
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new("y=ja Annan tangent=avbryt").style(Style::default().fg(Color::DarkGray)),
|
||||
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else if max <= 3 {
|
||||
s[..max].to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user