diff --git a/Cargo.lock b/Cargo.lock index b109ca64..279ee4c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3338,6 +3338,7 @@ dependencies = [ "fuzzy-matcher", "humantime", "unicode-width", + "uuid", "zellij-tile", ] @@ -4191,9 +4192,9 @@ checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" [[package]] name = "uuid" -version = "1.4.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ "getrandom 0.2.10", "serde", diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs index cd4fb45a..c241281a 100644 --- a/default-plugins/fixture-plugin-for-tests/src/main.rs +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -67,6 +67,7 @@ impl ZellijPlugin for State { EventType::FileSystemUpdate, EventType::FileSystemDelete, ]); + watch_filesystem(); } fn update(&mut self, event: Event) -> bool { diff --git a/default-plugins/session-manager/Cargo.toml b/default-plugins/session-manager/Cargo.toml index 97100ed6..deb8cec5 100644 --- a/default-plugins/session-manager/Cargo.toml +++ b/default-plugins/session-manager/Cargo.toml @@ -11,3 +11,4 @@ chrono = "0.4.0" fuzzy-matcher = "0.3.7" unicode-width = "0.1.10" humantime = "2.1.0" +uuid = { version = "1.7.0", features = ["v4"] } diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs index 8040f79e..7015144f 100644 --- a/default-plugins/session-manager/src/main.rs +++ b/default-plugins/session-manager/src/main.rs @@ -2,9 +2,9 @@ mod new_session_info; mod resurrectable_sessions; mod session_list; mod ui; -use zellij_tile::prelude::*; - use std::collections::BTreeMap; +use uuid::Uuid; +use zellij_tile::prelude::*; use new_session_info::NewSessionInfo; use ui::{ @@ -45,6 +45,7 @@ struct State { colors: Colors, is_welcome_screen: bool, show_kill_all_sessions_warning: bool, + request_ids: Vec, } register_plugin!(State); @@ -66,6 +67,28 @@ impl ZellijPlugin for State { ]); } + fn pipe(&mut self, pipe_message: PipeMessage) -> bool { + if pipe_message.name == "filepicker_result" { + match (pipe_message.payload, pipe_message.args.get("request_id")) { + (Some(payload), Some(request_id)) => { + match self.request_ids.iter().position(|p| p == request_id) { + Some(request_id_position) => { + self.request_ids.remove(request_id_position); + let new_session_folder = std::path::PathBuf::from(payload); + self.new_session_info.new_session_folder = Some(new_session_folder); + }, + None => { + eprintln!("request id not found"); + }, + } + }, + _ => {}, + } + true + } else { + false + } + } fn update(&mut self, event: Event) -> bool { let mut should_render = false; match event { @@ -109,7 +132,7 @@ impl ZellijPlugin for State { render_new_session_block( &self.new_session_info, self.colors, - height, + height.saturating_sub(2), width, x, y + 2, @@ -184,12 +207,33 @@ impl State { } else if let Key::Ctrl('w') = key { self.active_screen = ActiveScreen::NewSession; should_render = true; - } else if let Key::Ctrl('c') = key { - self.new_session_info.handle_key(key); - should_render = true; } else if let Key::BackTab = key { self.toggle_active_screen(); should_render = true; + } else if let Key::Ctrl('f') = key { + let request_id = Uuid::new_v4(); + let mut config = BTreeMap::new(); + let mut args = BTreeMap::new(); + self.request_ids.push(request_id.to_string()); + // we insert this into the config so that a new plugin will be opened (the plugin's + // uniqueness is determined by its name/url as well as its config) + config.insert("request_id".to_owned(), request_id.to_string()); + // we also insert this into the args so that the plugin will have an easier access to + // it + args.insert("request_id".to_owned(), request_id.to_string()); + pipe_message_to_plugin( + MessageToPlugin::new("filepicker") + .with_plugin_url("filepicker") + .with_plugin_config(config) + .new_plugin_instance_should_have_pane_title( + "Select folder for the new session...", + ) + .with_args(args), + ); + should_render = true; + } else if let Key::Ctrl('c') = key { + self.new_session_info.new_session_folder = None; + should_render = true; } else if let Key::Esc = key { self.new_session_info.handle_key(key); should_render = true; diff --git a/default-plugins/session-manager/src/new_session_info.rs b/default-plugins/session-manager/src/new_session_info.rs index 294f4fbc..02e53966 100644 --- a/default-plugins/session-manager/src/new_session_info.rs +++ b/default-plugins/session-manager/src/new_session_info.rs @@ -1,6 +1,7 @@ use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use std::cmp::Ordering; +use std::path::PathBuf; use zellij_tile::prelude::*; #[derive(Default)] @@ -8,6 +9,7 @@ pub struct NewSessionInfo { name: String, layout_list: LayoutList, entering_new_session_info: EnteringState, + pub new_session_folder: Option, } #[derive(Eq, PartialEq)] @@ -104,7 +106,8 @@ impl NewSessionInfo { if new_session_name != current_session_name.as_ref().map(|s| s.as_str()) { match new_session_layout { Some(new_session_layout) => { - switch_session_with_layout(new_session_name, new_session_layout, None) + let cwd = self.new_session_folder.as_ref().map(|c| PathBuf::from(c)); + switch_session_with_layout(new_session_name, new_session_layout, cwd) }, None => { switch_session(new_session_name); diff --git a/default-plugins/session-manager/src/ui/components.rs b/default-plugins/session-manager/src/ui/components.rs index d3c4afe8..d44fddf4 100644 --- a/default-plugins/session-manager/src/ui/components.rs +++ b/default-plugins/session-manager/src/ui/components.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use zellij_tile::prelude::*; @@ -519,9 +520,6 @@ pub fn render_screen_toggle(active_screen: ActiveScreen, x: usize, y: usize, max let key_indication_len = key_indication_text.chars().count() + 1; let first_ribbon_length = new_session_text.chars().count() + 4; let second_ribbon_length = running_sessions_text.chars().count() + 4; - let third_ribbon_length = exited_sessions_text.chars().count() + 4; - let total_len = - key_indication_len + first_ribbon_length + second_ribbon_length + third_ribbon_length; let key_indication_x = x; let first_ribbon_x = key_indication_x + key_indication_len; let second_ribbon_x = first_ribbon_x + first_ribbon_length; @@ -552,6 +550,140 @@ pub fn render_screen_toggle(active_screen: ActiveScreen, x: usize, y: usize, max print_ribbon_with_coordinates(exited_sessions_text, third_ribbon_x, y, None, None); } +fn render_new_session_folder_prompt( + new_session_info: &NewSessionInfo, + colors: Colors, + x: usize, + y: usize, + max_cols: usize, +) { + match new_session_info.new_session_folder.as_ref() { + Some(new_session_folder) => { + let folder_prompt = "New session folder:"; + let short_folder_prompt = "Folder:"; + let new_session_path = new_session_folder.clone(); + let new_session_folder = new_session_folder.display().to_string(); + let change_folder_shortcut_text = ""; + let change_folder_shortcut = colors.magenta(&change_folder_shortcut_text); + let to_change = "to change"; + let reset_folder_shortcut_text = ""; + let reset_folder_shortcut = colors.magenta(reset_folder_shortcut_text); + let to_reset = "to reset"; + if max_cols + >= folder_prompt.width() + + new_session_folder.width() + + change_folder_shortcut_text.width() + + to_change.width() + + reset_folder_shortcut_text.width() + + to_reset.width() + + 8 + { + print!( + "\u{1b}[m{}{} {} ({} {}, {} {})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(folder_prompt), + colors.orange(&new_session_folder), + change_folder_shortcut, + to_change, + reset_folder_shortcut, + to_reset, + ); + } else if max_cols + >= short_folder_prompt.width() + + new_session_folder.width() + + change_folder_shortcut_text.width() + + to_change.width() + + reset_folder_shortcut_text.width() + + to_reset.width() + + 8 + { + print!( + "\u{1b}[m{}{} {} ({} {}, {} {})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(short_folder_prompt), + colors.orange(&new_session_folder), + change_folder_shortcut, + to_change, + reset_folder_shortcut, + to_reset, + ); + } else if max_cols + >= short_folder_prompt.width() + + new_session_folder.width() + + change_folder_shortcut_text.width() + + reset_folder_shortcut_text.width() + + 5 + { + print!( + "\u{1b}[m{}{} {} ({}/{})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(short_folder_prompt), + colors.orange(&new_session_folder), + change_folder_shortcut, + reset_folder_shortcut, + ); + } else { + let total_len = short_folder_prompt.width() + + change_folder_shortcut_text.width() + + reset_folder_shortcut_text.width() + + 5; + let max_path_len = max_cols.saturating_sub(total_len); + let truncated_path = truncate_path( + new_session_path, + new_session_folder.width().saturating_sub(max_path_len), + ); + print!( + "\u{1b}[m{}{} {} ({}/{})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(short_folder_prompt), + colors.orange(&truncated_path), + change_folder_shortcut, + reset_folder_shortcut, + ); + } + }, + None => { + let folder_prompt = "New session folder:"; + let short_folder_prompt = "Folder:"; + let change_folder_shortcut_text = ""; + let change_folder_shortcut = colors.magenta(change_folder_shortcut_text); + let to_set = "to set"; + + if max_cols + >= folder_prompt.width() + change_folder_shortcut_text.width() + to_set.width() + 4 + { + print!( + "\u{1b}[m{}{} ({} {})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(folder_prompt), + change_folder_shortcut, + to_set, + ); + } else if max_cols + >= short_folder_prompt.width() + + change_folder_shortcut_text.width() + + to_set.width() + + 4 + { + print!( + "\u{1b}[m{}{} ({} {})", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(short_folder_prompt), + change_folder_shortcut, + to_set, + ); + } else { + print!( + "\u{1b}[m{}{} {}", + format!("\u{1b}[{};{}H", y + 1, x + 1), + colors.green(short_folder_prompt), + change_folder_shortcut, + ); + } + }, + } +} + pub fn render_new_session_block( new_session_info: &NewSessionInfo, colors: Colors, @@ -615,6 +747,7 @@ pub fn render_new_session_block( } render_layout_selection_list( new_session_info, + colors, max_rows_of_new_session_block.saturating_sub(1), max_cols_of_new_session_block, x, @@ -625,6 +758,7 @@ pub fn render_new_session_block( pub fn render_layout_selection_list( new_session_info: &NewSessionInfo, + colors: Colors, max_rows_of_new_session_block: usize, max_cols_of_new_session_block: usize, x: usize, @@ -658,7 +792,7 @@ pub fn render_layout_selection_list( let layout_name = layout_info.name(); let layout_name_len = layout_name.width(); let is_builtin = layout_info.is_builtin(); - if i > max_rows_of_new_session_block { + if i > max_rows_of_new_session_block.saturating_sub(1) { break; } else { let mut layout_cell = if is_builtin { @@ -682,7 +816,15 @@ pub fn render_layout_selection_list( table = table.add_styled_row(vec![arrow_cell, layout_cell]); } } - print_table_with_coordinates(table, x, y + 3, None, None); + let table_y = y + 3; + print_table_with_coordinates(table, x, table_y, None, None); + render_new_session_folder_prompt( + new_session_info, + colors, + x, + (y + max_rows_of_new_session_block).saturating_sub(3), + max_cols_of_new_session_block, + ); } pub fn render_error(error_text: &str, rows: usize, columns: usize, x: usize, y: usize) { @@ -733,10 +875,6 @@ pub fn render_controls_line( } }, ActiveScreen::AttachToSession => { - let arrows = colors.magenta("<←↓↑→>"); - let navigate = colors.bold("Navigate"); - let enter = colors.magenta(""); - let select = colors.bold("Attach"); let rename = colors.magenta(""); let rename_text = colors.bold("Rename"); let disconnect = colors.magenta(""); @@ -817,3 +955,25 @@ impl Colors { self.color(&self.palette.magenta, text) } } + +fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String { + let mut truncated = String::new(); + let component_count = path.iter().count(); + for (i, component) in path.iter().enumerate() { + let mut component_str = component.to_string_lossy().to_string(); + if char_count_to_remove > 0 { + truncated.push(component_str.remove(0)); + if i != 0 && i + 1 != component_count { + truncated.push('/'); + } + char_count_to_remove = + char_count_to_remove.saturating_sub(component_str.width().saturating_sub(1)); + } else { + truncated.push_str(&component_str); + if i != 0 && i + 1 != component_count { + truncated.push('/'); + } + } + } + truncated +} diff --git a/default-plugins/strider/.cargo/config.toml b/default-plugins/strider/.cargo/config.toml index bc255e30..6b77899c 100644 --- a/default-plugins/strider/.cargo/config.toml +++ b/default-plugins/strider/.cargo/config.toml @@ -1,2 +1,2 @@ [build] -target = "wasm32-wasi" \ No newline at end of file +target = "wasm32-wasi" diff --git a/default-plugins/strider/src/file_list_view.rs b/default-plugins/strider/src/file_list_view.rs new file mode 100644 index 00000000..e16c66b3 --- /dev/null +++ b/default-plugins/strider/src/file_list_view.rs @@ -0,0 +1,191 @@ +use crate::shared::{calculate_list_bounds, render_list_tip}; +use crate::state::{refresh_directory, ROOT}; +use pretty_bytes::converter::convert as pretty_bytes; +use std::collections::HashMap; +use std::path::PathBuf; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +#[derive(Debug, Clone)] +pub struct FileListView { + pub path: PathBuf, + pub path_is_dir: bool, + pub files: Vec, + pub cursor_hist: HashMap, +} + +impl Default for FileListView { + fn default() -> Self { + FileListView { + path_is_dir: true, + path: Default::default(), + files: Default::default(), + cursor_hist: Default::default(), + } + } +} + +impl FileListView { + pub fn descend_to_previous_path(&mut self) { + self.path.pop(); + self.path_is_dir = true; + self.files.clear(); + self.reset_selected(); + refresh_directory(&self.path); + } + pub fn descend_to_root_path(&mut self) { + self.path.clear(); + self.path_is_dir = true; + self.files.clear(); + self.reset_selected(); + refresh_directory(&self.path); + } + pub fn enter_dir(&mut self, entry: &FsEntry) { + let is_dir = entry.is_folder(); + let path = entry.get_pathbuf_without_root_prefix(); + self.path = path; + self.path_is_dir = is_dir; + self.files.clear(); + self.reset_selected(); + } + pub fn reset_selected(&mut self) { + *self.selected_mut() = self.selected().unwrap_or(0); + } + pub fn update_files( + &mut self, + paths: Vec<(PathBuf, Option)>, + hide_hidden_files: bool, + ) { + let mut files = vec![]; + for (entry, entry_metadata) in paths { + if entry_metadata.map(|e| e.is_symlink).unwrap_or(false) { + continue; // ignore symlinks + } + let entry = if entry_metadata.map(|e| e.is_dir).unwrap_or(false) { + FsEntry::Dir(entry) + } else { + let size = entry_metadata.map(|e| e.len).unwrap_or(0); + FsEntry::File(entry, size) + }; + if !entry.is_hidden_file() || !hide_hidden_files { + files.push(entry); + } + } + self.files = files; + self.files.sort_unstable(); + } + pub fn get_selected_entry(&self) -> Option { + self.selected().and_then(|f| self.files.get(f).cloned()) + } + pub fn selected_mut(&mut self) -> &mut usize { + self.cursor_hist.entry(self.path.clone()).or_default() + } + pub fn selected(&self) -> Option { + self.cursor_hist.get(&self.path).copied() + } + pub fn move_selection_up(&mut self) { + if let Some(selected) = self.selected() { + *self.selected_mut() = selected.saturating_sub(1); + } + } + pub fn move_selection_down(&mut self) { + if let Some(selected) = self.selected() { + let next = selected.saturating_add(1); + *self.selected_mut() = std::cmp::min(self.files.len().saturating_sub(1), next); + } else { + *self.selected_mut() = 0; + } + } + pub fn render(&mut self, rows: usize, cols: usize) { + let (start_index, selected_index_in_range, end_index) = + calculate_list_bounds(self.files.len(), rows.saturating_sub(1), self.selected()); + + render_list_tip(3, cols); + for i in start_index..end_index { + if let Some(entry) = self.files.get(i) { + let is_selected = Some(i) == selected_index_in_range; + let mut file_or_folder_name = entry.name(); + let size = entry + .size() + .map(|s| pretty_bytes(s as f64)) + .unwrap_or("".to_owned()); + if entry.is_folder() { + file_or_folder_name.push('/'); + } + let file_or_folder_name_width = file_or_folder_name.width(); + let size_width = size.width(); + let text = if file_or_folder_name_width + size_width < cols { + let padding = " ".repeat( + cols.saturating_sub(file_or_folder_name_width) + .saturating_sub(size_width), + ); + format!("{}{}{}", file_or_folder_name, padding, size) + } else { + // drop the size, no room for it + let padding = " ".repeat(cols.saturating_sub(file_or_folder_name_width)); + format!("{}{}", file_or_folder_name, padding) + }; + let mut text_element = if is_selected { + Text::new(text).selected() + } else { + Text::new(text) + }; + if entry.is_folder() { + text_element = text_element.color_range(0, ..); + } + print_text_with_coordinates( + text_element, + 0, + 4 + i.saturating_sub(start_index), + Some(cols), + None, + ); + } + } + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] +pub enum FsEntry { + Dir(PathBuf), + File(PathBuf, u64), +} + +impl FsEntry { + pub fn name(&self) -> String { + let path = match self { + FsEntry::Dir(p) => p, + FsEntry::File(p, _) => p, + }; + path.file_name().unwrap().to_string_lossy().into_owned() + } + pub fn size(&self) -> Option { + match self { + FsEntry::Dir(p) => None, + FsEntry::File(_, size) => Some(*size), + } + } + pub fn get_pathbuf_without_root_prefix(&self) -> PathBuf { + match self { + FsEntry::Dir(p) => p + .strip_prefix(ROOT) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| p.clone()), + FsEntry::File(p, _) => p + .strip_prefix(ROOT) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| p.clone()), + } + } + + pub fn is_hidden_file(&self) -> bool { + self.name().starts_with('.') + } + + pub fn is_folder(&self) -> bool { + match self { + FsEntry::Dir(_) => true, + _ => false, + } + } +} diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs index 1ad2e70c..ce9a5273 100644 --- a/default-plugins/strider/src/main.rs +++ b/default-plugins/strider/src/main.rs @@ -1,121 +1,122 @@ +mod file_list_view; +mod search_view; +mod shared; mod state; -use colored::*; -use state::{refresh_directory, FsEntry, State}; +use crate::file_list_view::FsEntry; +use shared::{render_current_path, render_instruction_line, render_search_term}; +use state::{refresh_directory, State}; use std::collections::BTreeMap; -use std::{cmp::min, time::Instant}; +use std::path::PathBuf; use zellij_tile::prelude::*; register_plugin!(State); impl ZellijPlugin for State { - fn load(&mut self, _configuration: BTreeMap) { - refresh_directory(self); + fn load(&mut self, configuration: BTreeMap) { + let plugin_ids = get_plugin_ids(); + self.initial_cwd = plugin_ids.initial_cwd; + let show_hidden_files = configuration + .get("show_hidden_files") + .map(|v| v == "true") + .unwrap_or(false); + self.hide_hidden_files = !show_hidden_files; + self.close_on_selection = configuration + .get("close_on_selection") + .map(|v| v == "true") + .unwrap_or(false); subscribe(&[ EventType::Key, EventType::Mouse, EventType::CustomMessage, EventType::Timer, + EventType::FileSystemUpdate, ]); + self.file_list_view.reset_selected(); + // the caller_cwd might be different from the initial_cwd if this plugin was defined as an + // alias, with access to a certain part of the file system (often broader) and was called + // from an individual pane somewhere inside this broad scope - in this case, we want to + // start in the same cwd as the caller, giving them the full access we were granted + match configuration + .get("caller_cwd") + .map(|c| PathBuf::from(c)) + .and_then(|c| { + c.strip_prefix(&self.initial_cwd) + .ok() + .map(|c| PathBuf::from(c)) + }) { + Some(relative_caller_path) => { + let relative_caller_path = FsEntry::Dir(relative_caller_path.to_path_buf()); + self.file_list_view.enter_dir(&relative_caller_path); + refresh_directory(&self.file_list_view.path); + }, + None => { + refresh_directory(&std::path::Path::new("/")); + }, + } } fn update(&mut self, event: Event) -> bool { let mut should_render = false; - let prev_event = if self.ev_history.len() == 2 { - self.ev_history.pop_front() - } else { - None - }; - self.ev_history.push_back((event.clone(), Instant::now())); match event { + Event::FileSystemUpdate(paths) => { + self.update_files(paths); + should_render = true; + }, Event::Key(key) => match key { - Key::Esc => { - hide_self(); + Key::Char(character) if character != '\n' => { + self.update_search_term(character); should_render = true; }, - Key::Up | Key::Char('k') => { - let currently_selected = self.selected(); - *self.selected_mut() = self.selected().saturating_sub(1); - if currently_selected != self.selected() { - should_render = true; - } - }, - Key::Down | Key::Char('j') => { - let currently_selected = self.selected(); - let next = self.selected().saturating_add(1); - if next >= self.files.len() { - refresh_directory(self); - } - *self.selected_mut() = min(self.files.len().saturating_sub(1), next); - if currently_selected != self.selected() { - should_render = true; - } - }, - Key::Right | Key::Char('\n') | Key::Char('l') if !self.files.is_empty() => { - self.traverse_dir_or_open_file(); - self.ev_history.clear(); + Key::Backspace => { + self.handle_backspace(); should_render = true; }, - Key::Left | Key::Char('h') => { - if self.path.components().count() > 2 { - // don't descend into /host - // the reason this is a hard-coded number (2) and not "== ROOT" - // or some such is that there are certain cases in which self.path - // is empty and this will work then too - should_render = true; - self.path.pop(); - refresh_directory(self); - } + Key::Esc | Key::Ctrl('c') => { + self.clear_search_term_or_descend(); + should_render = true; }, - Key::Char('.') => { + Key::Up => { + self.move_selection_up(); + should_render = true; + }, + Key::Down => { + self.move_selection_down(); + should_render = true; + }, + Key::Char('\n') if self.handling_filepick_request_from.is_some() => { + self.send_filepick_response(); + }, + Key::Char('\n') => { + self.open_selected_path(); + }, + Key::Right | Key::BackTab => { + self.traverse_dir(); + should_render = true; + }, + Key::Left => { + self.descend_to_previous_path(); + should_render = true; + }, + Key::Ctrl('e') => { should_render = true; self.toggle_hidden_files(); - refresh_directory(self); + refresh_directory(&self.file_list_view.path); }, - _ => (), }, Event::Mouse(mouse_event) => match mouse_event { Mouse::ScrollDown(_) => { - let currently_selected = self.selected(); - let next = self.selected().saturating_add(1); - if next >= self.files.len() { - refresh_directory(self); - } - *self.selected_mut() = min(self.files.len().saturating_sub(1), next); - if currently_selected != self.selected() { - should_render = true; - } + self.move_selection_down(); + should_render = true; }, Mouse::ScrollUp(_) => { - let currently_selected = self.selected(); - *self.selected_mut() = self.selected().saturating_sub(1); - if currently_selected != self.selected() { - should_render = true; - } + self.move_selection_up(); + should_render = true; }, - Mouse::Release(line, _) => { - if line < 0 { - return should_render; - } - let mut should_select = true; - if let Some((Event::Mouse(Mouse::Release(prev_line, _)), t)) = prev_event { - if prev_line == line - && Instant::now().saturating_duration_since(t).as_millis() < 400 - { - self.traverse_dir_or_open_file(); - self.ev_history.clear(); - should_select = false; - should_render = true; - } - } - if should_select && self.scroll() + (line as usize) < self.files.len() { - let currently_selected = self.selected(); - *self.selected_mut() = self.scroll() + (line as usize); - if currently_selected != self.selected() { - should_render = true; - } - } + Mouse::LeftClick(line, _) => { + self.handle_left_click(line); + should_render = true; }, _ => {}, }, @@ -125,42 +126,37 @@ impl ZellijPlugin for State { }; should_render } + fn pipe(&mut self, pipe_message: PipeMessage) -> bool { + if pipe_message.is_private && pipe_message.name == "filepicker" { + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + // here we block the cli pipe input because we want it to wait until the user chose + // a file + #[cfg(target_family = "wasm")] + block_cli_pipe_input(pipe_id); + } + self.handling_filepick_request_from = Some((pipe_message.source, pipe_message.args)); + true + } else { + false + } + } fn render(&mut self, rows: usize, cols: usize) { self.current_rows = Some(rows); - for i in 0..rows { - if self.selected() < self.scroll() { - *self.scroll_mut() = self.selected(); - } - if self.selected() - self.scroll() + 2 > rows { - *self.scroll_mut() = self.selected() + 2 - rows; - } - - let is_last_row = i == rows.saturating_sub(1); - let i = self.scroll() + i; - if let Some(entry) = self.files.get(i) { - let mut path = entry.as_line(cols).normal(); - - if let FsEntry::Dir(..) = entry { - path = path.dimmed().bold(); - } - - if i == self.selected() { - if is_last_row { - print!("{}", path.clone().reversed()); - } else { - println!("{}", path.clone().reversed()); - } - } else { - if is_last_row { - print!("{}", path); - } else { - println!("{}", path); - } - } - } else if !is_last_row { - println!(); - } + let rows_for_list = rows.saturating_sub(6); + render_search_term(&self.search_term); + render_current_path( + &self.initial_cwd, + &self.file_list_view.path, + self.file_list_view.path_is_dir, + self.handling_filepick_request_from.is_some(), + cols, + ); + if self.is_searching { + self.search_view.render(rows_for_list, cols); + } else { + self.file_list_view.render(rows_for_list, cols); } + render_instruction_line(rows, cols); } } diff --git a/default-plugins/strider/src/search_view.rs b/default-plugins/strider/src/search_view.rs new file mode 100644 index 00000000..bcc38bc8 --- /dev/null +++ b/default-plugins/strider/src/search_view.rs @@ -0,0 +1,134 @@ +use crate::shared::{calculate_list_bounds, render_list_tip}; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use pretty_bytes::converter::convert as pretty_bytes; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +use crate::file_list_view::FsEntry; + +#[derive(Default, Debug)] +pub struct SearchView { + pub search_results: Vec, + pub selected_search_result: usize, +} + +impl SearchView { + pub fn search_result_count(&self) -> usize { + self.search_results.len() + } + pub fn update_search_results(&mut self, search_term: &str, files: &Vec) { + self.selected_search_result = 0; + if search_term.is_empty() { + self.search_results.clear(); + } else { + let mut matches = vec![]; + let matcher = SkimMatcherV2::default().use_cache(true); + for file in files { + let name = file.name(); + if let Some((score, indices)) = matcher.fuzzy_indices(&name, search_term) { + matches.push(SearchResult::new(file.clone(), score, indices)); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + self.search_results = matches; + } + } + pub fn clear_and_reset_selection(&mut self) { + self.search_results.clear(); + self.selected_search_result = 0; + } + pub fn move_selection_up(&mut self) { + self.selected_search_result = self.selected_search_result.saturating_sub(1); + } + pub fn move_selection_down(&mut self) { + if self.selected_search_result + 1 < self.search_results.len() { + self.selected_search_result += 1; + } + } + pub fn get_selected_entry(&self) -> Option { + self.search_results + .get(self.selected_search_result) + .map(|s| s.entry.clone()) + } + pub fn render(&mut self, rows: usize, cols: usize) { + let (start_index, selected_index_in_range, end_index) = calculate_list_bounds( + self.search_results.len(), + rows.saturating_sub(1), + Some(self.selected_search_result), + ); + render_list_tip(3, cols); + for i in start_index..end_index { + if let Some(search_result) = self.search_results.get(i) { + let is_selected = Some(i) == selected_index_in_range; + let mut search_result_text = search_result.name(); + let size = search_result + .size() + .map(|s| pretty_bytes(s as f64)) + .unwrap_or("".to_owned()); + if search_result.is_folder() { + search_result_text.push('/'); + } + + let search_result_text_width = search_result_text.width(); + let size_width = size.width(); + let text = if search_result_text_width + size_width < cols { + let padding = " ".repeat( + cols.saturating_sub(search_result_text_width) + .saturating_sub(size_width), + ); + format!("{}{}{}", search_result_text, padding, size) + } else { + // drop the size, no room for it + let padding = " ".repeat(cols.saturating_sub(search_result_text_width)); + format!("{}{}", search_result_text, padding) + }; + let mut text_element = if is_selected { + Text::new(text).selected() + } else { + Text::new(text) + }; + if search_result.is_folder() { + text_element = text_element.color_range(0, ..); + } + text_element = text_element.color_indices(3, search_result.indices()); + print_text_with_coordinates( + text_element, + 0, + i.saturating_sub(start_index) + 4, + Some(cols), + None, + ); + } + } + } +} + +#[derive(Debug)] +pub struct SearchResult { + pub entry: FsEntry, + pub score: i64, + pub indices: Vec, +} + +impl SearchResult { + pub fn new(entry: FsEntry, score: i64, indices: Vec) -> Self { + SearchResult { + entry, + score, + indices, + } + } + pub fn name(&self) -> String { + self.entry.name() + } + pub fn size(&self) -> Option { + self.entry.size() + } + pub fn indices(&self) -> Vec { + self.indices.clone() + } + pub fn is_folder(&self) -> bool { + self.entry.is_folder() + } +} diff --git a/default-plugins/strider/src/shared.rs b/default-plugins/strider/src/shared.rs new file mode 100644 index 00000000..c51cb312 --- /dev/null +++ b/default-plugins/strider/src/shared.rs @@ -0,0 +1,156 @@ +use std::path::PathBuf; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +pub fn render_instruction_line(y: usize, max_cols: usize) { + if max_cols > 78 { + let text = "Help: go back with , go to root with /, - toggle hidden files"; + let text = Text::new(text) + .color_range(3, 19..27) + .color_range(3, 45..46) + .color_range(3, 48..56); + print_text_with_coordinates(text, 0, y, Some(max_cols), None); + } else if max_cols > 56 { + let text = "Help: - back, / - root, - hidden files"; + let text = Text::new(text) + .color_range(3, 6..14) + .color_range(3, 23..24) + .color_range(3, 33..41); + print_text_with_coordinates(text, 0, y, Some(max_cols), None); + } else if max_cols > 25 { + let text = " - back, / - root"; + let text = Text::new(text).color_range(3, ..8).color_range(3, 17..18); + print_text_with_coordinates(text, 0, y, Some(max_cols), None); + } +} + +pub fn render_list_tip(y: usize, max_cols: usize) { + let tip = Text::new(format!("(<↓↑> - Navigate, - Select)")) + .color_range(3, 1..5) + .color_range(3, 18..23); + print_text_with_coordinates(tip, 0, y, Some(max_cols), None); +} + +// returns the list (start_index, selected_index_in_range, end_index) +pub fn calculate_list_bounds( + result_count: usize, + max_result_count: usize, + selected_index_in_all_results: Option, +) -> (usize, Option, usize) { + match selected_index_in_all_results { + Some(selected_index_in_all_results) => { + let mut room_in_list = max_result_count; + let mut start_index = selected_index_in_all_results; + let mut end_index = selected_index_in_all_results + 1; + let mut alternate = false; + loop { + if room_in_list == 0 { + break; + } + if !alternate && start_index > 0 { + start_index -= 1; + room_in_list -= 1; + } else if alternate && end_index < result_count { + end_index += 1; + room_in_list -= 1; + } else if start_index > 0 { + start_index -= 1; + room_in_list -= 1; + } else if end_index < result_count { + end_index += 1; + room_in_list -= 1; + } else { + break; + } + alternate = !alternate; + } + (start_index, Some(selected_index_in_all_results), end_index) + }, + None => (0, None, max_result_count + 1), + } +} + +pub fn render_search_term(search_term: &str) { + let prompt = "FIND: "; + let text = Text::new(format!("{}{}_", prompt, search_term)) + .color_range(2, 0..prompt.len()) + .color_range(3, prompt.len()..); + print_text(text); + println!("") +} + +pub fn render_current_path( + initial_cwd: &PathBuf, + path: &PathBuf, + path_is_dir: bool, + handling_filepick: bool, + max_cols: usize, +) { + let prompt = "PATH: "; + let current_path = initial_cwd.join(path); + let current_path = current_path.display().to_string(); + let prompt_len = prompt.width(); + let current_path_len = current_path.width(); + + let enter_tip = if handling_filepick { + "Select" + } else if path_is_dir { + "Open terminal here" + } else { + "Open in editor" + }; + if max_cols > prompt_len + current_path_len + enter_tip.width() + 13 { + let path_end = prompt_len + current_path_len; + let current_path = Text::new(format!( + "{}{} ( - {})", + prompt, current_path, enter_tip + )) + .color_range(2, 0..prompt_len) + .color_range(0, prompt_len..path_end) + .color_range(3, path_end + 2..path_end + 9); + print_text(current_path); + } else { + let max_path_len = max_cols + .saturating_sub(prompt_len) + .saturating_sub(8) + .saturating_sub(prompt_len); + let current_path = if current_path_len <= max_path_len { + current_path + } else { + truncate_path( + initial_cwd.join(path), + current_path_len.saturating_sub(max_path_len), + ) + }; + let current_path_len = current_path.width(); + let path_end = prompt_len + current_path_len; + let current_path = Text::new(format!("{}{} ", prompt, current_path)) + .color_range(2, 0..prompt_len) + .color_range(0, prompt_len..path_end) + .color_range(3, path_end + 1..path_end + 9); + print_text(current_path); + } + println!(); + println!(); +} + +fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String { + let mut truncated = String::new(); + let component_count = path.iter().count(); + for (i, component) in path.iter().enumerate() { + let mut component_str = component.to_string_lossy().to_string(); + if char_count_to_remove > 0 { + truncated.push(component_str.remove(0)); + if i != 0 && i + 1 != component_count { + truncated.push('/'); + } + char_count_to_remove = char_count_to_remove.saturating_sub(component_str.width() + 1); + } else { + truncated.push_str(&component_str); + if i != 0 && i + 1 != component_count { + truncated.push('/'); + } + } + } + truncated +} diff --git a/default-plugins/strider/src/state.rs b/default-plugins/strider/src/state.rs index 230e92ea..1ff58386 100644 --- a/default-plugins/strider/src/state.rs +++ b/default-plugins/strider/src/state.rs @@ -1,9 +1,9 @@ -use pretty_bytes::converter as pb; +use crate::file_list_view::{FileListView, FsEntry}; +use crate::search_view::SearchView; +use crate::shared::calculate_list_bounds; use std::{ - collections::{HashMap, VecDeque}, - fs::read_dir, + collections::BTreeMap, path::{Path, PathBuf}, - time::Instant, }; use zellij_tile::prelude::*; @@ -11,106 +11,189 @@ pub const ROOT: &str = "/host"; #[derive(Default)] pub struct State { - pub path: PathBuf, - pub files: Vec, - pub cursor_hist: HashMap, + pub file_list_view: FileListView, + pub search_view: SearchView, pub hide_hidden_files: bool, - pub ev_history: VecDeque<(Event, Instant)>, // stores last event, can be expanded in future pub loading: bool, pub loading_animation_offset: u8, pub should_open_floating: bool, pub current_rows: Option, + pub handling_filepick_request_from: Option<(PipeSource, BTreeMap)>, + pub initial_cwd: PathBuf, // TODO: get this from zellij + pub is_searching: bool, + pub search_term: String, + pub close_on_selection: bool, } impl State { - pub fn selected_mut(&mut self) -> &mut usize { - &mut self.cursor_hist.entry(self.path.clone()).or_default().0 + pub fn update_search_term(&mut self, character: char) { + self.search_term.push(character); + if &self.search_term == ".." { + self.descend_to_previous_path(); + } else if &self.search_term == "/" { + self.descend_to_root_path(); + } else { + self.is_searching = true; + self.search_view + .update_search_results(&self.search_term, &self.file_list_view.files); + } } - pub fn selected(&self) -> usize { - self.cursor_hist.get(&self.path).unwrap_or(&(0, 0)).0 + pub fn handle_backspace(&mut self) { + if self.search_term.is_empty() { + self.descend_to_previous_path(); + } else { + self.search_term.pop(); + if self.search_term.is_empty() { + self.is_searching = false; + } + self.search_view + .update_search_results(&self.search_term, &self.file_list_view.files); + } } - pub fn scroll_mut(&mut self) -> &mut usize { - &mut self.cursor_hist.entry(self.path.clone()).or_default().1 + pub fn clear_search_term_or_descend(&mut self) { + if self.search_term.is_empty() { + self.descend_to_previous_path(); + } else { + self.search_term.clear(); + self.search_view + .update_search_results(&self.search_term, &self.file_list_view.files); + self.is_searching = false; + } } - pub fn scroll(&self) -> usize { - self.cursor_hist.get(&self.path).unwrap_or(&(0, 0)).1 + pub fn move_selection_up(&mut self) { + if self.is_searching { + self.search_view.move_selection_up(); + } else { + self.file_list_view.move_selection_up(); + } + } + pub fn move_selection_down(&mut self) { + if self.is_searching { + self.search_view.move_selection_down(); + } else { + self.file_list_view.move_selection_down(); + } + } + pub fn handle_left_click(&mut self, line: isize) { + if let Some(current_rows) = self.current_rows { + let rows_for_list = current_rows.saturating_sub(5); + if self.is_searching { + let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds( + self.search_view.search_result_count(), + rows_for_list, + Some(self.search_view.selected_search_result), + ); + let prev_selected = self.search_view.selected_search_result; + self.search_view.selected_search_result = + (line as usize).saturating_sub(2) + start_index; + if prev_selected == self.search_view.selected_search_result { + self.traverse_dir(); + } + } else { + let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds( + self.file_list_view.files.len(), + rows_for_list, + self.file_list_view.selected(), + ); + let prev_selected = self.file_list_view.selected(); + *self.file_list_view.selected_mut() = + (line as usize).saturating_sub(2) + start_index; + if prev_selected == self.file_list_view.selected() { + self.traverse_dir(); + } + } + } + } + pub fn descend_to_previous_path(&mut self) { + self.search_term.clear(); + self.search_view.clear_and_reset_selection(); + self.file_list_view.descend_to_previous_path(); + } + pub fn descend_to_root_path(&mut self) { + self.search_term.clear(); + self.search_view.clear_and_reset_selection(); + self.file_list_view.descend_to_root_path(); + refresh_directory(&self.file_list_view.path); } pub fn toggle_hidden_files(&mut self) { self.hide_hidden_files = !self.hide_hidden_files; } - pub fn traverse_dir_or_open_file(&mut self) { - if let Some(f) = self.files.get(self.selected()) { - match f.clone() { - FsEntry::Dir(p) => { - self.path = p; - refresh_directory(self); - }, - FsEntry::File(p, _) => open_file(FileToOpen { - path: p.strip_prefix(ROOT).unwrap().into(), - ..Default::default() - }), - } - } - } -} - -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum FsEntry { - Dir(PathBuf), - File(PathBuf, u64), -} - -impl FsEntry { - pub fn name(&self) -> String { - let path = match self { - FsEntry::Dir(p) => p, - FsEntry::File(p, _) => p, - }; - path.file_name().unwrap().to_string_lossy().into_owned() - } - - pub fn as_line(&self, width: usize) -> String { - let info = match self { - FsEntry::Dir(_s) => "".to_owned(), - FsEntry::File(_, s) => pb::convert(*s as f64), - }; - let space = width.saturating_sub(info.len()); - let name = self.name(); - if space.saturating_sub(1) < name.len() { - [&name[..space.saturating_sub(2)], &info].join("~ ") + pub fn traverse_dir(&mut self) { + let entry = if self.is_searching { + self.search_view.get_selected_entry() } else { - let padding = " ".repeat(space - name.len()); - [name, padding, info].concat() - } - } - - pub fn is_hidden_file(&self) -> bool { - self.name().starts_with('.') - } -} - -pub(crate) fn refresh_directory(state: &mut State) { - // TODO: might be good to do this asynchronously with a worker - let mut max_lines = (state.current_rows.unwrap_or(50) + state.scroll()) * 2; // 100 is arbitrary for performance reasons - let mut files = vec![]; - for entry in read_dir(Path::new(ROOT).join(&state.path)).unwrap() { - if let Ok(entry) = entry { - if max_lines == 0 { - break; + self.file_list_view.get_selected_entry() + }; + if let Some(entry) = entry { + match &entry { + FsEntry::Dir(_p) => { + self.file_list_view.enter_dir(&entry); + self.search_view.clear_and_reset_selection(); + refresh_directory(&self.file_list_view.path); + }, + FsEntry::File(_p, _) => { + self.file_list_view.enter_dir(&entry); + self.search_view.clear_and_reset_selection(); + }, } - let entry_metadata = entry.metadata().unwrap(); - let entry = if entry_metadata.is_dir() { - FsEntry::Dir(entry.path()) + } + self.is_searching = false; + self.search_term.clear(); + self.search_view.clear_and_reset_selection(); + } + pub fn update_files(&mut self, paths: Vec<(PathBuf, Option)>) { + self.file_list_view + .update_files(paths, self.hide_hidden_files); + } + pub fn open_selected_path(&mut self) { + if self.file_list_view.path_is_dir { + open_terminal(&self.file_list_view.path); + } else { + if let Some(parent_folder) = self.file_list_view.path.parent() { + open_file( + FileToOpen::new(&self.file_list_view.path).with_cwd(parent_folder.into()), + ); } else { - let size = entry_metadata.len(); - FsEntry::File(entry.path(), size) - }; - if !entry.is_hidden_file() || !state.hide_hidden_files { - max_lines = max_lines.saturating_sub(1); - files.push(entry); + open_file(FileToOpen::new(&self.file_list_view.path)); } } + if self.close_on_selection { + close_focus(); + } + } + pub fn send_filepick_response(&mut self) { + let selected_path = self.initial_cwd.join( + self.file_list_view + .path + .strip_prefix(ROOT) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| self.file_list_view.path.clone()), + ); + match &self.handling_filepick_request_from { + Some((PipeSource::Plugin(plugin_id), args)) => { + pipe_message_to_plugin( + MessageToPlugin::new("filepicker_result") + .with_destination_plugin_id(*plugin_id) + .with_args(args.clone()) + .with_payload(selected_path.display().to_string()), + ); + #[cfg(target_family = "wasm")] + close_focus(); + }, + Some((PipeSource::Cli(pipe_id), _args)) => { + #[cfg(target_family = "wasm")] + cli_pipe_output(pipe_id, &selected_path.display().to_string()); + #[cfg(target_family = "wasm")] + unblock_cli_pipe_input(pipe_id); + #[cfg(target_family = "wasm")] + close_focus(); + }, + _ => {}, + } } - state.files = files; - state.files.sort_unstable(); +} + +pub(crate) fn refresh_directory(path: &Path) { + let path_on_host = Path::new(ROOT).join(path.strip_prefix("/").unwrap_or(path)); + scan_host_folder(&path_on_host); } diff --git a/src/main.rs b/src/main.rs index 871d468f..2ae37e2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,7 +69,7 @@ fn main() { height, })) = opts.command { - let cwd = std::env::current_dir().ok(); + let cwd = None; let command_cli_action = CliAction::NewPane { command: vec![], plugin: Some(url), diff --git a/zellij-client/src/cli_client.rs b/zellij-client/src/cli_client.rs index 38d73a4c..6d25e21b 100644 --- a/zellij-client/src/cli_client.rs +++ b/zellij-client/src/cli_client.rs @@ -74,7 +74,7 @@ fn pipe_client( os_input: &mut Box, pipe_id: String, mut name: Option, - payload: Option, + mut payload: Option, plugin: Option, args: Option>, mut configuration: Option>, @@ -87,7 +87,13 @@ fn pipe_client( pane_title: Option, ) { let mut stdin = os_input.get_stdin_reader(); - let name = name.take().or_else(|| Some(Uuid::new_v4().to_string())); + let name = name + // first we try to take the explicitly supplied message name + .take() + // then we use the plugin, to facilitate using aliases + .or_else(|| plugin.clone()) + // then we use a uuid to at least have some sort of identifier for this message + .or_else(|| Some(Uuid::new_v4().to_string())); if launch_new { // we do this to make sure the plugin is unique (has a unique configuration parameter) so // that a new one would be launched, but we'll still send it to the same instance rather @@ -116,26 +122,30 @@ fn pipe_client( None, ) }; + let is_piped = !os_input.stdin_is_terminal(); loop { - if payload.is_some() { - // we got payload from the command line, we should use it and not wait for more - let msg = create_msg(payload); + if let Some(payload) = payload.take() { + let msg = create_msg(Some(payload)); os_input.send_to_server(msg); - break; - } - // we didn't get payload from the command line, meaning we listen on STDIN because this - // signifies the user is about to pipe more (eg. cat my-large-file | zellij pipe ...) - let mut buffer = String::new(); - let _ = stdin.read_line(&mut buffer); - if buffer.is_empty() { - // end of pipe, send an empty message down the pipe + } else if !is_piped { + // here we send an empty message to trigger the plugin, because we don't have any more + // data let msg = create_msg(None); os_input.send_to_server(msg); - break; } else { - // we've got data! send it down the pipe (most common) - let msg = create_msg(Some(buffer)); - os_input.send_to_server(msg); + // we didn't get payload from the command line, meaning we listen on STDIN because this + // signifies the user is about to pipe more (eg. cat my-large-file | zellij pipe ...) + let mut buffer = String::new(); + let _ = stdin.read_line(&mut buffer); + if buffer.is_empty() { + // TODO: consider notifying the relevant plugin that the pipe has ended with a + // specialized message + break; + } else { + // we've got data! send it down the pipe (most common) + let msg = create_msg(Some(buffer)); + os_input.send_to_server(msg); + } } loop { // wait for a response and act accordingly @@ -144,7 +154,13 @@ fn pipe_client( // unblock this pipe, meaning we need to stop waiting for a response and read // once more from STDIN if pipe_name == pipe_id { - break; + if !is_piped { + // if this client is not piped, we need to exit the process completely + // rather than wait for more data + process::exit(0); + } else { + break; + } } }, Some((ServerToClientMsg::CliPipeOutput(pipe_name, output), _)) => { diff --git a/zellij-client/src/os_input_output.rs b/zellij-client/src/os_input_output.rs index 50e7e2eb..1bfd6666 100644 --- a/zellij-client/src/os_input_output.rs +++ b/zellij-client/src/os_input_output.rs @@ -8,6 +8,7 @@ use nix::pty::Winsize; use nix::sys::termios; use signal_hook::{consts::signal::*, iterator::Signals}; use std::io::prelude::*; +use std::io::IsTerminal; use std::os::unix::io::RawFd; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -97,6 +98,12 @@ pub trait ClientOsApi: Send + Sync { fn get_stdout_writer(&self) -> Box; /// Returns a BufReader that allows to read from STDIN line by line, also locks STDIN fn get_stdin_reader(&self) -> Box; + fn stdin_is_terminal(&self) -> bool { + true + } + fn stdout_is_terminal(&self) -> bool { + true + } fn update_session_name(&mut self, new_session_name: String); /// Returns the raw contents of standard input. fn read_from_stdin(&mut self) -> Result, &'static str>; @@ -193,6 +200,16 @@ impl ClientOsApi for ClientOsInputOutput { Box::new(stdin.lock()) } + fn stdin_is_terminal(&self) -> bool { + let stdin = ::std::io::stdin(); + stdin.is_terminal() + } + + fn stdout_is_terminal(&self) -> bool { + let stdout = ::std::io::stdout(); + stdout.is_terminal() + } + fn send_to_server(&self, msg: ClientToServerMsg) { // TODO: handle the error here, right now we silently ignore it let _ = self diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index d44ebb49..1f2f2be7 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -127,6 +127,7 @@ pub enum PluginInstruction { message: MessageToPlugin, }, UnblockCliPipes(Vec), + WatchFilesystem, Exit, } @@ -162,6 +163,7 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::CachePluginEvents { .. } => PluginContext::CachePluginEvents, PluginInstruction::MessageFromPlugin { .. } => PluginContext::MessageFromPlugin, PluginInstruction::UnblockCliPipes { .. } => PluginContext::UnblockCliPipes, + PluginInstruction::WatchFilesystem => PluginContext::WatchFilesystem, } } } @@ -428,15 +430,8 @@ pub(crate) fn plugin_thread_main( wasm_bridge.update_plugins(updates, shutdown_send.clone())?; }, PluginInstruction::PluginSubscribedToEvents(_plugin_id, _client_id, events) => { - for event in events { - if let EventType::FileSystemCreate - | EventType::FileSystemRead - | EventType::FileSystemUpdate - | EventType::FileSystemDelete = event - { - wasm_bridge.start_fs_watcher_if_not_started(); - } - } + // no-op, there used to be stuff we did here - now there isn't, but we might want + // to add stuff here in the future }, PluginInstruction::PermissionRequestResult( plugin_id, @@ -634,6 +629,9 @@ pub(crate) fn plugin_thread_main( .context("failed to unblock input pipe"); } }, + PluginInstruction::WatchFilesystem => { + wasm_bridge.start_fs_watcher_if_not_started(); + }, PluginInstruction::Exit => { break; }, diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index 10a1f51a..7f39c9b1 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -580,6 +580,7 @@ impl WasmBridge { &mut running_plugin, &event, &mut plugin_render_assets, + senders.clone(), ) { Ok(()) => { let _ = senders.send_to_screen(ScreenInstruction::PluginBytes( @@ -830,6 +831,7 @@ impl WasmBridge { &mut running_plugin, &event, &mut plugin_render_assets, + senders.clone(), ) { Ok(()) => { let _ = senders.send_to_screen( @@ -1261,6 +1263,7 @@ pub fn apply_event_to_plugin( running_plugin: &mut RunningPlugin, event: &Event, plugin_render_assets: &mut Vec, + senders: ThreadSenders, ) -> Result<()> { let instance = &running_plugin.instance; let plugin_env = &running_plugin.plugin_env; @@ -1315,6 +1318,17 @@ pub fn apply_event_to_plugin( ) .with_pipes(pipes_to_block_or_unblock); plugin_render_assets.push(plugin_render_asset); + } else { + // This is a bit of a hack to get around the fact that plugins are allowed not to + // render and still unblock CLI pipes + let pipes_to_block_or_unblock = pipes_to_block_or_unblock(running_plugin, None); + let plugin_render_asset = PluginRenderAsset::new(plugin_id, client_id, vec![]) + .with_pipes(pipes_to_block_or_unblock); + let _ = senders + .send_to_plugin(PluginInstruction::UnblockCliPipes(vec![ + plugin_render_asset, + ])) + .context("failed to unblock input pipe"); } }, (PermissionStatus::Denied, permission) => { diff --git a/zellij-server/src/plugins/watch_filesystem.rs b/zellij-server/src/plugins/watch_filesystem.rs index 36c0e04d..d21d1c02 100644 --- a/zellij-server/src/plugins/watch_filesystem.rs +++ b/zellij-server/src/plugins/watch_filesystem.rs @@ -90,11 +90,36 @@ pub fn watch_filesystem( .collect() }) .collect(); + // TODO: at some point we might want to add FileMetadata to these, but right now + // the API is a bit unstable, so let's not rock the boat too much by adding another + // expensive syscall let _ = senders.send_to_plugin(PluginInstruction::Update(vec![ - (None, None, Event::FileSystemRead(read_paths)), - (None, None, Event::FileSystemCreate(create_paths)), - (None, None, Event::FileSystemUpdate(update_paths)), - (None, None, Event::FileSystemDelete(delete_paths)), + ( + None, + None, + Event::FileSystemRead(read_paths.into_iter().map(|p| (p, None)).collect()), + ), + ( + None, + None, + Event::FileSystemCreate( + create_paths.into_iter().map(|p| (p, None)).collect(), + ), + ), + ( + None, + None, + Event::FileSystemUpdate( + update_paths.into_iter().map(|p| (p, None)).collect(), + ), + ), + ( + None, + None, + Event::FileSystemDelete( + delete_paths.into_iter().map(|p| (p, None)).collect(), + ), + ), ])); }, Err(errors) => errors diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 2c95de42..65c18e16 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -259,6 +259,10 @@ fn host_run_plugin_command(env: FunctionEnvMut) { PluginCommand::MessageToPlugin(message) => message_to_plugin(env, message)?, PluginCommand::DisconnectOtherClients => disconnect_other_clients(env), PluginCommand::KillSessions(session_list) => kill_sessions(session_list), + PluginCommand::ScanHostFolder(folder_to_scan) => { + scan_host_folder(env, folder_to_scan) + }, + PluginCommand::WatchFilesystem => watch_filesystem(env), }, (PermissionStatus::Denied, permission) => { log::error!( @@ -398,6 +402,7 @@ fn get_plugin_ids(env: &ForeignFunctionEnv) { let ids = PluginIds { plugin_id: env.plugin_env.plugin_id, zellij_pid: process::id(), + initial_cwd: env.plugin_env.plugin_cwd.clone(), }; ProtobufPluginIds::try_from(ids) .map_err(|e| anyhow!("Failed to serialized plugin ids: {}", e)) @@ -1326,6 +1331,82 @@ fn kill_sessions(session_names: Vec) { } } +fn watch_filesystem(env: &ForeignFunctionEnv) { + let _ = env + .plugin_env + .senders + .to_plugin + .as_ref() + .map(|sender| sender.send(PluginInstruction::WatchFilesystem)); +} + +fn scan_host_folder(env: &ForeignFunctionEnv, folder_to_scan: PathBuf) { + if !folder_to_scan.starts_with("/host") { + log::error!( + "Can only scan files in the /host filesystem, found: {}", + folder_to_scan.display() + ); + return; + } + let plugin_host_folder = env.plugin_env.plugin_cwd.clone(); + let folder_to_scan = plugin_host_folder.join(folder_to_scan.strip_prefix("/host").unwrap()); + match folder_to_scan.canonicalize() { + Ok(folder_to_scan) => { + if !folder_to_scan.starts_with(&plugin_host_folder) { + log::error!( + "Can only scan files in the plugin filesystem: {}, found: {}", + plugin_host_folder.display(), + folder_to_scan.display() + ); + return; + } + let reading_folder = std::fs::read_dir(&folder_to_scan); + match reading_folder { + Ok(reading_folder) => { + let send_plugin_instructions = env.plugin_env.senders.to_plugin.clone(); + let update_target = Some(env.plugin_env.plugin_id); + let client_id = env.plugin_env.client_id; + thread::spawn({ + move || { + let mut paths_in_folder = vec![]; + for entry in reading_folder { + if let Ok(entry) = entry { + let entry_metadata = entry.metadata().ok().map(|m| m.into()); + paths_in_folder.push(( + PathBuf::from("/host").join( + entry.path().strip_prefix(&plugin_host_folder).unwrap(), + ), + entry_metadata.into(), + )); + } + } + let _ = send_plugin_instructions + .ok_or(anyhow!("found no sender to send plugin instruction to")) + .map(|sender| { + let _ = sender.send(PluginInstruction::Update(vec![( + update_target, + Some(client_id), + Event::FileSystemUpdate(paths_in_folder), + )])); + }) + .non_fatal(); + } + }); + }, + Err(e) => { + log::error!("Failed to read folder {}: {e}", folder_to_scan.display()); + }, + } + }, + Err(e) => { + log::error!( + "Failed to canonicalize path {folder_to_scan:?} when scanning folder: {:?}", + e + ); + }, + } +} + // Custom panic handler for plugins. // // This is called when a panic occurs in a plugin. Since most panics will likely originate in the diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 064a53d8..2d04a7b6 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -766,6 +766,24 @@ where unsafe { host_run_plugin_command() }; } +/// Scan a specific folder in the host filesystem (this is a hack around some WASI runtime performance +/// issues), will not follow symlinks +pub fn scan_host_folder>(folder_to_scan: &S) { + let plugin_command = PluginCommand::ScanHostFolder(folder_to_scan.as_ref().to_path_buf()); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + +/// Start watching the host folder for filesystem changes (Note: somewhat unstable at the time +/// being) +pub fn watch_filesystem() { + let plugin_command = PluginCommand::WatchFilesystem; + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + // Utility Functions #[allow(unused)] diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 6d4ed729..2a4f8427 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -109,6 +109,23 @@ pub struct PermissionRequestResultPayload { pub struct FileListPayload { #[prost(string, repeated, tag = "1")] pub paths: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(message, repeated, tag = "2")] + pub paths_metadata: ::prost::alloc::vec::Vec, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FileMetadata { + /// if this is false, the metadata for this file has not been read + #[prost(bool, tag = "1")] + pub metadata_is_set: bool, + #[prost(bool, tag = "2")] + pub is_dir: bool, + #[prost(bool, tag = "3")] + pub is_file: bool, + #[prost(bool, tag = "4")] + pub is_symlink: bool, + #[prost(uint64, tag = "5")] + pub len: u64, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 44110ac8..5ec93e2e 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -5,7 +5,7 @@ pub struct PluginCommand { pub name: i32, #[prost( oneof = "plugin_command::Payload", - tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60" + tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61" )] pub payload: ::core::option::Option, } @@ -114,6 +114,8 @@ pub mod plugin_command { MessageToPluginPayload(super::MessageToPluginPayload), #[prost(message, tag = "60")] KillSessionsPayload(super::KillSessionsPayload), + #[prost(string, tag = "61")] + ScanHostFolderPayload(::prost::alloc::string::String), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -416,6 +418,8 @@ pub enum CommandName { MessageToPlugin = 79, DisconnectOtherClients = 80, KillSessions = 81, + ScanHostFolder = 82, + WatchFilesystem = 83, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -506,6 +510,8 @@ impl CommandName { CommandName::MessageToPlugin => "MessageToPlugin", CommandName::DisconnectOtherClients => "DisconnectOtherClients", CommandName::KillSessions => "KillSessions", + CommandName::ScanHostFolder => "ScanHostFolder", + CommandName::WatchFilesystem => "WatchFilesystem", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -593,6 +599,8 @@ impl CommandName { "MessageToPlugin" => Some(Self::MessageToPlugin), "DisconnectOtherClients" => Some(Self::DisconnectOtherClients), "KillSessions" => Some(Self::KillSessions), + "ScanHostFolder" => Some(Self::ScanHostFolder), + "WatchFilesystem" => Some(Self::WatchFilesystem), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_ids.rs b/zellij-utils/assets/prost/api.plugin_ids.rs index 647cc06c..972bc949 100644 --- a/zellij-utils/assets/prost/api.plugin_ids.rs +++ b/zellij-utils/assets/prost/api.plugin_ids.rs @@ -5,6 +5,8 @@ pub struct PluginIds { pub plugin_id: i32, #[prost(int32, tag = "2")] pub zellij_pid: i32, + #[prost(string, tag = "3")] + pub initial_cwd: ::prost::alloc::string::String, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 63ccccce..9bb0d1f9 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -5,6 +5,7 @@ use clap::ArgEnum; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; +use std::fs::Metadata; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -458,6 +459,25 @@ pub enum Mouse { Release(isize, usize), // line and column } +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FileMetadata { + pub is_dir: bool, + pub is_file: bool, + pub is_symlink: bool, + pub len: u64, +} + +impl From for FileMetadata { + fn from(metadata: Metadata) -> Self { + FileMetadata { + is_dir: metadata.is_dir(), + is_file: metadata.is_file(), + is_symlink: metadata.is_symlink(), + len: metadata.len(), + } + } +} + /// These events can be subscribed to with subscribe method exported by `zellij-tile`. /// Once subscribed to, they will trigger the `update` method of the `ZellijPlugin` trait. #[derive(Debug, Clone, PartialEq, EnumDiscriminants, ToString, Serialize, Deserialize)] @@ -488,13 +508,13 @@ pub enum Event { String, // payload ), /// A file was created somewhere in the Zellij CWD folder - FileSystemCreate(Vec), + FileSystemCreate(Vec<(PathBuf, Option)>), /// A file was accessed somewhere in the Zellij CWD folder - FileSystemRead(Vec), + FileSystemRead(Vec<(PathBuf, Option)>), /// A file was modified somewhere in the Zellij CWD folder - FileSystemUpdate(Vec), + FileSystemUpdate(Vec<(PathBuf, Option)>), /// A file was deleted somewhere in the Zellij CWD folder - FileSystemDelete(Vec), + FileSystemDelete(Vec<(PathBuf, Option)>), /// A Result of plugin permission request PermissionRequestResult(PermissionStatus), SessionUpdate( @@ -904,10 +924,11 @@ pub struct PaneInfo { pub is_selectable: bool, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct PluginIds { pub plugin_id: u32, pub zellij_pid: u32, + pub initial_cwd: PathBuf, } /// Tag used to identify the plugin in layout and config kdl files @@ -1350,4 +1371,6 @@ pub enum PluginCommand { MessageToPlugin(MessageToPlugin), DisconnectOtherClients, KillSessions(Vec), // one or more session names + ScanHostFolder(PathBuf), // TODO: rename to ScanHostFolder + WatchFilesystem, } diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 0677f611..9a100e38 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -400,6 +400,7 @@ pub enum PluginContext { CachePluginEvents, MessageFromPlugin, UnblockCliPipes, + WatchFilesystem, } /// Stack call representations corresponding to the different types of [`ClientInstruction`]s. diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index c71a8ca6..fdf04030 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -353,7 +353,7 @@ impl Action { let alias_cwd = cwd.clone().map(|cwd| current_dir.join(cwd)); let cwd = cwd .map(|cwd| current_dir.join(cwd)) - .or_else(|| Some(current_dir)); + .or_else(|| Some(current_dir.clone())); if let Some(plugin) = plugin { let plugin = match RunPluginLocation::parse(&plugin, cwd.clone()) { Ok(location) => { @@ -365,11 +365,17 @@ impl Action { initial_cwd: cwd.clone(), }) }, - Err(_) => RunPluginOrAlias::Alias(PluginAlias::new( - &plugin, - &configuration.map(|c| c.inner().clone()), - alias_cwd, - )), + Err(_) => { + let mut user_configuration = + configuration.map(|c| c.inner().clone()).unwrap_or_default(); + user_configuration + .insert("caller_cwd".to_owned(), current_dir.display().to_string()); + RunPluginOrAlias::Alias(PluginAlias::new( + &plugin, + &Some(user_configuration), + alias_cwd, + )) + }, }; if floating { Ok(vec![Action::NewFloatingPluginPane( diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index e26a9c23..abc239c0 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -103,6 +103,15 @@ message PermissionRequestResultPayload { message FileListPayload { repeated string paths = 1; + repeated FileMetadata paths_metadata = 2; +} + +message FileMetadata { + bool metadata_is_set = 1; // if this is false, the metadata for this file has not been read + bool is_dir = 2; + bool is_file = 3; + bool is_symlink = 4; + uint64 len = 5; } message CustomMessagePayload { diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 21ff1eff..1b2cbce2 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -3,10 +3,11 @@ pub use super::generated_api::api::{ event::{ event::Payload as ProtobufEventPayload, CopyDestination as ProtobufCopyDestination, Event as ProtobufEvent, EventNameList as ProtobufEventNameList, - EventType as ProtobufEventType, InputModeKeybinds as ProtobufInputModeKeybinds, - KeyBind as ProtobufKeyBind, LayoutInfo as ProtobufLayoutInfo, - ModeUpdatePayload as ProtobufModeUpdatePayload, PaneInfo as ProtobufPaneInfo, - PaneManifest as ProtobufPaneManifest, ResurrectableSession as ProtobufResurrectableSession, + EventType as ProtobufEventType, FileMetadata as ProtobufFileMetadata, + InputModeKeybinds as ProtobufInputModeKeybinds, KeyBind as ProtobufKeyBind, + LayoutInfo as ProtobufLayoutInfo, ModeUpdatePayload as ProtobufModeUpdatePayload, + PaneInfo as ProtobufPaneInfo, PaneManifest as ProtobufPaneManifest, + ResurrectableSession as ProtobufResurrectableSession, SessionManifest as ProtobufSessionManifest, TabInfo as ProtobufTabInfo, *, }, input_mode::InputMode as ProtobufInputMode, @@ -14,8 +15,8 @@ pub use super::generated_api::api::{ style::Style as ProtobufStyle, }; use crate::data::{ - CopyDestination, Event, EventType, InputMode, Key, LayoutInfo, ModeInfo, Mouse, PaneInfo, - PaneManifest, PermissionStatus, PluginCapabilities, SessionInfo, Style, TabInfo, + CopyDestination, Event, EventType, FileMetadata, InputMode, Key, LayoutInfo, ModeInfo, Mouse, + PaneInfo, PaneManifest, PermissionStatus, PluginCapabilities, SessionInfo, Style, TabInfo, }; use crate::errors::prelude::*; @@ -124,7 +125,8 @@ impl TryFrom for Event { let file_paths = file_list_payload .paths .iter() - .map(|p| PathBuf::from(p)) + .zip(file_list_payload.paths_metadata.iter()) + .map(|(p, m)| (PathBuf::from(p), m.into())) .collect(); Ok(Event::FileSystemCreate(file_paths)) }, @@ -135,7 +137,8 @@ impl TryFrom for Event { let file_paths = file_list_payload .paths .iter() - .map(|p| PathBuf::from(p)) + .zip(file_list_payload.paths_metadata.iter()) + .map(|(p, m)| (PathBuf::from(p), m.into())) .collect(); Ok(Event::FileSystemRead(file_paths)) }, @@ -146,7 +149,8 @@ impl TryFrom for Event { let file_paths = file_list_payload .paths .iter() - .map(|p| PathBuf::from(p)) + .zip(file_list_payload.paths_metadata.iter()) + .map(|(p, m)| (PathBuf::from(p), m.into())) .collect(); Ok(Event::FileSystemUpdate(file_paths)) }, @@ -157,7 +161,8 @@ impl TryFrom for Event { let file_paths = file_list_payload .paths .iter() - .map(|p| PathBuf::from(p)) + .zip(file_list_payload.paths_metadata.iter()) + .map(|(p, m)| (PathBuf::from(p), m.into())) .collect(); Ok(Event::FileSystemDelete(file_paths)) }, @@ -322,36 +327,64 @@ impl TryFrom for ProtobufEvent { payload, })), }), - Event::FileSystemCreate(paths) => { + Event::FileSystemCreate(event_paths) => { + let mut paths = vec![]; + let mut paths_metadata = vec![]; + for (path, path_metadata) in event_paths { + paths.push(path.display().to_string()); + paths_metadata.push(path_metadata.into()); + } let file_list_payload = FileListPayload { - paths: paths.iter().map(|p| p.display().to_string()).collect(), + paths, + paths_metadata, }; Ok(ProtobufEvent { name: ProtobufEventType::FileSystemCreate as i32, payload: Some(event::Payload::FileListPayload(file_list_payload)), }) }, - Event::FileSystemRead(paths) => { + Event::FileSystemRead(event_paths) => { + let mut paths = vec![]; + let mut paths_metadata = vec![]; + for (path, path_metadata) in event_paths { + paths.push(path.display().to_string()); + paths_metadata.push(path_metadata.into()); + } let file_list_payload = FileListPayload { - paths: paths.iter().map(|p| p.display().to_string()).collect(), + paths, + paths_metadata, }; Ok(ProtobufEvent { name: ProtobufEventType::FileSystemRead as i32, payload: Some(event::Payload::FileListPayload(file_list_payload)), }) }, - Event::FileSystemUpdate(paths) => { + Event::FileSystemUpdate(event_paths) => { + let mut paths = vec![]; + let mut paths_metadata = vec![]; + for (path, path_metadata) in event_paths { + paths.push(path.display().to_string()); + paths_metadata.push(path_metadata.into()); + } let file_list_payload = FileListPayload { - paths: paths.iter().map(|p| p.display().to_string()).collect(), + paths, + paths_metadata, }; Ok(ProtobufEvent { name: ProtobufEventType::FileSystemUpdate as i32, payload: Some(event::Payload::FileListPayload(file_list_payload)), }) }, - Event::FileSystemDelete(paths) => { + Event::FileSystemDelete(event_paths) => { + let mut paths = vec![]; + let mut paths_metadata = vec![]; + for (path, path_metadata) in event_paths { + paths.push(path.display().to_string()); + paths_metadata.push(path_metadata.into()); + } let file_list_payload = FileListPayload { - paths: paths.iter().map(|p| p.display().to_string()).collect(), + paths, + paths_metadata, }; Ok(ProtobufEvent { name: ProtobufEventType::FileSystemDelete as i32, @@ -958,6 +991,39 @@ impl From<(String, Duration)> for ProtobufResurrectableSession { } } +impl From<&ProtobufFileMetadata> for Option { + fn from(protobuf_file_metadata: &ProtobufFileMetadata) -> Option { + if protobuf_file_metadata.metadata_is_set { + Some(FileMetadata { + is_file: protobuf_file_metadata.is_file, + is_dir: protobuf_file_metadata.is_dir, + is_symlink: protobuf_file_metadata.is_symlink, + len: protobuf_file_metadata.len, + }) + } else { + None + } + } +} + +impl From> for ProtobufFileMetadata { + fn from(file_metadata: Option) -> ProtobufFileMetadata { + match file_metadata { + Some(file_metadata) => ProtobufFileMetadata { + metadata_is_set: true, + is_file: file_metadata.is_file, + is_dir: file_metadata.is_dir, + is_symlink: file_metadata.is_symlink, + len: file_metadata.len, + }, + None => ProtobufFileMetadata { + metadata_is_set: false, + ..Default::default() + }, + } + } +} + #[test] fn serialize_mode_update_event() { use prost::Message; @@ -1256,8 +1322,10 @@ fn serialize_custom_message_event() { #[test] fn serialize_file_system_create_event() { use prost::Message; - let file_system_event = - Event::FileSystemCreate(vec!["/absolute/path".into(), "./relative_path".into()]); + let file_system_event = Event::FileSystemCreate(vec![ + ("/absolute/path".into(), None), + ("./relative_path".into(), Default::default()), + ]); let protobuf_event: ProtobufEvent = file_system_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = @@ -1272,8 +1340,10 @@ fn serialize_file_system_create_event() { #[test] fn serialize_file_system_read_event() { use prost::Message; - let file_system_event = - Event::FileSystemRead(vec!["/absolute/path".into(), "./relative_path".into()]); + let file_system_event = Event::FileSystemRead(vec![ + ("/absolute/path".into(), None), + ("./relative_path".into(), Default::default()), + ]); let protobuf_event: ProtobufEvent = file_system_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = @@ -1288,8 +1358,10 @@ fn serialize_file_system_read_event() { #[test] fn serialize_file_system_update_event() { use prost::Message; - let file_system_event = - Event::FileSystemUpdate(vec!["/absolute/path".into(), "./relative_path".into()]); + let file_system_event = Event::FileSystemUpdate(vec![ + ("/absolute/path".into(), None), + ("./relative_path".into(), Some(Default::default())), + ]); let protobuf_event: ProtobufEvent = file_system_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = @@ -1304,8 +1376,10 @@ fn serialize_file_system_update_event() { #[test] fn serialize_file_system_delete_event() { use prost::Message; - let file_system_event = - Event::FileSystemDelete(vec!["/absolute/path".into(), "./relative_path".into()]); + let file_system_event = Event::FileSystemDelete(vec![ + ("/absolute/path".into(), None), + ("./relative_path".into(), Default::default()), + ]); let protobuf_event: ProtobufEvent = file_system_event.clone().try_into().unwrap(); let serialized_protobuf_event = protobuf_event.encode_to_vec(); let deserialized_protobuf_event: ProtobufEvent = diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index eecae43b..c95111e5 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -93,6 +93,8 @@ enum CommandName { MessageToPlugin = 79; DisconnectOtherClients = 80; KillSessions = 81; + ScanHostFolder = 82; + WatchFilesystem = 83; } message PluginCommand { @@ -148,6 +150,7 @@ message PluginCommand { CliPipeOutputPayload cli_pipe_output_payload = 49; MessageToPluginPayload message_to_plugin_payload = 50; KillSessionsPayload kill_sessions_payload = 60; + string scan_host_folder_payload = 61; } } diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index ab9d5294..e452e53c 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -855,7 +855,17 @@ impl TryFrom for PluginCommand { Some(Payload::KillSessionsPayload(KillSessionsPayload { session_names })) => { Ok(PluginCommand::KillSessions(session_names)) }, - _ => Err("Mismatched payload for PipeOutput"), + _ => Err("Mismatched payload for KillSessions"), + }, + Some(CommandName::ScanHostFolder) => match protobuf_plugin_command.payload { + Some(Payload::ScanHostFolderPayload(folder_to_scan)) => { + Ok(PluginCommand::ScanHostFolder(PathBuf::from(folder_to_scan))) + }, + _ => Err("Mismatched payload for ScanHostFolder"), + }, + Some(CommandName::WatchFilesystem) => match protobuf_plugin_command.payload { + Some(_) => Err("WatchFilesystem should have no payload, found a payload"), + None => Ok(PluginCommand::WatchFilesystem), }, None => Err("Unrecognized plugin command"), } @@ -1361,6 +1371,16 @@ impl TryFrom for ProtobufPluginCommand { session_names, })), }), + PluginCommand::ScanHostFolder(folder_to_scan) => Ok(ProtobufPluginCommand { + name: CommandName::ScanHostFolder as i32, + payload: Some(Payload::ScanHostFolderPayload( + folder_to_scan.display().to_string(), + )), + }), + PluginCommand::WatchFilesystem => Ok(ProtobufPluginCommand { + name: CommandName::WatchFilesystem as i32, + payload: None, + }), } } } diff --git a/zellij-utils/src/plugin_api/plugin_ids.proto b/zellij-utils/src/plugin_api/plugin_ids.proto index 2977dbe4..09d30bd0 100644 --- a/zellij-utils/src/plugin_api/plugin_ids.proto +++ b/zellij-utils/src/plugin_api/plugin_ids.proto @@ -5,6 +5,7 @@ package api.plugin_ids; message PluginIds { int32 plugin_id = 1; int32 zellij_pid = 2; + string initial_cwd = 3; } message ZellijVersion { diff --git a/zellij-utils/src/plugin_api/plugin_ids.rs b/zellij-utils/src/plugin_api/plugin_ids.rs index 51f526c6..749ccf22 100644 --- a/zellij-utils/src/plugin_api/plugin_ids.rs +++ b/zellij-utils/src/plugin_api/plugin_ids.rs @@ -4,6 +4,7 @@ pub use super::generated_api::api::plugin_ids::{ use crate::data::PluginIds; use std::convert::TryFrom; +use std::path::PathBuf; impl TryFrom for PluginIds { type Error = &'static str; @@ -11,6 +12,7 @@ impl TryFrom for PluginIds { Ok(PluginIds { plugin_id: protobuf_plugin_ids.plugin_id as u32, zellij_pid: protobuf_plugin_ids.zellij_pid as u32, + initial_cwd: PathBuf::from(protobuf_plugin_ids.initial_cwd), }) } } @@ -21,6 +23,7 @@ impl TryFrom for ProtobufPluginIds { Ok(ProtobufPluginIds { plugin_id: plugin_ids.plugin_id as i32, zellij_pid: plugin_ids.zellij_pid as i32, + initial_cwd: plugin_ids.initial_cwd.display().to_string(), }) } }