From 298001dc15a48c1370127495401c1783a0d43ab7 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Wed, 25 Dec 2024 12:14:55 +0000 Subject: [PATCH] feat: Support entry renaming Signed-off-by: Awiteb --- .../key_event_matcher/collapse_dir.rs | 2 +- .../key_event_matcher/expand_dir.rs | 2 +- src/controller/key_event_matcher/mod.rs | 46 ++++++++++++++----- src/controller/key_event_matcher/modes.rs | 26 +++++++++++ src/controller/key_event_matcher/reload.rs | 2 +- src/controller/key_event_matcher/rename.rs | 46 +++++++++++++++++++ src/controller/mod.rs | 28 +++++++++-- src/model/config/keybinding.rs | 21 ++++----- src/model/event.rs | 16 +++++++ src/model/path_node/debug.rs | 2 +- src/model/path_node/mod.rs | 12 +++-- src/utils.rs | 8 ++-- src/view/composer.rs | 25 +++++----- src/view/mod.rs | 8 ++++ src/view/mode.rs | 21 +++++++++ src/view/update.rs | 36 ++++++++++----- 16 files changed, 235 insertions(+), 66 deletions(-) create mode 100644 src/controller/key_event_matcher/modes.rs create mode 100644 src/controller/key_event_matcher/rename.rs create mode 100644 src/view/mode.rs diff --git a/src/controller/key_event_matcher/collapse_dir.rs b/src/controller/key_event_matcher/collapse_dir.rs index 6ca1996..70b11a3 100644 --- a/src/controller/key_event_matcher/collapse_dir.rs +++ b/src/controller/key_event_matcher/collapse_dir.rs @@ -17,7 +17,7 @@ impl EventQueue { self.path_node_root.collapse_dir(&tree_index); } - self.text_entries = self.composer.compose_path_node(&self.path_node_root); + self.entries = self.composer.compose_path_node(&self.path_node_root); self.update_pager(cursor_delta); Some(()) diff --git a/src/controller/key_event_matcher/expand_dir.rs b/src/controller/key_event_matcher/expand_dir.rs index ed8d1ac..0293632 100644 --- a/src/controller/key_event_matcher/expand_dir.rs +++ b/src/controller/key_event_matcher/expand_dir.rs @@ -11,7 +11,7 @@ impl EventQueue { .flat_index_to_tree_index(self.pager.cursor_row as usize); self.path_node_root .expand_dir(&tree_index, self.path_node_compare); - self.text_entries = self.composer.compose_path_node(&self.path_node_root); + self.entries = self.composer.compose_path_node(&self.path_node_root); self.update_pager(0); Some(()) diff --git a/src/controller/key_event_matcher/mod.rs b/src/controller/key_event_matcher/mod.rs index 4892ea8..70bcae2 100644 --- a/src/controller/key_event_matcher/mod.rs +++ b/src/controller/key_event_matcher/mod.rs @@ -4,6 +4,9 @@ use crate::controller::EventQueue; use crate::model::event::Key; +use crate::view::Mode; + +use std::borrow::Cow; use std::io::Write; mod collapse_dir; @@ -11,28 +14,49 @@ mod entry_down; mod entry_up; mod expand_dir; mod file_action; +mod modes; mod quit; mod reload; +mod rename; impl EventQueue { - #[rustfmt::skip] pub fn match_key_event(&mut self, key: Key) -> Option<()> { - let ck = self.config.keybinding.clone(); + let ck = &self.config.keybinding; - if key == Key::from(ck.collapse_dir) { self.do_collapse_dir() } - else if key == Key::from(ck.entry_down) { self.do_entry_down() } - else if key == Key::from(ck.entry_up) { self.do_entry_up() } - else if key == Key::from(ck.expand_dir) { self.do_expand_dir() } - else if key == Key::from(ck.file_action) { self.do_file_action() } - else if key == Key::from(ck.quit) { self.do_quit() } - else if key == Key::from(ck.reload) { self.do_reload() } - else { Some(()) } + match (self.pager.mode.clone(), key) { + (Mode::Normal, k) if k == Key::from(&ck.collapse_dir) => self.do_collapse_dir(), + (Mode::Normal, k) if k == Key::from(&ck.entry_down) => self.do_entry_down(), + (Mode::Normal, k) if k == Key::from(&ck.entry_up) => self.do_entry_up(), + (Mode::Normal, k) if k == Key::from(&ck.expand_dir) => self.do_expand_dir(), + (Mode::Normal, k) if k == Key::from(&ck.file_action) => self.do_file_action(), + (Mode::Normal, k) if k == Key::from(&ck.quit) => self.do_quit(), + (Mode::Normal, k) if k == Key::from(&ck.reload) => self.do_reload(), + (Mode::Normal, k) if k == Key::from(&ck.rename_mode) => self.do_enter_rename_mode(), + (Mode::Rename(..), k) if k == Key::from(&ck.quit) => self.do_enter_normal_mode(), + (Mode::Rename(new_name), k) if k == Key::from("return") => { + if !new_name.chars().all(char::is_whitespace) { + self.do_rename_current_file(&new_name); + self.do_reload(); + } + self.do_enter_normal_mode(); + Some(()) + } + (Mode::Rename(new_name), k) => { + if let Cow::Owned(updated_name) = self.do_handle_rename_input(&new_name, k.inner()) + { + self.pager.mode = Mode::Rename(updated_name); + self.update_pager(0); + } + Some(()) + } + _ => Some(()), + } } fn update_pager(&mut self, cursor_delta: i32) { self.pager.update( cursor_delta, - &self.text_entries, + &self.entries, self.config .setup .with_cwd_header diff --git a/src/controller/key_event_matcher/modes.rs b/src/controller/key_event_matcher/modes.rs new file mode 100644 index 0000000..975e0ef --- /dev/null +++ b/src/controller/key_event_matcher/modes.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 Awiteb + +use std::io::Write; + +use crate::{controller::EventQueue, view::Mode}; + +impl EventQueue { + pub fn do_enter_normal_mode(&mut self) -> Option<()> { + self.pager.mode = Mode::Normal; + self.update_pager(0); + Some(()) + } + pub fn do_enter_rename_mode(&mut self) -> Option<()> { + self.pager.mode = Mode::Rename( + self.pager + .current_entry + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + ); + self.update_pager(0); + Some(()) + } +} diff --git a/src/controller/key_event_matcher/reload.rs b/src/controller/key_event_matcher/reload.rs index ed2ccc6..55017ab 100644 --- a/src/controller/key_event_matcher/reload.rs +++ b/src/controller/key_event_matcher/reload.rs @@ -11,7 +11,7 @@ impl EventQueue { pub fn do_reload(&mut self) -> Option<()> { self.reload_openend_dirs(); - self.text_entries = self.composer.compose_path_node(&self.path_node_root); + self.entries = self.composer.compose_path_node(&self.path_node_root); self.update_pager(0); diff --git a/src/controller/key_event_matcher/rename.rs b/src/controller/key_event_matcher/rename.rs new file mode 100644 index 0000000..425465d --- /dev/null +++ b/src/controller/key_event_matcher/rename.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 Awiteb + +use std::{borrow::Cow, fs, io::Write}; + +use termion::event::Key as TKey; + +use crate::controller::EventQueue; + +impl EventQueue { + /// Remove the last character from the string. + fn pop_char(name: &str) -> Cow<'_, str> { + if name.is_empty() { + return Cow::Borrowed(name); + } + let mut chars = name.chars(); + chars.next_back(); + Cow::Owned(chars.collect()) + } + + /// Add a character to the string, if it is not a control character. + fn push_char<'a>(name: &'a str, chr: &char) -> Cow<'a, str> { + if chr.is_control() { + return Cow::Borrowed(name); + } + Cow::Owned(format!("{name}{chr}")) + } + + /// Rename the current file to the new name. + pub fn do_rename_current_file(&mut self, new_name: &str) -> Option<()> { + let new_path = self.pager.current_entry.with_file_name(new_name); + if !new_path.exists() { + let _ = fs::rename(&self.pager.current_entry, new_path); + }; + Some(()) + } + + /// Handle the rename input, and return the new name. + pub fn do_handle_rename_input<'a>(&mut self, new_name: &'a str, key: &TKey) -> Cow<'a, str> { + match key { + TKey::Backspace => return Self::pop_char(new_name), + TKey::Char(chr) => Self::push_char(new_name, chr), + _ => Cow::Borrowed(new_name), + } + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 29cb165..1f0ccdd 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -12,6 +12,8 @@ use crate::view::composer::Composer; use crate::view::Pager; use log::info; use std::io::Write; +use std::path::Path; +use std::path::PathBuf; use std::sync::mpsc::sync_channel; use std::sync::mpsc::Receiver; use std::sync::mpsc::SyncSender; @@ -21,6 +23,12 @@ mod key_event_handler; mod key_event_matcher; mod resize_event_handler; +/// Entrie, a struct that represents a file or directory. +pub struct Entrie { + pub path: PathBuf, + pub display_text: String, +} + pub struct EventQueue { config: Config, composer: Composer, @@ -31,7 +39,7 @@ pub struct EventQueue { queue_sender: SyncSender, // TODO: should be part of the view? - text_entries: Vec, + entries: Vec, command_to_run_on_exit: Option, } @@ -47,10 +55,10 @@ impl EventQueue { let (queue_sender, queue_receiver): (SyncSender, Receiver) = sync_channel(1024); let path_node_compare = PathNode::get_path_node_compare(&config); - let text_entries = composer.compose_path_node(&path_node_root); + let entries = composer.compose_path_node(&path_node_root); pager.update( 0, - &text_entries, + &entries, config .setup .with_cwd_header @@ -66,7 +74,7 @@ impl EventQueue { path_node_compare, queue_receiver, queue_sender, - text_entries, + entries, command_to_run_on_exit, } } @@ -94,7 +102,7 @@ impl EventQueue { Event::Resize => { self.pager.update( 0, - &self.text_entries, + &self.entries, self.config .setup .with_cwd_header @@ -105,3 +113,13 @@ impl EventQueue { } } } + +impl Entrie { + /// Create a new `Entrie`. + pub fn new(path: impl AsRef, display_text: impl Into) -> Self { + Self { + path: path.as_ref().to_path_buf(), + display_text: display_text.into(), + } + } +} diff --git a/src/model/config/keybinding.rs b/src/model/config/keybinding.rs index bfbbff3..bc0a90d 100644 --- a/src/model/config/keybinding.rs +++ b/src/model/config/keybinding.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb use serde::Deserialize; @@ -7,24 +8,20 @@ use serde::Deserialize; pub struct Keybinding { #[serde(default = "Keybinding::default_quit")] pub quit: String, - #[serde(default = "Keybinding::default_entry_up")] pub entry_up: String, - #[serde(default = "Keybinding::default_entry_down")] pub entry_down: String, - #[serde(default = "Keybinding::default_expand_dir")] pub expand_dir: String, - #[serde(default = "Keybinding::default_collapse_dir")] pub collapse_dir: String, - #[serde(default = "Keybinding::default_file_action")] pub file_action: String, - #[serde(default = "Keybinding::default_reload")] pub reload: String, + #[serde(default = "Keybinding::default_rename_mode")] + pub rename_mode: String, } impl Default for Keybinding { @@ -37,36 +34,34 @@ impl Default for Keybinding { collapse_dir: Self::default_collapse_dir(), file_action: Self::default_file_action(), reload: Self::default_reload(), + rename_mode: Self::default_rename_mode(), } } } impl Keybinding { fn default_quit() -> String { - String::from("q") + String::from("ctrl+q") } - fn default_entry_up() -> String { String::from("up") } - fn default_entry_down() -> String { String::from("down") } - fn default_expand_dir() -> String { String::from("right") } - fn default_collapse_dir() -> String { String::from("left") } - fn default_file_action() -> String { String::from("return") } - fn default_reload() -> String { + String::from("ctrl+r") + } + fn default_rename_mode() -> String { String::from("r") } } diff --git a/src/model/event.rs b/src/model/event.rs index c26c281..26a7dd1 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -33,6 +33,22 @@ impl From for Key { } } +impl From<&String> for Key { + fn from(s: &String) -> Key { + Key::from(convert_str_to_termion_event(s)) + } +} + +impl Key { + /// Returns the inner termion key event. + pub fn inner(&self) -> &TKey { + match &self.inner { + termion::event::Event::Key(tkey) => tkey, + _ => unreachable!(), + } + } +} + fn convert_str_to_termion_event(s: &str) -> TEvent { if s.chars().count() == 1 { return TEvent::Key(TKey::Char(s.chars().last().unwrap())); diff --git a/src/model/path_node/debug.rs b/src/model/path_node/debug.rs index c81ed9d..c8ae2c8 100644 --- a/src/model/path_node/debug.rs +++ b/src/model/path_node/debug.rs @@ -15,7 +15,7 @@ impl Debug for PathNode { let entries = composer.compose_path_node(self); for (index, entry) in entries.iter().enumerate() { - writeln!(f, "{:4}|{}", index, entry)?; + writeln!(f, "{:4}|{}", index, entry.display_text)?; } Ok(()) diff --git a/src/model/path_node/mod.rs b/src/model/path_node/mod.rs index fd07898..dd0c631 100644 --- a/src/model/path_node/mod.rs +++ b/src/model/path_node/mod.rs @@ -20,7 +20,7 @@ pub struct PathNode { pub is_err: bool, pub is_expanded: bool, pub path: PathBuf, - pub gitignore: Vec, + pub gitignore: Vec, } impl From for PathNode @@ -28,9 +28,11 @@ where T: AsRef, { fn from(working_dir: T) -> Self { - let mut gitignore = - utils::parse_gitignore(Path::new(working_dir.as_ref()).join(".gitignore")); - gitignore.push(working_dir.as_ref().join(".git").display().to_string()); + let mut gitignore = utils::parse_gitignore( + working_dir.as_ref(), + Path::new(working_dir.as_ref()).join(".gitignore"), + ); + gitignore.push(working_dir.as_ref().join(".git")); Self { children: Vec::new(), display_text: working_dir.as_ref().display().to_string(), @@ -72,7 +74,7 @@ impl PathNode { .filter_map(|dir_entry| { let dir_entry = dir_entry.unwrap(); let entry_name = dir_entry.file_name().into_string().unwrap(); - (!self.gitignore.contains(&entry_name)).then(|| PathNode { + (!self.gitignore.contains(&dir_entry.path())).then(|| PathNode { children: Vec::new(), display_text: entry_name, is_dir: dir_entry.path().is_dir(), diff --git a/src/utils.rs b/src/utils.rs index 6d8463a..5bca27f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,7 +5,7 @@ use log::info; use std::fs; use std::panic::set_hook; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::exit; pub fn print_help() { @@ -63,13 +63,13 @@ pub fn get_config_dir() -> std::io::Result { } } -pub fn parse_gitignore(gitignore_path: impl AsRef) -> Vec { - if Path::exists(gitignore_path.as_ref()) { +pub fn parse_gitignore(root: impl AsRef, gitignore_path: impl AsRef) -> Vec { + if gitignore_path.as_ref().exists() { fs::read_to_string(&gitignore_path) .map(|content| { content .split('\n') - .map(|path| path.trim_matches('/').to_owned()) + .map(|path| root.as_ref().join(path.trim_matches('/'))) .collect() }) .unwrap_or_default() diff --git a/src/view/composer.rs b/src/view/composer.rs index 134edcd..38e4e3e 100644 --- a/src/view/composer.rs +++ b/src/view/composer.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb +use crate::controller::Entrie; use crate::model::config::Config; use crate::model::path_node::PathNode; use log::info; @@ -34,7 +36,7 @@ impl Composer { format!("{}~", truncated) } - pub fn compose_path_node(&self, path_node: &PathNode) -> Vec { + pub fn compose_path_node(&self, path_node: &PathNode) -> Vec { let mut result = Vec::new(); self.compose_path_node_recursive(path_node, &mut result, 0); @@ -45,7 +47,7 @@ impl Composer { fn compose_path_node_recursive( &self, path_node: &PathNode, - texts: &mut Vec, + entries: &mut Vec, depth: usize, ) { for child in &path_node.children { @@ -53,15 +55,12 @@ impl Composer { let dir_suffix = self.get_dir_suffix(child); let indent = self.get_indent(depth); - let text = format!( - "{}{}{}{}", - indent, - dir_prefix, - child.display_text.clone(), - dir_suffix, + let display_text = format!( + "{indent} {dir_prefix} {name}{dir_suffix}", + name = child.display_text ); - texts.push(text); - self.compose_path_node_recursive(child, texts, depth + 1); + entries.push(Entrie::new(&child.path, display_text)); + self.compose_path_node_recursive(child, entries, depth + 1); } } @@ -96,16 +95,16 @@ impl Composer { } fn get_indent(&self, depth: usize) -> String { - let indent_char = if !self.config.composition.show_indent { + let indent = " ".repeat(self.config.composition.indent as usize - 1); + let char_indent = if !self.config.composition.show_indent { ' ' } else if self.config.composition.use_utf8 { 'ยท' } else { '-' }; - let indent = " ".repeat(self.config.composition.indent as usize - 1); - format!("{}{}", indent_char, indent).repeat(depth) + format!("{char_indent}{indent}").repeat(depth) } } diff --git a/src/view/mod.rs b/src/view/mod.rs index f5cd69b..83c3afa 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,19 +1,25 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb use crate::model::config::Config; use crate::view::composer::Composer; use log::info; +pub use mode::Mode; use std::io::Write; +use std::path::{Path, PathBuf}; pub mod composer; +mod mode; mod print; mod scroll; mod update; pub struct Pager { config: Config, + pub mode: Mode, pub cursor_row: i32, + pub current_entry: PathBuf, out: W, terminal_cols: i32, terminal_rows: i32, @@ -34,12 +40,14 @@ impl Pager { .unwrap(); Self { + current_entry: Path::new(&config.setup.working_dir).to_path_buf(), config, cursor_row: 0, out, terminal_cols: 0, terminal_rows: 0, text_row: 0, + mode: Mode::Normal, } } } diff --git a/src/view/mode.rs b/src/view/mode.rs new file mode 100644 index 0000000..037eed1 --- /dev/null +++ b/src/view/mode.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 Awiteb + +/// Mode of the pager. +#[derive(Clone)] +pub enum Mode { + /// Normal mode. Exploring the file system. + Normal, + /// Rename mode. Renaming a file. + Rename(String), +} + +impl Mode { + /// Returns the new name of the file, if in rename mode. + pub fn new_name(&self) -> Option<&str> { + match &self { + Mode::Rename(new_name) => Some(new_name), + _ => None, + } + } +} diff --git a/src/view/update.rs b/src/view/update.rs index 1933e53..1bc3c6c 100644 --- a/src/view/update.rs +++ b/src/view/update.rs @@ -2,8 +2,8 @@ // Copyright (c) 2019-2022 golmman // Copyright (c) 2024 Awiteb -use crate::view::Pager; -use std::io::Write; +use crate::{controller::Entrie, view::Pager}; +use std::{io::Write, path}; use termion::terminal_size; impl Pager { @@ -26,7 +26,7 @@ impl Pager { pub fn update( &mut self, cursor_row_delta: i32, - text_entries: &[String], + entries: &[Entrie], header_text: Option, ) { self.update_terminal_size(); @@ -34,12 +34,12 @@ impl Pager { let spacing_bot = self.config.debug.spacing_bot; let spacing_top = self.config.debug.spacing_top; - let text_entries_len = text_entries.len() as i32; + let entries_len = entries.len() as i32; - self.update_cursor_row(cursor_row_delta, text_entries_len); + self.update_cursor_row(cursor_row_delta, entries_len); self.text_row = match self.config.behavior.scrolling.as_str() { - "center" => self.scroll_like_center(cursor_row_delta, text_entries_len), + "center" => self.scroll_like_center(cursor_row_delta, entries_len), "editor" => self.scroll_like_editor(), _ => 0, }; @@ -55,18 +55,32 @@ impl Pager { for i in 0..displayable_rows { let index = first_index + i; - if index >= 0 && index < text_entries.len() as i32 { - let text_entry = &text_entries[index as usize]; + if index >= 0 && index < entries.len() as i32 { + let entry = &entries[index as usize]; if index == self.cursor_row { - self.print_text_entry_emphasized(text_entry, 1 + spacing_top + i) + self.current_entry = + path::absolute(&entry.path).unwrap_or_else(|_| entry.path.clone()); + + let rename_name = self.mode.new_name().map(|new_name| { + let filename = entry.path.file_name().unwrap().to_str().unwrap(); + let mut new_display_text = entry.display_text.chars(); + for _ in 0..(filename.chars().count() + entry.path.is_dir() as usize) { + new_display_text.next_back(); + } + format!("{}{new_name}", new_display_text.collect::()) + }); + self.print_text_entry_emphasized( + rename_name.as_ref().unwrap_or(&entry.display_text), + 1 + spacing_top + i, + ) } else { - self.print_text_entry(text_entry, 1 + spacing_top + i); + self.print_text_entry(&entry.display_text, 1 + spacing_top + i); } } } - let footer_text = format!("[{}/{}]", self.cursor_row + 1, text_entries_len); + let footer_text = format!("[{}/{}]", self.cursor_row + 1, entries_len); if let Some(header_text) = header_text.as_ref() { self.print_header(header_text);