feat: Support entry renaming

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb 2024-12-25 12:14:55 +00:00
parent ffafb7de6a
commit 298001dc15
Signed by: awiteb
GPG key ID: 3F6B55640AA6682F
16 changed files with 235 additions and 66 deletions

View file

@ -17,7 +17,7 @@ impl<W: Write> EventQueue<W> {
self.path_node_root.collapse_dir(&tree_index); 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); self.update_pager(cursor_delta);
Some(()) Some(())

View file

@ -11,7 +11,7 @@ impl<W: Write> EventQueue<W> {
.flat_index_to_tree_index(self.pager.cursor_row as usize); .flat_index_to_tree_index(self.pager.cursor_row as usize);
self.path_node_root self.path_node_root
.expand_dir(&tree_index, self.path_node_compare); .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); self.update_pager(0);
Some(()) Some(())

View file

@ -4,6 +4,9 @@
use crate::controller::EventQueue; use crate::controller::EventQueue;
use crate::model::event::Key; use crate::model::event::Key;
use crate::view::Mode;
use std::borrow::Cow;
use std::io::Write; use std::io::Write;
mod collapse_dir; mod collapse_dir;
@ -11,28 +14,49 @@ mod entry_down;
mod entry_up; mod entry_up;
mod expand_dir; mod expand_dir;
mod file_action; mod file_action;
mod modes;
mod quit; mod quit;
mod reload; mod reload;
mod rename;
impl<W: Write> EventQueue<W> { impl<W: Write> EventQueue<W> {
#[rustfmt::skip]
pub fn match_key_event(&mut self, key: Key) -> Option<()> { 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() } match (self.pager.mode.clone(), key) {
else if key == Key::from(ck.entry_down) { self.do_entry_down() } (Mode::Normal, k) if k == Key::from(&ck.collapse_dir) => self.do_collapse_dir(),
else if key == Key::from(ck.entry_up) { self.do_entry_up() } (Mode::Normal, k) if k == Key::from(&ck.entry_down) => self.do_entry_down(),
else if key == Key::from(ck.expand_dir) { self.do_expand_dir() } (Mode::Normal, k) if k == Key::from(&ck.entry_up) => self.do_entry_up(),
else if key == Key::from(ck.file_action) { self.do_file_action() } (Mode::Normal, k) if k == Key::from(&ck.expand_dir) => self.do_expand_dir(),
else if key == Key::from(ck.quit) { self.do_quit() } (Mode::Normal, k) if k == Key::from(&ck.file_action) => self.do_file_action(),
else if key == Key::from(ck.reload) { self.do_reload() } (Mode::Normal, k) if k == Key::from(&ck.quit) => self.do_quit(),
else { Some(()) } (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) { fn update_pager(&mut self, cursor_delta: i32) {
self.pager.update( self.pager.update(
cursor_delta, cursor_delta,
&self.text_entries, &self.entries,
self.config self.config
.setup .setup
.with_cwd_header .with_cwd_header

View file

@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2024 Awiteb <a@4rs.nl>
use std::io::Write;
use crate::{controller::EventQueue, view::Mode};
impl<W: Write> EventQueue<W> {
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(())
}
}

View file

@ -11,7 +11,7 @@ impl<W: Write> EventQueue<W> {
pub fn do_reload(&mut self) -> Option<()> { pub fn do_reload(&mut self) -> Option<()> {
self.reload_openend_dirs(); 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); self.update_pager(0);

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2024 Awiteb <a@4rs.nl>
use std::{borrow::Cow, fs, io::Write};
use termion::event::Key as TKey;
use crate::controller::EventQueue;
impl<W: Write> EventQueue<W> {
/// 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),
}
}
}

View file

@ -12,6 +12,8 @@ use crate::view::composer::Composer;
use crate::view::Pager; use crate::view::Pager;
use log::info; use log::info;
use std::io::Write; use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::mpsc::sync_channel; use std::sync::mpsc::sync_channel;
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
use std::sync::mpsc::SyncSender; use std::sync::mpsc::SyncSender;
@ -21,6 +23,12 @@ mod key_event_handler;
mod key_event_matcher; mod key_event_matcher;
mod resize_event_handler; 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<W: Write> { pub struct EventQueue<W: Write> {
config: Config, config: Config,
composer: Composer, composer: Composer,
@ -31,7 +39,7 @@ pub struct EventQueue<W: Write> {
queue_sender: SyncSender<Event>, queue_sender: SyncSender<Event>,
// TODO: should be part of the view? // TODO: should be part of the view?
text_entries: Vec<String>, entries: Vec<Entrie>,
command_to_run_on_exit: Option<String>, command_to_run_on_exit: Option<String>,
} }
@ -47,10 +55,10 @@ impl<W: Write> EventQueue<W> {
let (queue_sender, queue_receiver): (SyncSender<Event>, Receiver<Event>) = let (queue_sender, queue_receiver): (SyncSender<Event>, Receiver<Event>) =
sync_channel(1024); sync_channel(1024);
let path_node_compare = PathNode::get_path_node_compare(&config); 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( pager.update(
0, 0,
&text_entries, &entries,
config config
.setup .setup
.with_cwd_header .with_cwd_header
@ -66,7 +74,7 @@ impl<W: Write> EventQueue<W> {
path_node_compare, path_node_compare,
queue_receiver, queue_receiver,
queue_sender, queue_sender,
text_entries, entries,
command_to_run_on_exit, command_to_run_on_exit,
} }
} }
@ -94,7 +102,7 @@ impl<W: Write> EventQueue<W> {
Event::Resize => { Event::Resize => {
self.pager.update( self.pager.update(
0, 0,
&self.text_entries, &self.entries,
self.config self.config
.setup .setup
.with_cwd_header .with_cwd_header
@ -105,3 +113,13 @@ impl<W: Write> EventQueue<W> {
} }
} }
} }
impl Entrie {
/// Create a new `Entrie`.
pub fn new(path: impl AsRef<Path>, display_text: impl Into<String>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
display_text: display_text.into(),
}
}
}

View file

@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// Copyright (c) 2019-2022 golmman // Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
use serde::Deserialize; use serde::Deserialize;
@ -7,24 +8,20 @@ use serde::Deserialize;
pub struct Keybinding { pub struct Keybinding {
#[serde(default = "Keybinding::default_quit")] #[serde(default = "Keybinding::default_quit")]
pub quit: String, pub quit: String,
#[serde(default = "Keybinding::default_entry_up")] #[serde(default = "Keybinding::default_entry_up")]
pub entry_up: String, pub entry_up: String,
#[serde(default = "Keybinding::default_entry_down")] #[serde(default = "Keybinding::default_entry_down")]
pub entry_down: String, pub entry_down: String,
#[serde(default = "Keybinding::default_expand_dir")] #[serde(default = "Keybinding::default_expand_dir")]
pub expand_dir: String, pub expand_dir: String,
#[serde(default = "Keybinding::default_collapse_dir")] #[serde(default = "Keybinding::default_collapse_dir")]
pub collapse_dir: String, pub collapse_dir: String,
#[serde(default = "Keybinding::default_file_action")] #[serde(default = "Keybinding::default_file_action")]
pub file_action: String, pub file_action: String,
#[serde(default = "Keybinding::default_reload")] #[serde(default = "Keybinding::default_reload")]
pub reload: String, pub reload: String,
#[serde(default = "Keybinding::default_rename_mode")]
pub rename_mode: String,
} }
impl Default for Keybinding { impl Default for Keybinding {
@ -37,36 +34,34 @@ impl Default for Keybinding {
collapse_dir: Self::default_collapse_dir(), collapse_dir: Self::default_collapse_dir(),
file_action: Self::default_file_action(), file_action: Self::default_file_action(),
reload: Self::default_reload(), reload: Self::default_reload(),
rename_mode: Self::default_rename_mode(),
} }
} }
} }
impl Keybinding { impl Keybinding {
fn default_quit() -> String { fn default_quit() -> String {
String::from("q") String::from("ctrl+q")
} }
fn default_entry_up() -> String { fn default_entry_up() -> String {
String::from("up") String::from("up")
} }
fn default_entry_down() -> String { fn default_entry_down() -> String {
String::from("down") String::from("down")
} }
fn default_expand_dir() -> String { fn default_expand_dir() -> String {
String::from("right") String::from("right")
} }
fn default_collapse_dir() -> String { fn default_collapse_dir() -> String {
String::from("left") String::from("left")
} }
fn default_file_action() -> String { fn default_file_action() -> String {
String::from("return") String::from("return")
} }
fn default_reload() -> String { fn default_reload() -> String {
String::from("ctrl+r")
}
fn default_rename_mode() -> String {
String::from("r") String::from("r")
} }
} }

View file

@ -33,6 +33,22 @@ impl From<String> 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 { fn convert_str_to_termion_event(s: &str) -> TEvent {
if s.chars().count() == 1 { if s.chars().count() == 1 {
return TEvent::Key(TKey::Char(s.chars().last().unwrap())); return TEvent::Key(TKey::Char(s.chars().last().unwrap()));

View file

@ -15,7 +15,7 @@ impl Debug for PathNode {
let entries = composer.compose_path_node(self); let entries = composer.compose_path_node(self);
for (index, entry) in entries.iter().enumerate() { for (index, entry) in entries.iter().enumerate() {
writeln!(f, "{:4}|{}", index, entry)?; writeln!(f, "{:4}|{}", index, entry.display_text)?;
} }
Ok(()) Ok(())

View file

@ -20,7 +20,7 @@ pub struct PathNode {
pub is_err: bool, pub is_err: bool,
pub is_expanded: bool, pub is_expanded: bool,
pub path: PathBuf, pub path: PathBuf,
pub gitignore: Vec<String>, pub gitignore: Vec<PathBuf>,
} }
impl<T> From<T> for PathNode impl<T> From<T> for PathNode
@ -28,9 +28,11 @@ where
T: AsRef<Path>, T: AsRef<Path>,
{ {
fn from(working_dir: T) -> Self { fn from(working_dir: T) -> Self {
let mut gitignore = let mut gitignore = utils::parse_gitignore(
utils::parse_gitignore(Path::new(working_dir.as_ref()).join(".gitignore")); working_dir.as_ref(),
gitignore.push(working_dir.as_ref().join(".git").display().to_string()); Path::new(working_dir.as_ref()).join(".gitignore"),
);
gitignore.push(working_dir.as_ref().join(".git"));
Self { Self {
children: Vec::new(), children: Vec::new(),
display_text: working_dir.as_ref().display().to_string(), display_text: working_dir.as_ref().display().to_string(),
@ -72,7 +74,7 @@ impl PathNode {
.filter_map(|dir_entry| { .filter_map(|dir_entry| {
let dir_entry = dir_entry.unwrap(); let dir_entry = dir_entry.unwrap();
let entry_name = dir_entry.file_name().into_string().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(), children: Vec::new(),
display_text: entry_name, display_text: entry_name,
is_dir: dir_entry.path().is_dir(), is_dir: dir_entry.path().is_dir(),

View file

@ -5,7 +5,7 @@
use log::info; use log::info;
use std::fs; use std::fs;
use std::panic::set_hook; use std::panic::set_hook;
use std::path::Path; use std::path::{Path, PathBuf};
use std::process::exit; use std::process::exit;
pub fn print_help() { pub fn print_help() {
@ -63,13 +63,13 @@ pub fn get_config_dir() -> std::io::Result<String> {
} }
} }
pub fn parse_gitignore(gitignore_path: impl AsRef<Path>) -> Vec<String> { pub fn parse_gitignore(root: impl AsRef<Path>, gitignore_path: impl AsRef<Path>) -> Vec<PathBuf> {
if Path::exists(gitignore_path.as_ref()) { if gitignore_path.as_ref().exists() {
fs::read_to_string(&gitignore_path) fs::read_to_string(&gitignore_path)
.map(|content| { .map(|content| {
content content
.split('\n') .split('\n')
.map(|path| path.trim_matches('/').to_owned()) .map(|path| root.as_ref().join(path.trim_matches('/')))
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default()

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// Copyright (c) 2019-2022 golmman // Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
use crate::controller::Entrie;
use crate::model::config::Config; use crate::model::config::Config;
use crate::model::path_node::PathNode; use crate::model::path_node::PathNode;
use log::info; use log::info;
@ -34,7 +36,7 @@ impl Composer {
format!("{}~", truncated) format!("{}~", truncated)
} }
pub fn compose_path_node(&self, path_node: &PathNode) -> Vec<String> { pub fn compose_path_node(&self, path_node: &PathNode) -> Vec<Entrie> {
let mut result = Vec::new(); let mut result = Vec::new();
self.compose_path_node_recursive(path_node, &mut result, 0); self.compose_path_node_recursive(path_node, &mut result, 0);
@ -45,7 +47,7 @@ impl Composer {
fn compose_path_node_recursive( fn compose_path_node_recursive(
&self, &self,
path_node: &PathNode, path_node: &PathNode,
texts: &mut Vec<String>, entries: &mut Vec<Entrie>,
depth: usize, depth: usize,
) { ) {
for child in &path_node.children { for child in &path_node.children {
@ -53,15 +55,12 @@ impl Composer {
let dir_suffix = self.get_dir_suffix(child); let dir_suffix = self.get_dir_suffix(child);
let indent = self.get_indent(depth); let indent = self.get_indent(depth);
let text = format!( let display_text = format!(
"{}{}{}{}", "{indent} {dir_prefix} {name}{dir_suffix}",
indent, name = child.display_text
dir_prefix,
child.display_text.clone(),
dir_suffix,
); );
texts.push(text); entries.push(Entrie::new(&child.path, display_text));
self.compose_path_node_recursive(child, texts, depth + 1); self.compose_path_node_recursive(child, entries, depth + 1);
} }
} }
@ -96,16 +95,16 @@ impl Composer {
} }
fn get_indent(&self, depth: usize) -> String { 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 if self.config.composition.use_utf8 {
'·' '·'
} else { } else {
'-' '-'
}; };
let indent = " ".repeat(self.config.composition.indent as usize - 1);
format!("{}{}", indent_char, indent).repeat(depth) format!("{char_indent}{indent}").repeat(depth)
} }
} }

View file

@ -1,19 +1,25 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// Copyright (c) 2019-2022 golmman // Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
use crate::model::config::Config; use crate::model::config::Config;
use crate::view::composer::Composer; use crate::view::composer::Composer;
use log::info; use log::info;
pub use mode::Mode;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf};
pub mod composer; pub mod composer;
mod mode;
mod print; mod print;
mod scroll; mod scroll;
mod update; mod update;
pub struct Pager<W: Write> { pub struct Pager<W: Write> {
config: Config, config: Config,
pub mode: Mode,
pub cursor_row: i32, pub cursor_row: i32,
pub current_entry: PathBuf,
out: W, out: W,
terminal_cols: i32, terminal_cols: i32,
terminal_rows: i32, terminal_rows: i32,
@ -34,12 +40,14 @@ impl<W: Write> Pager<W> {
.unwrap(); .unwrap();
Self { Self {
current_entry: Path::new(&config.setup.working_dir).to_path_buf(),
config, config,
cursor_row: 0, cursor_row: 0,
out, out,
terminal_cols: 0, terminal_cols: 0,
terminal_rows: 0, terminal_rows: 0,
text_row: 0, text_row: 0,
mode: Mode::Normal,
} }
} }
} }

21
src/view/mode.rs Normal file
View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2024 Awiteb <a@4rs.nl>
/// 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,
}
}
}

View file

@ -2,8 +2,8 @@
// Copyright (c) 2019-2022 golmman // Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl> // Copyright (c) 2024 Awiteb <a@4rs.nl>
use crate::view::Pager; use crate::{controller::Entrie, view::Pager};
use std::io::Write; use std::{io::Write, path};
use termion::terminal_size; use termion::terminal_size;
impl<W: Write> Pager<W> { impl<W: Write> Pager<W> {
@ -26,7 +26,7 @@ impl<W: Write> Pager<W> {
pub fn update( pub fn update(
&mut self, &mut self,
cursor_row_delta: i32, cursor_row_delta: i32,
text_entries: &[String], entries: &[Entrie],
header_text: Option<String>, header_text: Option<String>,
) { ) {
self.update_terminal_size(); self.update_terminal_size();
@ -34,12 +34,12 @@ impl<W: Write> Pager<W> {
let spacing_bot = self.config.debug.spacing_bot; let spacing_bot = self.config.debug.spacing_bot;
let spacing_top = self.config.debug.spacing_top; 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() { 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(), "editor" => self.scroll_like_editor(),
_ => 0, _ => 0,
}; };
@ -55,18 +55,32 @@ impl<W: Write> Pager<W> {
for i in 0..displayable_rows { for i in 0..displayable_rows {
let index = first_index + i; let index = first_index + i;
if index >= 0 && index < text_entries.len() as i32 { if index >= 0 && index < entries.len() as i32 {
let text_entry = &text_entries[index as usize]; let entry = &entries[index as usize];
if index == self.cursor_row { 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::<String>())
});
self.print_text_entry_emphasized(
rename_name.as_ref().unwrap_or(&entry.display_text),
1 + spacing_top + i,
)
} else { } 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() { if let Some(header_text) = header_text.as_ref() {
self.print_header(header_text); self.print_header(header_text);