feat: Support entry renaming #3

Manually merged
awiteb merged 7 commits from awiteb/support-renaming into master 2024-12-25 13:16:43 +01:00 AGit
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.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(())

View file

@ -11,7 +11,7 @@ impl<W: Write> EventQueue<W> {
.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(())

View file

@ -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<W: Write> EventQueue<W> {
#[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

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<()> {
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);

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 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<W: Write> {
config: Config,
composer: Composer,
@ -31,7 +39,7 @@ pub struct EventQueue<W: Write> {
queue_sender: SyncSender<Event>,
// TODO: should be part of the view?
text_entries: Vec<String>,
entries: Vec<Entrie>,
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>) =
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<W: Write> EventQueue<W> {
path_node_compare,
queue_receiver,
queue_sender,
text_entries,
entries,
command_to_run_on_exit,
}
}
@ -94,7 +102,7 @@ impl<W: Write> EventQueue<W> {
Event::Resize => {
self.pager.update(
0,
&self.text_entries,
&self.entries,
self.config
.setup
.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
// Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
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")
}
}

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 {
if s.chars().count() == 1 {
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);
for (index, entry) in entries.iter().enumerate() {
writeln!(f, "{:4}|{}", index, entry)?;
writeln!(f, "{:4}|{}", index, entry.display_text)?;
}
Ok(())

View file

@ -20,7 +20,7 @@ pub struct PathNode {
pub is_err: bool,
pub is_expanded: bool,
pub path: PathBuf,
pub gitignore: Vec<String>,
pub gitignore: Vec<PathBuf>,
}
impl<T> From<T> for PathNode
@ -28,9 +28,11 @@ where
T: AsRef<Path>,
{
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(),

View file

@ -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<String> {
}
}
pub fn parse_gitignore(gitignore_path: impl AsRef<Path>) -> Vec<String> {
if Path::exists(gitignore_path.as_ref()) {
pub fn parse_gitignore(root: impl AsRef<Path>, gitignore_path: impl AsRef<Path>) -> Vec<PathBuf> {
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()

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
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<String> {
pub fn compose_path_node(&self, path_node: &PathNode) -> Vec<Entrie> {
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<String>,
entries: &mut Vec<Entrie>,
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)
}
}

View file

@ -1,19 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2019-2022 golmman
// Copyright (c) 2024 Awiteb <a@4rs.nl>
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<W: Write> {
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<W: Write> Pager<W> {
.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,
}
}
}

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) 2024 Awiteb <a@4rs.nl>
use crate::view::Pager;
use std::io::Write;
use crate::{controller::Entrie, view::Pager};
use std::{io::Write, path};
use termion::terminal_size;
impl<W: Write> Pager<W> {
@ -26,7 +26,7 @@ impl<W: Write> Pager<W> {
pub fn update(
&mut self,
cursor_row_delta: i32,
text_entries: &[String],
entries: &[Entrie],
header_text: Option<String>,
) {
self.update_terminal_size();
@ -34,12 +34,12 @@ impl<W: Write> Pager<W> {
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<W: Write> Pager<W> {
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::<String>())
});
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);