diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9a61f..628fd00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ FIXME: Released on +- **Explorer Formatter**: + - Added possibility to customize the format when listing files in the explorers (Read more on README) + - Added `file_fmt` key to configuration (if missing, default will be used). + - Added the text input to the Settings view to set the value for `file_fmt`. - Bugfix: - Solved file index in explorer files at start of termscp, in case the first entry is an hidden file - SCP File transfer: when listing directory entries, check if a symlink points to a directory or to a file diff --git a/README.md b/README.md index 7f329f6..cd8e8e7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ FIXME: Current version: 0.3.2 (18/01/2021) - [How do I configure the text editor ๐Ÿฆฅ](#how-do-i-configure-the-text-editor-) - [Configuration โš™๏ธ](#configuration-๏ธ) - [SSH Key Storage ๐Ÿ”](#ssh-key-storage-) + - [File Explorer Format](#file-explorer-format) - [Keybindings โŒจ](#keybindings-) - [Documentation ๐Ÿ“š](#documentation-) - [Known issues ๐Ÿงป](#known-issues-) @@ -60,12 +61,16 @@ It happens quite often to me, when using SCP at work to forget the path of a fil - SFTP - SCP - FTP and FTPS +- Compatible with Windows, Linux, BSD and MacOS - Practical user interface to explore and operate on the remote and on the local machine file system - Bookmarks and recent connections can be saved to access quickly to your favourite hosts - Supports text editors to view and edit text files - Supports both SFTP/SCP authentication through SSH keys and username/password -- User customization directly from the user interface -- Compatible with Windows, Linux, BSD and MacOS +- Customizations: + - Custom file explorer format + - Customizable text editor + - Customizable file sorting +- SSH key storage - Written in Rust - Easy to extend with new file transfers protocols - Developed keeping an eye on performance @@ -288,6 +293,25 @@ You can access the SSH key storage, from configuration moving to the `SSH Keys` > Q: Wait, my private key is protected with password, can I use it? > A: Of course you can. The password provided for authentication in termscp, is valid both for username/password authentication and for RSA key authentication. +### File Explorer Format + +It is possible through configuration to define a custom format for the file explorer. This field, with name `File formatter syntax` will define how the files will be displayed in the file explorer. +The syntax for the formatter is the following `{KEY1}... {KEY2}... {KEYn}...`. +Each key in bracket will be replaced with the related attribute, while everything outside brackets will be left unchanged. +These are the keys supported by the formatter: + +- `ATIME`: Last access time (with syntax `%b %d %Y %H:%M`) +- `CTIME`: Creation time (with syntax `%b %d %Y %H:%M`) +- `GROUP`: Owner group +- `MTIME`: Last change time (with syntax `%b %d %Y %H:%M`) +- `NAME`: File name (Elided if longer than 24) +- `PEX`: File permissions (UNIX format) +- `SIZE`: File size (omitted for directories) +- `SYMLINK`: Symlink (if any `-> {FILE_PATH}`) +- `USER`: Owner user + +If left empty, the default formatter syntax will be used: `{NAME} {PEX} {USER} {SIZE} {MTIME}` + --- ## Keybindings โŒจ diff --git a/src/config/mod.rs b/src/config/mod.rs index 05f23a8..2225d2a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -56,6 +56,7 @@ pub struct UserInterfaceConfig { pub default_protocol: String, pub show_hidden_files: bool, pub group_dirs: Option, + pub file_fmt: Option, } #[derive(Deserialize, Serialize, std::fmt::Debug)] @@ -85,6 +86,7 @@ impl Default for UserInterfaceConfig { default_protocol: FileTransferProtocol::Sftp.to_string(), show_hidden_files: false, group_dirs: None, + file_fmt: None, } } } @@ -171,6 +173,7 @@ mod tests { text_editor: PathBuf::from("nano"), show_hidden_files: true, group_dirs: Some(String::from("first")), + file_fmt: Some(String::from("{NAME}")), }; let cfg: UserConfig = UserConfig { user_interface: ui, @@ -187,6 +190,7 @@ mod tests { assert_eq!(cfg.user_interface.text_editor, PathBuf::from("nano")); assert_eq!(cfg.user_interface.show_hidden_files, true); assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first"))); + assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}"))); } #[test] diff --git a/src/config/serializer.rs b/src/config/serializer.rs index 89334d1..544b594 100644 --- a/src/config/serializer.rs +++ b/src/config/serializer.rs @@ -108,6 +108,7 @@ mod tests { assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); assert_eq!(cfg.user_interface.show_hidden_files, true); assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last"))); + assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME} {PEX}"))); // Verify keys assert_eq!( *cfg.remote @@ -143,6 +144,7 @@ mod tests { assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim")); assert_eq!(cfg.user_interface.show_hidden_files, true); assert_eq!(cfg.user_interface.group_dirs, None); + assert_eq!(cfg.user_interface.file_fmt, None); // Verify keys assert_eq!( *cfg.remote @@ -199,6 +201,7 @@ mod tests { text_editor = "vim" show_hidden_files = true group_dirs = "last" + file_fmt = "{NAME} {PEX}" [remote.ssh_keys] "192.168.1.31" = "/home/omar/.ssh/raspberry.key" diff --git a/src/fs/explorer/builder.rs b/src/fs/explorer/builder.rs index 41ba028..7090f9f 100644 --- a/src/fs/explorer/builder.rs +++ b/src/fs/explorer/builder.rs @@ -24,6 +24,7 @@ */ // Locals +use super::formatter::Formatter; use super::{ExplorerOpts, FileExplorer, FileSorting, GroupDirs}; // Ext use std::collections::VecDeque; @@ -95,6 +96,18 @@ impl FileExplorerBuilder { } self } + + /// ### with_formatter + /// + /// Set formatter for FileExplorer + pub fn with_formatter(&mut self, fmt_str: Option<&str>) -> &mut FileExplorerBuilder { + if let Some(e) = self.explorer.as_mut() { + if let Some(fmt_str) = fmt_str { + e.fmt = Formatter::new(fmt_str); + } + } + self + } } #[cfg(test)] @@ -119,6 +132,7 @@ mod tests { .with_group_dirs(Some(GroupDirs::First)) .with_hidden_files(true) .with_stack_size(24) + .with_formatter(Some("{NAME}")) .build(); // Verify assert!(explorer.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES)); diff --git a/src/fs/explorer/formatter.rs b/src/fs/explorer/formatter.rs new file mode 100644 index 0000000..d14ef56 --- /dev/null +++ b/src/fs/explorer/formatter.rs @@ -0,0 +1,718 @@ +//! ## Formatter +//! +//! `formatter` is the module which provides formatting utilities for `FileExplorer` + +/* +* +* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +// Deps +extern crate bytesize; +extern crate regex; +#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] +extern crate users; +// Locals +use super::FsEntry; +use crate::utils::fmt::{fmt_path_elide, fmt_pex, fmt_time}; +// Ext +use bytesize::ByteSize; +use regex::Regex; +#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] +use users::{get_group_by_gid, get_user_by_uid}; +// Types +type FmtCallback = fn(&Formatter, &FsEntry, &str, &str) -> String; + +// Keys +const FMT_KEY_ATIME: &str = "{ATIME}"; +const FMT_KEY_CTIME: &str = "{CTIME}"; +const FMT_KEY_GROUP: &str = "{GROUP}"; +const FMT_KEY_MTIME: &str = "{MTIME}"; +const FMT_KEY_NAME: &str = "{NAME}"; +const FMT_KEY_PEX: &str = "{PEX}"; +const FMT_KEY_SIZE: &str = "{SIZE}"; +const FMT_KEY_SYMLINK: &str = "{SYMLINK}"; +const FMT_KEY_USER: &str = "{USER}"; +// Default +const FMT_DEFAULT_STX: &str = "{NAME} {PEX} {USER} {SIZE} {MTIME}"; +// Regex +lazy_static! { + static ref FMT_KEY_REGEX: Regex = Regex::new(r"\{(.*?)\}").ok().unwrap(); +} + +/// ## CallChainBlock +/// +/// Call Chain block is a block in a chain of functions which are called in order to format the FsEntry. +/// A callChain is instantiated starting from the Formatter syntax and the regex, once the groups are found +/// a chain of function is made using the Formatters method. +/// This method provides an extremely fast way to format fs entries +struct CallChainBlock { + func: FmtCallback, + prefix: String, + next_block: Option>, +} + +impl CallChainBlock { + /// ### new + /// + /// Create a new `CallChainBlock` + pub fn new(func: FmtCallback, prefix: String) -> Self { + CallChainBlock { + func, + prefix, + next_block: None, + } + } + + /// ### next + /// + /// Call next callback in the CallChain + pub fn next(&self, fmt: &Formatter, fsentry: &FsEntry, cur_str: &str) -> String { + // Call func + let new_str: String = (self.func)(fmt, fsentry, cur_str, self.prefix.as_str()); + // If next is some, call next, otherwise (END OF CHAIN) return new_str + match &self.next_block { + Some(block) => block.next(fmt, fsentry, new_str.as_str()), + None => new_str, + } + } + + /// ### push + /// + /// Push func to the last element in the Call chain + pub fn push(&mut self, func: FmtCallback, prefix: String) { + // Call recursively until an element with next_block equal to None is found + match &mut self.next_block { + None => self.next_block = Some(Box::new(CallChainBlock::new(func, prefix))), + Some(block) => block.push(func, prefix), + } + } +} + +/// ## Formatter +/// +/// Formatter takes care of formatting FsEntries according to the provided keys. +/// Formatting is performed using the `CallChainBlock`, which composed makes a Call Chain. This method is extremely fast compared to match the format groups +/// at each fmt call. +pub struct Formatter { + call_chain: CallChainBlock, +} + +impl Default for Formatter { + /// ### default + /// + /// Instantiates a Formatter with the default fmt syntax + fn default() -> Self { + Formatter { + call_chain: Self::make_callchain(FMT_DEFAULT_STX), + } + } +} + +impl Formatter { + /// ### new + /// + /// Instantiates a new `Formatter` with the provided format string + pub fn new(fmt_str: &str) -> Self { + Formatter { + call_chain: Self::make_callchain(fmt_str), + } + } + + /// ### fmt + /// + /// Format fsentry + pub fn fmt(&self, fsentry: &FsEntry) -> String { + // Execute callchain blocks + self.call_chain.next(self, fsentry, "") + } + + // Fmt methods + + /// ### fmt_atime + /// + /// Format last access time + fn fmt_atime(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get date + let datetime: String = fmt_time(fsentry.get_last_access_time(), "%b %d %Y %H:%M"); + // Add to cur str, prefix and the key value + format!("{}{}{:17}", cur_str, prefix, datetime) + } + + /// ### fmt_ctime + /// + /// Format creation time + fn fmt_ctime(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get date + let datetime: String = fmt_time(fsentry.get_creation_time(), "%b %d %Y %H:%M"); + // Add to cur str, prefix and the key value + format!("{}{}{:17}", cur_str, prefix, datetime) + } + + /// ### fmt_group + /// + /// Format owner group + fn fmt_group(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get username + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + let group: String = match fsentry.get_group() { + Some(gid) => match get_group_by_gid(gid) { + Some(user) => user.name().to_string_lossy().to_string(), + None => gid.to_string(), + }, + None => 0.to_string(), + }; + #[cfg(target_os = "windows")] + let group: String = match fsentry.get_group() { + Some(gid) => gid.to_string(), + None => 0.to_string(), + }; + // Add to cur str, prefix and the key value + format!("{}{}{:12}", cur_str, prefix, group) + } + + /// ### fmt_mtime + /// + /// Format last change time + fn fmt_mtime(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get date + let datetime: String = fmt_time(fsentry.get_last_change_time(), "%b %d %Y %H:%M"); + // Add to cur str, prefix and the key value + format!("{}{}{:17}", cur_str, prefix, datetime) + } + + /// ### fmt_name + /// + /// Format file name + fn fmt_name(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get file name (or elide if too long) + let name: &str = fsentry.get_name(); + let last_idx: usize = match fsentry.is_dir() { + // NOTE: For directories is 19, since we push '/' to name + true => 19, + false => 20, + }; + let mut name: String = match name.len() >= 24 { + false => name.to_string(), + true => format!("{}...", &name[0..last_idx]), + }; + if fsentry.is_dir() { + name.push('/'); + } + // Add to cur str, prefix and the key value + format!("{}{}{:24}", cur_str, prefix, name) + } + + /// ### fmt_pex + /// + /// Format file permissions + fn fmt_pex(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Create mode string + let mut pex: String = String::with_capacity(10); + let file_type: char = match fsentry.is_symlink() { + true => 'l', + false => match fsentry.is_dir() { + true => 'd', + false => '-', + }, + }; + pex.push(file_type); + match fsentry.get_unix_pex() { + None => pex.push_str("?????????"), + Some((owner, group, others)) => pex.push_str(fmt_pex(owner, group, others).as_str()), + } + // Add to cur str, prefix and the key value + format!("{}{}{:10}", cur_str, prefix, pex) + } + + /// ### fmt_size + /// + /// Format file size + fn fmt_size(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + if fsentry.is_file() { + // Get byte size + let size: ByteSize = ByteSize(fsentry.get_size() as u64); + // Add to cur str, prefix and the key value + format!("{}{}{:10}", cur_str, prefix, size.to_string()) + } else { + // Add to cur str, prefix and the key value + format!("{}{} ", cur_str, prefix) + } + } + + /// ### fmt_symlink + /// + /// Format file symlink (if any) + fn fmt_symlink(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get file name (or elide if too long) + // Replace `FMT_KEY_NAME` with name + match fsentry.is_symlink() { + false => format!("{}{} ", cur_str, prefix), + true => format!( + "{}{}-> {:21}", + cur_str, + prefix, + fmt_path_elide(fsentry.get_realfile().get_abs_path().as_path(), 20) + ), + } + } + + /// ### fmt_user + /// + /// Format owner user + fn fmt_user(&self, fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Get username + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + let username: String = match fsentry.get_user() { + Some(uid) => match get_user_by_uid(uid) { + Some(user) => user.name().to_string_lossy().to_string(), + None => uid.to_string(), + }, + None => 0.to_string(), + }; + #[cfg(target_os = "windows")] + let username: String = match fsentry.get_user() { + Some(uid) => uid.to_string(), + None => 0.to_string(), + }; + // Add to cur str, prefix and the key value + format!("{}{}{:12}", cur_str, prefix, username) + } + + /// ### fmt_fallback + /// + /// Fallback function in case the format key is unknown + /// It does nothing, just returns cur_str + fn fmt_fallback(&self, _fsentry: &FsEntry, cur_str: &str, prefix: &str) -> String { + // Add to cur str and prefix + format!("{}{}", cur_str, prefix) + } + + // Static + + /// ### make_callchain + /// + /// Make a callchain starting from the fmt str + fn make_callchain(fmt_str: &str) -> CallChainBlock { + // Init chain block + let mut callchain: Option = None; + // Track index of the last match found, to get the prefix for each token + let mut last_index: usize = 0; + // Match fmt str against regex + for regex_match in FMT_KEY_REGEX.captures_iter(fmt_str) { + // Get match index (unwrap is safe, since always exists) + let index: usize = fmt_str.find(®ex_match[0]).unwrap(); + // Get prefix + let prefix: String = String::from(&fmt_str[last_index..index]); + // Increment last index (sum prefix lenght and the length of the key) + last_index += prefix.len() + regex_match[0].len(); + // Match the match (I guess...) + let callback: FmtCallback = match ®ex_match[0] { + FMT_KEY_ATIME => Self::fmt_atime, + FMT_KEY_CTIME => Self::fmt_ctime, + FMT_KEY_GROUP => Self::fmt_group, + FMT_KEY_MTIME => Self::fmt_mtime, + FMT_KEY_NAME => Self::fmt_name, + FMT_KEY_PEX => Self::fmt_pex, + FMT_KEY_SIZE => Self::fmt_size, + FMT_KEY_SYMLINK => Self::fmt_symlink, + FMT_KEY_USER => Self::fmt_user, + _ => Self::fmt_fallback, + }; + // Create a callchain or push new element to its back + match callchain.as_mut() { + None => callchain = Some(CallChainBlock::new(callback, prefix)), + Some(chain_block) => chain_block.push(callback, prefix), + } + } + // Finalize and return + match callchain { + Some(callchain) => callchain, + None => CallChainBlock::new(Self::fmt_fallback, String::new()), + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::fs::{FsDirectory, FsFile}; + use std::path::PathBuf; + use std::time::SystemTime; + + #[test] + fn test_fs_explorer_formatter_callchain() { + // Make a dummy formatter + let dummy_formatter: Formatter = Formatter::new(""); + // Make a dummy entry + let t_now: SystemTime = SystemTime::now(); + let dummy_entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t_now, + last_access_time: t_now, + creation_time: t_now, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + let prefix: String = String::from("h"); + let mut callchain: CallChainBlock = CallChainBlock::new(dummy_fmt, prefix); + assert!(callchain.next_block.is_none()); + assert_eq!(callchain.prefix, String::from("h")); + // Execute + assert_eq!( + callchain.next(&dummy_formatter, &dummy_entry, ""), + String::from("hA") + ); + // Push 4 new blocks + callchain.push(dummy_fmt, String::from("h")); + callchain.push(dummy_fmt, String::from("h")); + callchain.push(dummy_fmt, String::from("h")); + callchain.push(dummy_fmt, String::from("h")); + // Verify + assert_eq!( + callchain.next(&dummy_formatter, &dummy_entry, ""), + String::from("hAhAhAhAhA") + ); + } + + #[test] + fn test_fs_explorer_formatter_format_files() { + // Make default + let formatter: Formatter = Formatter::default(); + // Experiments :D + let t: SystemTime = SystemTime::now(); + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -rw-r--r-- root 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -rw-r--r-- 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + // Elide name + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("piroparoporoperoperupupu.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "piroparoporoperoperu... -rw-r--r-- root 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "piroparoporoperoperu... -rw-r--r-- 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + // No pex + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: None, // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -????????? root 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -????????? 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + // No user + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: None, // UNIX only + group: Some(0), // UNIX only + unix_pex: None, // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -????????? 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "bar.txt -????????? 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + } + + #[test] + fn test_fs_explorer_formatter_format_dirs() { + // Make default + let formatter: Formatter = Formatter::default(); + // Experiments :D + let t_now: SystemTime = SystemTime::now(); + let entry: FsEntry = FsEntry::Directory(FsDirectory { + name: String::from("projects"), + abs_path: PathBuf::from("/home/cvisintin/projects"), + last_change_time: t_now, + last_access_time: t_now, + creation_time: t_now, + readonly: false, + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: Some((7, 5, 5)), // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "projects/ drwxr-xr-x root {}", + fmt_time(t_now, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "projects/ drwxr-xr-x 0 {}", + fmt_time(t_now, "%b %d %Y %H:%M") + ) + ); + // No pex, no user + let entry: FsEntry = FsEntry::Directory(FsDirectory { + name: String::from("projects"), + abs_path: PathBuf::from("/home/cvisintin/projects"), + last_change_time: t_now, + last_access_time: t_now, + creation_time: t_now, + readonly: false, + symlink: None, // UNIX only + user: None, // UNIX only + group: Some(0), // UNIX only + unix_pex: None, // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + formatter.fmt(&entry), + format!( + "projects/ d????????? 0 {}", + fmt_time(t_now, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + formatter.fmt(&entry), + format!( + "projects/ d????????? 0 {}", + fmt_time(t_now, "%b %d %Y %H:%M") + ) + ); + } + + #[test] + fn test_fs_explorer_formatter_all_together_now() { + let formatter: Formatter = + Formatter::new("{NAME} {SYMLINK} {GROUP} {USER} {PEX} {SIZE} {ATIME} {CTIME} {MTIME}"); + // Directory (with symlink) + let t: SystemTime = SystemTime::now(); + let pointer: FsEntry = FsEntry::File(FsFile { + name: String::from("project.info"), + abs_path: PathBuf::from("/project.info"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: None, // UNIX only + }); + let entry: FsEntry = FsEntry::Directory(FsDirectory { + name: String::from("projects"), + abs_path: PathBuf::from("/home/cvisintin/project"), + last_change_time: t, + last_access_time: t, + creation_time: t, + readonly: false, + symlink: Some(Box::new(pointer)), // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: Some((7, 5, 5)), // UNIX only + }); + assert_eq!(formatter.fmt(&entry), format!( + "projects/ -> /project.info 0 0 lrwxr-xr-x {} {} {}", + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + )); + // Directory without symlink + let entry: FsEntry = FsEntry::Directory(FsDirectory { + name: String::from("projects"), + abs_path: PathBuf::from("/home/cvisintin/project"), + last_change_time: t, + last_access_time: t, + creation_time: t, + readonly: false, + symlink: None, // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: Some((7, 5, 5)), // UNIX only + }); + assert_eq!(formatter.fmt(&entry), format!( + "projects/ 0 0 drwxr-xr-x {} {} {}", + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + )); + // File with symlink + let pointer: FsEntry = FsEntry::File(FsFile { + name: String::from("project.info"), + abs_path: PathBuf::from("/project.info"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: None, // UNIX only + }); + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: Some(Box::new(pointer)), // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + assert_eq!(formatter.fmt(&entry), format!( + "bar.txt -> /project.info 0 0 lrw-r--r-- 8.2 KB {} {} {}", + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + )); + // File without symlink + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: None, // UNIX only + group: None, // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + assert_eq!(formatter.fmt(&entry), format!( + "bar.txt 0 0 -rw-r--r-- 8.2 KB {} {} {}", + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + fmt_time(t, "%b %d %Y %H:%M"), + )); + } + + /// ### dummy_fmt + /// + /// Dummy formatter, just yelds an 'A' at the end of the current string + fn dummy_fmt(_fmt: &Formatter, _entry: &FsEntry, cur_str: &str, prefix: &str) -> String { + format!("{}{}A", cur_str, prefix) + } +} diff --git a/src/fs/explorer/mod.rs b/src/fs/explorer/mod.rs index ffc8ea3..ee7b0af 100644 --- a/src/fs/explorer/mod.rs +++ b/src/fs/explorer/mod.rs @@ -25,10 +25,12 @@ // Mods pub(crate) mod builder; +mod formatter; // Deps extern crate bitflags; // Locals use super::FsEntry; +use formatter::Formatter; // Ext use std::cmp::Reverse; use std::collections::VecDeque; @@ -75,6 +77,7 @@ pub struct FileExplorer { pub(crate) file_sorting: FileSorting, // File sorting criteria pub(crate) group_dirs: Option, // If Some, defines how to group directories pub(crate) opts: ExplorerOpts, // Explorer options + pub(crate) fmt: Formatter, // FsEntry formatter index: usize, // Selected file files: Vec, // Files in directory } @@ -88,6 +91,7 @@ impl Default for FileExplorer { file_sorting: FileSorting::ByName, group_dirs: None, opts: ExplorerOpts::empty(), + fmt: Formatter::default(), index: 0, files: Vec::new(), } @@ -166,6 +170,15 @@ impl FileExplorer { self.files.get(self.index) } + // Formatting + + /// ### fmt_file + /// + /// Format a file entry + pub fn fmt_file(&self, entry: &FsEntry) -> String { + self.fmt.fmt(entry) + } + // Sorting /// ### sort_by @@ -239,7 +252,8 @@ impl FileExplorer { /// /// Sort files by creation time; the newest comes first fn sort_files_by_creation_time(&mut self) { - self.files.sort_by_key(|b: &FsEntry| Reverse(b.get_creation_time())); + self.files + .sort_by_key(|b: &FsEntry| Reverse(b.get_creation_time())); } /// ### sort_files_by_size @@ -507,6 +521,7 @@ mod tests { use super::*; use crate::fs::{FsDirectory, FsFile}; + use crate::utils::fmt::fmt_time; use std::thread::sleep; use std::time::{Duration, SystemTime}; @@ -854,6 +869,43 @@ mod tests { assert_eq!(explorer.files.get(7).unwrap().get_name(), "README.md"); } + #[test] + fn test_fs_explorer_fmt() { + let explorer: FileExplorer = FileExplorer::default(); + // Create fs entry + let t: SystemTime = SystemTime::now(); + let entry: FsEntry = FsEntry::File(FsFile { + name: String::from("bar.txt"), + abs_path: PathBuf::from("/bar.txt"), + last_change_time: t, + last_access_time: t, + creation_time: t, + size: 8192, + readonly: false, + ftype: Some(String::from("txt")), + symlink: None, // UNIX only + user: Some(0), // UNIX only + group: Some(0), // UNIX only + unix_pex: Some((6, 4, 4)), // UNIX only + }); + #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] + assert_eq!( + explorer.fmt_file(&entry), + format!( + "bar.txt -rw-r--r-- root 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + #[cfg(target_os = "windows")] + assert_eq!( + explorer.fmt_file(&entry), + format!( + "bar.txt -rw-r--r-- 0 8.2 KB {}", + fmt_time(t, "%b %d %Y %H:%M") + ) + ); + } + #[test] fn test_fs_explorer_to_string_from_str_traits() { // File Sorting diff --git a/src/fs/mod.rs b/src/fs/mod.rs index 96f14e8..61645be 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -25,19 +25,9 @@ // Mod pub mod explorer; - -// Deps -extern crate bytesize; -#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] -extern crate users; -// Locals -use crate::utils::fmt::{fmt_pex, fmt_time}; // Ext -use bytesize::ByteSize; use std::path::PathBuf; use std::time::SystemTime; -#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] -use users::get_user_by_uid; /// ## FsEntry /// @@ -236,76 +226,6 @@ impl FsEntry { } } -impl std::fmt::Display for FsEntry { - /// ### fmt_ls - /// - /// Format File Entry as `ls` does - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - // Create mode string - let mut mode: String = String::with_capacity(10); - let file_type: char = match self.is_symlink() { - true => 'l', - false => match self.is_dir() { - true => 'd', - false => '-', - }, - }; - mode.push(file_type); - match self.get_unix_pex() { - None => mode.push_str("?????????"), - Some((owner, group, others)) => mode.push_str(fmt_pex(owner, group, others).as_str()), - } - // Get username - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - let username: String = match self.get_user() { - Some(uid) => match get_user_by_uid(uid) { - Some(user) => user.name().to_string_lossy().to_string(), - None => uid.to_string(), - }, - None => 0.to_string(), - }; - #[cfg(target_os = "windows")] - let username: String = match self.get_user() { - Some(uid) => uid.to_string(), - None => 0.to_string(), - }; - // Get group - /* - let group: String = match self.get_group() { - Some(gid) => match get_group_by_gid(gid) { - Some(group) => group.name().to_string_lossy().to_string(), - None => gid.to_string(), - }, - None => String::from("0"), - }; - */ - // Get byte size - let size: ByteSize = ByteSize(self.get_size() as u64); - // Get date - let datetime: String = fmt_time(self.get_last_change_time(), "%b %d %Y %H:%M"); - // Set file name (or elide if too long) - let name: &str = self.get_name(); - let last_idx: usize = match self.is_dir() { - // NOTE: For directories is 19, since we push '/' to name - true => 19, - false => 20, - }; - let mut name: String = match name.len() >= 24 { - false => name.to_string(), - true => format!("{}...", &name[0..last_idx]), - }; - // If is directory, append '/' - if self.is_dir() { - name.push('/'); - } - write!( - f, - "{:24}\t{:12}\t{:12}\t{:10}\t{:17}", - name, mode, username, size, datetime - ) - } -} - #[cfg(test)] mod tests { @@ -512,194 +432,4 @@ mod tests { PathBuf::from("/home/cvisintin/projects") ); } - - #[test] - fn test_fs_fmt_file() { - let t: SystemTime = SystemTime::now(); - let entry: FsEntry = FsEntry::File(FsFile { - name: String::from("bar.txt"), - abs_path: PathBuf::from("/bar.txt"), - last_change_time: t, - last_access_time: t, - creation_time: t, - size: 8192, - readonly: false, - ftype: Some(String::from("txt")), - symlink: None, // UNIX only - user: Some(0), // UNIX only - group: Some(0), // UNIX only - unix_pex: Some((6, 4, 4)), // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-rw-r--r-- \troot \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-rw-r--r-- \t0 \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - // Elide name - let entry: FsEntry = FsEntry::File(FsFile { - name: String::from("piroparoporoperoperupupu.txt"), - abs_path: PathBuf::from("/bar.txt"), - last_change_time: t, - last_access_time: t, - creation_time: t, - size: 8192, - readonly: false, - ftype: Some(String::from("txt")), - symlink: None, // UNIX only - user: Some(0), // UNIX only - group: Some(0), // UNIX only - unix_pex: Some((6, 4, 4)), // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "piroparoporoperoperu... \t-rw-r--r-- \troot \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "piroparoporoperoperu... \t-rw-r--r-- \t0 \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - // No pex - let entry: FsEntry = FsEntry::File(FsFile { - name: String::from("bar.txt"), - abs_path: PathBuf::from("/bar.txt"), - last_change_time: t, - last_access_time: t, - creation_time: t, - size: 8192, - readonly: false, - ftype: Some(String::from("txt")), - symlink: None, // UNIX only - user: Some(0), // UNIX only - group: Some(0), // UNIX only - unix_pex: None, // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-????????? \troot \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-????????? \t0 \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - // No user - let entry: FsEntry = FsEntry::File(FsFile { - name: String::from("bar.txt"), - abs_path: PathBuf::from("/bar.txt"), - last_change_time: t, - last_access_time: t, - creation_time: t, - size: 8192, - readonly: false, - ftype: Some(String::from("txt")), - symlink: None, // UNIX only - user: None, // UNIX only - group: Some(0), // UNIX only - unix_pex: None, // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-????????? \t0 \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "bar.txt \t-????????? \t0 \t8.2 KB \t{}", - fmt_time(t, "%b %d %Y %H:%M") - ) - ); - } - - #[test] - fn test_fs_fmt_dir() { - let t_now: SystemTime = SystemTime::now(); - let entry: FsEntry = FsEntry::Directory(FsDirectory { - name: String::from("projects"), - abs_path: PathBuf::from("/home/cvisintin/projects"), - last_change_time: t_now, - last_access_time: t_now, - creation_time: t_now, - readonly: false, - symlink: None, // UNIX only - user: Some(0), // UNIX only - group: Some(0), // UNIX only - unix_pex: Some((7, 5, 5)), // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "projects/ \tdrwxr-xr-x \troot \t4.1 KB \t{}", - fmt_time(t_now, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "projects/ \tdrwxr-xr-x \t0 \t4.1 KB \t{}", - fmt_time(t_now, "%b %d %Y %H:%M") - ) - ); - // No pex, no user - let entry: FsEntry = FsEntry::Directory(FsDirectory { - name: String::from("projects"), - abs_path: PathBuf::from("/home/cvisintin/projects"), - last_change_time: t_now, - last_access_time: t_now, - creation_time: t_now, - readonly: false, - symlink: None, // UNIX only - user: None, // UNIX only - group: Some(0), // UNIX only - unix_pex: None, // UNIX only - }); - #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] - assert_eq!( - format!("{}", entry), - format!( - "projects/ \td????????? \t0 \t4.1 KB \t{}", - fmt_time(t_now, "%b %d %Y %H:%M") - ) - ); - #[cfg(target_os = "windows")] - assert_eq!( - format!("{}", entry), - format!( - "projects/ \td????????? \t0 \t4.1 KB \t{}", - fmt_time(t_now, "%b %d %Y %H:%M") - ) - ); - } } diff --git a/src/system/config_client.rs b/src/system/config_client.rs index 50a1061..6b994c5 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -163,6 +163,23 @@ impl ConfigClient { }; } + /// ### get_file_fmt + /// + /// Get current file fmt + pub fn get_file_fmt(&self) -> Option { + self.config.user_interface.file_fmt.clone() + } + + /// ### set_file_fmt + /// + /// Set file fmt parameter + pub fn set_file_fmt(&mut self, s: String) { + self.config.user_interface.file_fmt = match s.is_empty() { + true => None, + false => Some(s), + }; + } + // SSH Keys /// ### save_ssh_key @@ -451,6 +468,21 @@ mod tests { assert_eq!(client.get_group_dirs(), None,); } + #[test] + fn test_system_config_file_fmt() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + assert_eq!(client.get_file_fmt(), None); + client.set_file_fmt(String::from("{NAME}")); + assert_eq!(client.get_file_fmt().unwrap(), String::from("{NAME}")); + // Delete + client.set_file_fmt(String::from("")); + assert_eq!(client.get_file_fmt(), None); + } + #[test] fn test_system_config_ssh_keys() { let tmp_dir: tempfile::TempDir = create_tmp_dir(); diff --git a/src/ui/activities/filetransfer_activity/layout.rs b/src/ui/activities/filetransfer_activity/layout.rs index 641a2f6..ae9b3d2 100644 --- a/src/ui/activities/filetransfer_activity/layout.rs +++ b/src/ui/activities/filetransfer_activity/layout.rs @@ -169,7 +169,7 @@ impl FileTransferActivity { let files: Vec = self .local .iter_files() - .map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry)))) + .map(|entry: &FsEntry| ListItem::new(Span::from(self.local.fmt_file(entry)))) .collect(); // Get colors to use; highlight element inverting fg/bg only when tab is active let (fg, bg): (Color, Color) = match self.tab { @@ -209,7 +209,7 @@ impl FileTransferActivity { let files: Vec = self .remote .iter_files() - .map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry)))) + .map(|entry: &FsEntry| ListItem::new(Span::from(self.remote.fmt_file(entry)))) .collect(); // Get colors to use; highlight element inverting fg/bg only when tab is active let (fg, bg): (Color, Color) = match self.tab { diff --git a/src/ui/activities/filetransfer_activity/misc.rs b/src/ui/activities/filetransfer_activity/misc.rs index e9cb9dd..04669d1 100644 --- a/src/ui/activities/filetransfer_activity/misc.rs +++ b/src/ui/activities/filetransfer_activity/misc.rs @@ -133,6 +133,7 @@ impl FileTransferActivity { .with_group_dirs(cli.get_group_dirs()) .with_hidden_files(cli.get_show_hidden_files()) .with_stack_size(16) + .with_formatter(cli.get_file_fmt().as_deref()) .build(), None => FileExplorerBuilder::new() // Build default .with_file_sorting(FileSorting::ByName) diff --git a/src/ui/activities/setup_activity/input.rs b/src/ui/activities/setup_activity/input.rs index cdd7894..aecd916 100644 --- a/src/ui/activities/setup_activity/input.rs +++ b/src/ui/activities/setup_activity/input.rs @@ -181,16 +181,28 @@ impl SetupActivity { KeyCode::Backspace => { // Pop character from selected input if let Some(config_cli) = self.config_cli.as_mut() { - // NOTE: replace with match if other text fields are added - if matches!(field, UserInterfaceInputField::TextEditor) { - // Pop from text editor - let mut input: String = String::from( - config_cli.get_text_editor().as_path().to_string_lossy(), - ); - input.pop(); - // Update text editor value - config_cli.set_text_editor(PathBuf::from(input.as_str())); + match field { + UserInterfaceInputField::TextEditor => { + // Pop from text editor + let mut input: String = String::from( + config_cli.get_text_editor().as_path().to_string_lossy(), + ); + input.pop(); + // Update text editor value + config_cli.set_text_editor(PathBuf::from(input.as_str())); + } + UserInterfaceInputField::FileFmt => { + // Push char to current file fmt + let mut file_fmt = config_cli.get_file_fmt().unwrap_or_default(); + // Pop from file fmt + file_fmt.pop(); + // If len is 0, will become None + config_cli.set_file_fmt(file_fmt); + } + _ => { /* Not a text field */ } } + // NOTE: replace with match if other text fields are added + if matches!(field, UserInterfaceInputField::TextEditor) {} } } KeyCode::Left => { @@ -270,6 +282,7 @@ impl SetupActivity { KeyCode::Up => { // Change selected field self.tab = SetupTab::UserInterface(match field { + UserInterfaceInputField::FileFmt => UserInterfaceInputField::GroupDirs, UserInterfaceInputField::GroupDirs => { UserInterfaceInputField::ShowHiddenFiles } @@ -279,7 +292,7 @@ impl SetupActivity { UserInterfaceInputField::DefaultProtocol => { UserInterfaceInputField::TextEditor } - UserInterfaceInputField::TextEditor => UserInterfaceInputField::GroupDirs, // Wrap + UserInterfaceInputField::TextEditor => UserInterfaceInputField::FileFmt, // Wrap }); } KeyCode::Down => { @@ -294,7 +307,8 @@ impl SetupActivity { UserInterfaceInputField::ShowHiddenFiles => { UserInterfaceInputField::GroupDirs } - UserInterfaceInputField::GroupDirs => UserInterfaceInputField::TextEditor, // Wrap + UserInterfaceInputField::GroupDirs => UserInterfaceInputField::FileFmt, + UserInterfaceInputField::FileFmt => UserInterfaceInputField::TextEditor, // Wrap }); } KeyCode::Char(ch) => { @@ -328,14 +342,24 @@ impl SetupActivity { // Push character to input field if let Some(config_cli) = self.config_cli.as_mut() { // NOTE: change to match if other fields are added - if matches!(field, UserInterfaceInputField::TextEditor) { - // Get current text editor and push character - let mut input: String = String::from( - config_cli.get_text_editor().as_path().to_string_lossy(), - ); - input.push(ch); - // Update text editor value - config_cli.set_text_editor(PathBuf::from(input.as_str())); + match field { + UserInterfaceInputField::TextEditor => { + // Get current text editor and push character + let mut input: String = String::from( + config_cli.get_text_editor().as_path().to_string_lossy(), + ); + input.push(ch); + // Update text editor value + config_cli.set_text_editor(PathBuf::from(input.as_str())); + } + UserInterfaceInputField::FileFmt => { + // Push char to current file fmt + let mut file_fmt = config_cli.get_file_fmt().unwrap_or_default(); + file_fmt.push(ch); + // update value + config_cli.set_file_fmt(file_fmt); + } + _ => { /* Not a text field */ } } } } diff --git a/src/ui/activities/setup_activity/layout.rs b/src/ui/activities/setup_activity/layout.rs index e951f75..5cedd85 100644 --- a/src/ui/activities/setup_activity/layout.rs +++ b/src/ui/activities/setup_activity/layout.rs @@ -89,6 +89,7 @@ impl SetupActivity { Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), + Constraint::Length(3), Constraint::Length(1), ] .as_ref(), @@ -107,15 +108,28 @@ impl SetupActivity { if let Some(tab) = self.draw_default_group_dirs_tab() { f.render_widget(tab, ui_cfg_chunks[3]); } + if let Some(tab) = self.draw_file_fmt_input() { + f.render_widget(tab, ui_cfg_chunks[4]); + } // Set cursor if let Some(cli) = &self.config_cli { - if matches!(form_field, UserInterfaceInputField::TextEditor) { - let editor_text: String = - String::from(cli.get_text_editor().as_path().to_string_lossy()); - f.set_cursor( - ui_cfg_chunks[0].x + editor_text.width() as u16 + 1, - ui_cfg_chunks[0].y + 1, - ) + match form_field { + UserInterfaceInputField::TextEditor => { + let editor_text: String = + String::from(cli.get_text_editor().as_path().to_string_lossy()); + f.set_cursor( + ui_cfg_chunks[0].x + editor_text.width() as u16 + 1, + ui_cfg_chunks[0].y + 1, + ); + } + UserInterfaceInputField::FileFmt => { + let file_fmt: String = cli.get_file_fmt().unwrap_or_default(); + f.set_cursor( + ui_cfg_chunks[4].x + file_fmt.width() as u16 + 1, + ui_cfg_chunks[4].y + 1, + ); + } + _ => { /* Not a text field */ } } } } @@ -392,6 +406,31 @@ impl SetupActivity { } } + /// ### draw_file_fmt_input + /// + /// Draw input text field for file fmt + fn draw_file_fmt_input(&self) -> Option { + match &self.config_cli { + Some(cli) => Some( + Paragraph::new(cli.get_file_fmt().unwrap_or_default()) + .style(Style::default().fg(match &self.tab { + SetupTab::SshConfig => Color::White, + SetupTab::UserInterface(field) => match field { + UserInterfaceInputField::FileFmt => Color::LightCyan, + _ => Color::White, + }, + })) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title("File formatter syntax"), + ), + ), + None => None, + } + } + /// ### draw_ssh_keys_list /// /// Draw ssh keys list diff --git a/src/ui/activities/setup_activity/mod.rs b/src/ui/activities/setup_activity/mod.rs index 57ef6ce..b589f9c 100644 --- a/src/ui/activities/setup_activity/mod.rs +++ b/src/ui/activities/setup_activity/mod.rs @@ -55,6 +55,7 @@ enum UserInterfaceInputField { TextEditor, ShowHiddenFiles, GroupDirs, + FileFmt, } /// ### SetupTab diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index 498f94b..12c42bc 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -27,6 +27,7 @@ extern crate chrono; extern crate textwrap; use chrono::prelude::*; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; /// ### fmt_pex @@ -116,6 +117,39 @@ pub fn align_text_center(text: &str, width: u16) -> String { .to_string() } +/// ### elide_path +/// +/// Elide a path if longer than width +/// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME} +pub fn fmt_path_elide(p: &Path, width: usize) -> String { + let fmt_path: String = format!("{}", p.display()); + match fmt_path.len() > width as usize { + false => fmt_path, + true => { + // Elide + let ancestors_len: usize = p.ancestors().count(); + let mut ancestors = p.ancestors(); + let mut elided_path: PathBuf = PathBuf::new(); + // If ancestors_len's size is bigger than 2, push count - 2 + if ancestors_len > 2 { + elided_path.push(ancestors.nth(ancestors_len - 2).unwrap()); + } + // If ancestors_len is bigger than 3, push '...' and parent too + if ancestors_len > 3 { + elided_path.push("..."); + if let Some(parent) = p.ancestors().nth(1) { + elided_path.push(parent.file_name().unwrap()); + } + } + // Push file_name + if let Some(name) = p.file_name() { + elided_path.push(name); + } + format!("{}", elided_path.display()) + } + } +} + #[cfg(test)] mod tests { @@ -169,4 +203,16 @@ mod tests { String::from("18.192") ); } + + #[test] + #[cfg(any(target_os = "unix", target_os = "linux", target_os = "macos"))] + fn test_utils_fmt_path_elide() { + let p: &Path = &Path::new("/develop/pippo"); + // Under max size + assert_eq!(fmt_path_elide(p, 16), String::from("/develop/pippo")); + // Above max size, only one ancestor + assert_eq!(fmt_path_elide(p, 8), String::from("/develop/pippo")); + let p: &Path = &Path::new("/develop/pippo/foo/bar"); + assert_eq!(fmt_path_elide(p, 16), String::from("/develop/.../foo/bar")); + } }