From 8e299cd2259ad3403c39016ea4bfe882a9c01323 Mon Sep 17 00:00:00 2001 From: Jordan MacDonald Date: Mon, 18 Nov 2024 23:08:12 -0500 Subject: [PATCH] Add file manager integration --- Cargo.lock | 176 +++++++++++++++++++++- Cargo.toml | 1 + src/commands/application.rs | 104 ++++++++++++- src/input/key_map/default.yml | 1 + src/models/application/preferences/mod.rs | 66 ++++++++ src/view/mod.rs | 27 +++- src/view/terminal/mod.rs | 2 + src/view/terminal/termion_terminal.rs | 78 +++++++--- src/view/terminal/test_terminal.rs | 7 + 9 files changed, 428 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb3ec370..d1b9f110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,7 @@ dependencies = [ "mio", "regex", "scribe", + "serial_test", "signal-hook", "smallvec", "syntect", @@ -519,6 +520,83 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "gethostname" version = "0.2.3" @@ -734,6 +812,16 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" @@ -935,9 +1023,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "onig" @@ -977,6 +1065,29 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall 0.5.7", + "smallvec", + "windows-targets 0.52.0", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -993,6 +1104,18 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.27" @@ -1114,6 +1237,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags 2.4.1", +] + [[package]] name = "redox_termios" version = "0.1.2" @@ -1198,6 +1330,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b202022bb57c049555430e11fc22fea12909276a80a4c3d368da36ac1d88ed" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1215,6 +1356,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sdd" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c1eeaf4b6a87c7479688c6d52b9f1153cedd3c489300564f932b065c6eab95" + [[package]] name = "serde" version = "1.0.188" @@ -1246,6 +1393,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "signal-hook" version = "0.1.17" diff --git a/Cargo.toml b/Cargo.toml index 1f4a0746..52fca237 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ yaml-rust = "0.4" smallvec = "1.11" lazy_static = "1.4" mio = "0.6" +serial_test = "3.2.0" [dependencies.signal-hook] version = "0.1" diff --git a/src/commands/application.rs b/src/commands/application.rs index ae0c605b..d24d8a8a 100644 --- a/src/commands/application.rs +++ b/src/commands/application.rs @@ -4,6 +4,8 @@ use crate::input::KeyMap; use crate::models::application::{Application, Mode, ModeKey}; use crate::util; use scribe::Buffer; +use std::fs::{read_to_string, remove_file, File}; +use std::path::PathBuf; pub fn handle_input(app: &mut Application) -> Result { // Listen for and respond to user input. @@ -246,6 +248,39 @@ pub fn switch_to_syntax_mode(app: &mut Application) -> Result { Ok(()) } +pub fn run_file_manager(app: &mut Application) -> Result { + let mut command = app + .preferences + .borrow() + .file_manager_command() + .chain_err(|| "No file manager configured.")?; + let path = app + .preferences + .borrow() + .file_manager_tmp_file_path() + .to_path_buf(); + + // Some FMs don't create temp files if a selection isn't made. + // Creating one normalizes expectations after executing it. + File::create(&path).chain_err(|| "Failed to create file manager temp file")?; + + // Run FM + app.view.replace(&mut command)?; + + // Read/clean up temp file + let file_manager_selections = + read_to_string(&path).chain_err(|| "Failed to read file manager temp file")?; + remove_file(&path).chain_err(|| "Failed to clean up file manager temp file")?; + + // Open selected buffers + for selection in file_manager_selections.lines() { + let path = PathBuf::from(selection); + util::open_buffer(&path, app)? + } + + Ok(()) +} + pub fn display_default_keymap(app: &mut Application) -> Result { commands::workspace::new_buffer(app)?; @@ -313,10 +348,14 @@ pub fn exit(app: &mut Application) -> Result { #[cfg(test)] mod tests { - use crate::models::application::Mode; + use crate::models::application::{Mode, Preferences}; use crate::models::Application; use scribe::Buffer; + use serial_test::serial; + use std::env; + use std::fs::read_to_string; use std::path::PathBuf; + use yaml_rust::yaml::YamlLoader; #[test] fn display_available_commands_creates_a_new_buffer() { @@ -385,4 +424,67 @@ mod tests { assert!(super::switch_to_path_mode(&mut app).is_err()); } + + #[test] + #[serial] + fn run_file_manager_executes_command_and_opens_path_written_to_tmp_file() { + let dir = env::current_dir().unwrap(); + let cwd = dir.display(); + + // Set up the application with a mock command that simulates a file + // manager by writing a file selection to the tmp file path. + let mut app = Application::new(&Vec::new()).unwrap(); + let data = YamlLoader::load_from_str(&format!( + " + file_manager: + command: sh + options: ['-c', 'echo {cwd}/Cargo.toml > {}'] + ", + "${tmp_file}", + )) + .unwrap(); + let preferences = Preferences::new(data.into_iter().nth(0)); + app.preferences.replace(preferences); + + super::run_file_manager(&mut app).unwrap(); + + assert_eq!( + app.workspace.current_buffer.as_ref().unwrap().data(), + read_to_string("Cargo.toml").unwrap() + ); + } + + #[test] + #[serial] + fn run_file_manager_handles_multiple_paths_written_to_tmp_file() { + let dir = env::current_dir().unwrap(); + let cwd = dir.display(); + + // Set up the application with a mock command that simulates a file + // manager by writing a file selection to the tmp file path. + let mut app = Application::new(&Vec::new()).unwrap(); + let data = YamlLoader::load_from_str(&format!( + " + file_manager: + command: sh + options: ['-c', 'echo -e \"{cwd}/Cargo.toml\\n{cwd}/Cargo.lock\" > {}'] + ", + "${tmp_file}", + )) + .unwrap(); + let preferences = Preferences::new(data.into_iter().nth(0)); + app.preferences.replace(preferences); + + super::run_file_manager(&mut app).unwrap(); + + assert_eq!( + app.workspace.current_buffer.as_ref().unwrap().data(), + read_to_string("Cargo.lock").unwrap() + ); + app.workspace.next_buffer(); + assert_eq!( + app.workspace.current_buffer.as_ref().unwrap().data(), + read_to_string("Cargo.toml").unwrap() + ); + } } diff --git a/src/input/key_map/default.yml b/src/input/key_map/default.yml index 1b8acf8b..7cba5300 100644 --- a/src/input/key_map/default.yml +++ b/src/input/key_map/default.yml @@ -81,6 +81,7 @@ normal: ctrl-z: application::suspend ctrl-c: application::exit "?": application::display_quick_start_guide + ":": application::run_file_manager insert: _: buffer::insert_char diff --git a/src/models/application/preferences/mod.rs b/src/models/application/preferences/mod.rs index 9898e5d6..cc20989d 100644 --- a/src/models/application/preferences/mod.rs +++ b/src/models/application/preferences/mod.rs @@ -9,12 +9,16 @@ use std::fs::OpenOptions; use std::io::Read; use std::path::{Path, PathBuf}; use std::process; +use std::sync::LazyLock; use yaml_rust::yaml::{Hash, Yaml, YamlLoader}; const APP_INFO: AppInfo = AppInfo { name: "amp", author: "Jordan MacDonald", }; +const FILE_MANAGER_KEY: &str = "file_manager"; +static FILE_MANAGER_TMP_FILE_PATH: LazyLock = + LazyLock::new(|| format!("/tmp/amp_selected_file_{}", process::id())); const FILE_NAME: &str = "config.yml"; const FORMAT_TOOL_KEY: &str = "format_tool"; const LINE_COMMENT_PREFIX_KEY: &str = "line_comment_prefix"; @@ -343,6 +347,32 @@ impl Preferences { Some(command) } + pub fn file_manager_command(&self) -> Option { + let program = self + .data + .as_ref() + .and_then(|data| data[FILE_MANAGER_KEY]["command"].as_str())?; + let mut command = process::Command::new(program); + + let option_data = self + .data + .as_ref() + .and_then(|data| data[FILE_MANAGER_KEY]["options"].as_vec()); + if let Some(options) = option_data { + for option in options { + if let Some(o) = option.as_str() { + command.arg(o.replace("${tmp_file}", &FILE_MANAGER_TMP_FILE_PATH)); + } + } + } + + Some(command) + } + + pub fn file_manager_tmp_file_path(&self) -> &Path { + &Path::new(&*FILE_MANAGER_TMP_FILE_PATH) + } + fn default_open_mode_exclusions(&self) -> Result>> { let exclusions = self.default[OPEN_MODE_KEY][OPEN_MODE_EXCLUSIONS_KEY] .as_vec() @@ -410,6 +440,7 @@ mod tests { use super::{ExclusionPattern, Preferences, YamlLoader}; use crate::input::KeyMap; use std::path::{Path, PathBuf}; + use std::process::{self, Command}; use yaml_rust::yaml::{Hash, Yaml}; #[test] @@ -867,4 +898,39 @@ mod tests { Some("--check") ); } + + #[test] + fn file_manager_tmp_path_returns_a_pid_namespaced_path() { + let preferences = Preferences::new(None); + + assert_eq!( + preferences.file_manager_tmp_file_path(), + Path::new(&format!("/tmp/amp_selected_file_{}", process::id())) + ); + } + + #[test] + fn file_manager_command_returns_user_defined_command_with_tmp_file_substitution() { + let data = YamlLoader::load_from_str( + " + file_manager: + command: yazi + options: + - --chooser-file + - ${tmp_file} + ", + ) + .unwrap(); + let preferences = Preferences::new(data.into_iter().nth(0)); + let mut expected_command = Command::new("yazi"); + expected_command.args([ + "--chooser-file", + &preferences.file_manager_tmp_file_path().to_string_lossy(), + ]); + + assert_eq!( + format!("{:?}", preferences.file_manager_command().unwrap()), + format!("{:?}", expected_command) + ); + } } diff --git a/src/view/mod.rs b/src/view/mod.rs index b8958756..c635c099 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -27,6 +27,7 @@ use std::cell::RefCell; use std::cmp; use std::collections::HashMap; use std::ops::Drop; +use std::process::Command; use std::rc::Rc; use std::sync::mpsc::{self, Sender, SyncSender}; use std::sync::Arc; @@ -149,13 +150,15 @@ impl View { pub fn suspend(&mut self) { let _ = self.event_listener_killswitch.send(()); self.terminal.suspend(); - let (killswitch_tx, killswitch_rx) = mpsc::sync_channel(0); - EventListener::start( - self.terminal.clone(), - self.event_channel.clone(), - killswitch_rx, - ); - self.event_listener_killswitch = killswitch_tx; + self.initialize_event_listener(); + } + + pub fn replace(&mut self, command: &mut Command) -> Result<()> { + let _ = self.event_listener_killswitch.send(()); + let status = self.terminal.replace(command)?; + self.initialize_event_listener(); + + Ok(status) } pub fn last_key(&self) -> &Option { @@ -178,6 +181,16 @@ impl View { Ok(()) } + + fn initialize_event_listener(&mut self) { + let (killswitch_tx, killswitch_rx) = mpsc::sync_channel(0); + EventListener::start( + self.terminal.clone(), + self.event_channel.clone(), + killswitch_rx, + ); + self.event_listener_killswitch = killswitch_tx; + } } impl Drop for View { diff --git a/src/view/terminal/mod.rs b/src/view/terminal/mod.rs index c0e8b963..a9889fff 100644 --- a/src/view/terminal/mod.rs +++ b/src/view/terminal/mod.rs @@ -11,6 +11,7 @@ use crate::errors::*; use crate::models::application::Event; use crate::view::{Colors, Style}; use scribe::buffer::Position; +use std::process::Command; use std::sync::Arc; pub use self::buffer::TerminalBuffer; @@ -34,6 +35,7 @@ pub trait Terminal { fn set_cursor_type(&self, _: CursorType); fn print(&self, _: &Position, _: Style, _: Colors, _: &str) -> Result<()>; fn suspend(&self); + fn replace(&self, _: &mut Command) -> Result<()>; } #[cfg(not(test))] diff --git a/src/view/terminal/termion_terminal.rs b/src/view/terminal/termion_terminal.rs index ec2dd7c4..319822ed 100644 --- a/src/view/terminal/termion_terminal.rs +++ b/src/view/terminal/termion_terminal.rs @@ -20,6 +20,7 @@ use std::io::Stdout; use std::io::{stdin, stdout, BufWriter, Stdin, Write}; use std::ops::Drop; use std::os::unix::io::AsRawFd; +use std::process::Command; use std::sync::Mutex; use std::time::Duration; use unicode_segmentation::UnicodeSegmentation; @@ -142,6 +143,37 @@ impl TermionTerminal { } self.present(); } + + fn deinit(&self) { + self.restore_cursor(); + self.set_cursor(Some(Position { line: 0, offset: 0 })); + self.present(); + + // Clear the current position so we're forced + // to move it on the first print after resuming. + self.current_position.lock().ok().take(); + + // Terminal destructor cleans up for us. + if let Ok(mut guard) = self.output.lock() { + guard.take(); + } + if let Ok(mut guard) = self.input.lock() { + guard.take(); + } + + // Flush the terminal before suspending to cause the switch from the + // alternate screen to main screen to properly restore the terminal. + let _ = stdout().flush(); + } + + fn reinit(&self) { + if let Ok(mut guard) = self.output.lock() { + guard.replace(create_output_instance()); + } + if let Ok(mut guard) = self.input.lock() { + guard.replace(stdin().keys()); + } + } } impl Terminal for TermionTerminal { @@ -301,36 +333,34 @@ impl Terminal for TermionTerminal { } fn suspend(&self) { - self.restore_cursor(); - self.set_cursor(Some(Position { line: 0, offset: 0 })); - self.present(); - - // Clear the current position so we're forced - // to move it on the first print after resuming. - self.current_position.lock().ok().take(); - - // Terminal destructor cleans up for us. - if let Ok(mut guard) = self.output.lock() { - guard.take(); - } - if let Ok(mut guard) = self.input.lock() { - guard.take(); - } - - // Flush the terminal before suspending to cause the switch from the - // alternate screen to main screen to properly restore the terminal. - let _ = stdout().flush(); + self.deinit(); unsafe { // Stop the amp process. libc::raise(libc::SIGSTOP); } - if let Ok(mut guard) = self.output.lock() { - guard.replace(create_output_instance()); - } - if let Ok(mut guard) = self.input.lock() { - guard.replace(stdin().keys()); + self.reinit(); + } + + fn replace(&self, command: &mut Command) -> Result<()> { + self.deinit(); + + let status = command + .status() + .chain_err(|| "Failed to execute replacement command.")?; + + self.reinit(); + + if status.success() { + Ok(()) + } else { + let mut message = format!("'{command:?}' exited"); + match status.code() { + None => message.push_str(" without a status code"), + Some(c) => message.push_str(&format!(" with a status code of {c}")), + }; + bail!(message); } } } diff --git a/src/view/terminal/test_terminal.rs b/src/view/terminal/test_terminal.rs index 86736a4a..414ea1bd 100644 --- a/src/view/terminal/test_terminal.rs +++ b/src/view/terminal/test_terminal.rs @@ -4,6 +4,7 @@ use crate::input::Key; use crate::models::application::Event; use crate::view::{Colors, CursorType, Style}; use scribe::buffer::Position; +use std::process::Command; use std::sync::Mutex; const WIDTH: usize = 10; @@ -93,6 +94,12 @@ impl Terminal for TestTerminal { } fn set_cursor_type(&self, _: CursorType) {} fn suspend(&self) {} + fn replace(&self, command: &mut Command) -> Result<()> { + command + .status() + .expect("test terminal replace command failed"); + Ok(()) + } fn print(&self, position: &Position, _: Style, colors: Colors, content: &str) -> Result<()> { // Ignore lines beyond visible height. if position.line >= self.height() {