diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-02-17 15:43:28 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-17 15:43:28 +0100 |
| commit | 4e66a0beff5e41bda1dbde521fa1d2858c46fe7b (patch) | |
| tree | 380bee5eeac56bea2ebf341cccbe69615585e394 /default-plugins/sequence | |
| parent | Fix startup message and cache file creation (#4705) (diff) | |
| download | sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.gz sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.xz sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.zip | |
Revamp the sequence plugin (#4713)
* initial pipe interface
* initial open_x_pane_in_new_tab plugin api implementation
* fix: send CommandPaneExited for command panes opened as initial_terminals in new tabs from plugins
* open panes in place of specific pane id plugin commands - initial implementation
* sequence and plugin api ux
* focus self when starting
* combine ui and pipe paths
* highlight selected pane
* initial sequence removal of editing capabilities
* fix: translate plugin paths in api
* properly trim commands when loaded from pipe or pasted text
* minor ux fixes
* get ctrl c to work properly
* behavior fixes
* more ui fixes
* fix tests
* close tab and end sequence properly
* default to AND
* zellij sequence alias
* add alias
* consolidate paths
* rustfmt
* fix ui padding
* add pr
* fix: reset scrolling when resetting terminal state
Diffstat (limited to 'default-plugins/sequence')
| -rw-r--r-- | default-plugins/sequence/src/main.rs | 1425 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/command_entry.rs | 10 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/command_parser.rs | 177 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/editing.rs | 80 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/execution.rs | 185 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/layout.rs | 54 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/mod.rs | 461 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/positioning.rs | 49 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/selection.rs | 175 | ||||
| -rw-r--r-- | default-plugins/sequence/src/state/sequence_mode.rs | 6 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/components.rs | 115 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/fuzzy_complete.rs | 321 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/mod.rs | 2 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/text_input.rs | 997 |
14 files changed, 830 insertions, 3227 deletions
diff --git a/default-plugins/sequence/src/main.rs b/default-plugins/sequence/src/main.rs index 0bfbfddc..9c99ac5d 100644 --- a/default-plugins/sequence/src/main.rs +++ b/default-plugins/sequence/src/main.rs @@ -4,36 +4,29 @@ mod ui; use crate::state::CommandStatus; use crate::ui::components; -use crate::ui::fuzzy_complete; use crate::ui::layout_calculations::calculate_viewport; -use crate::ui::text_input::InputAction; -use crate::ui::truncation::truncate_middle; -use state::State; -use zellij_tile::prelude::actions::Action; +use state::{parse_commands, ChainType, CommandEntry, SequenceMode, State}; use zellij_tile::prelude::*; use std::collections::BTreeMap; use std::path::PathBuf; -use unicode_width::UnicodeWidthStr; - register_plugin!(State); impl ZellijPlugin for State { fn load(&mut self, _configuration: BTreeMap<String, String>) { subscribe(&[ EventType::ModeUpdate, - EventType::SessionUpdate, EventType::Key, EventType::PermissionRequestResult, EventType::HostFolderChanged, - EventType::RunCommandResult, - EventType::ActionComplete, - EventType::PaneUpdate, + EventType::CommandPaneExited, + EventType::CommandPaneOpened, + EventType::EditPaneExited, EventType::Timer, EventType::PastedText, EventType::TabUpdate, - EventType::CommandPaneOpened, + EventType::BeforeClose, ]); // Store our own plugin ID and client ID @@ -41,6 +34,7 @@ impl ZellijPlugin for State { self.plugin_id = Some(plugin_ids.plugin_id); self.client_id = Some(plugin_ids.client_id); self.cwd = Some(plugin_ids.initial_cwd); + rename_plugin_pane(plugin_ids.plugin_id, "Sequence"); update_title(self); } @@ -48,15 +42,67 @@ impl ZellijPlugin for State { handle_event(self, event) } + #[cfg(target_family = "wasm")] + fn pipe(&mut self, pipe_message: PipeMessage) -> bool { + if !pipe_message.is_private { + return false; + } + + let Some(payload) = pipe_message.payload else { + return false; + }; + + let payload = payload.trim().to_string(); + if payload.is_empty() { + return false; + } + + // If already running, emit error + if self.execution.is_running { + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + #[cfg(target_family = "wasm")] + cli_pipe_output(pipe_id, "error: sequence already running\n"); + } + return false; + } + + self.load_commands(&payload, None); + + // Handle blocking: keep CLI alive until sequence finishes + if pipe_message + .args + .get("blocking") + .map(|v| v == "true") + .unwrap_or(false) + { + if let PipeSource::Cli(pipe_id) = &pipe_message.source { + block_cli_pipe_input(pipe_id); + self.blocking_pipe_id = Some(pipe_id.clone()); + } + } + + show_self(true); + + true + } + fn render(&mut self, rows: usize, cols: usize) { - // Store dimensions for use in cursor calculations + // Store dimensions for use in calculations self.own_rows = Some(rows); self.own_columns = Some(cols); - let max_visible_rows = rows.saturating_sub(5); + // Main screen: plugin just opened, no commands entered yet + if self.all_commands_are_pending() && !self.execution.can_run_sequence() { + for (text, x, y) in components::render_intro_hint(rows, cols) { + print_text_with_coordinates(text, x, y, None, None); + } + return; + } - let base_x = 1; - let base_y = 0; + let is_staging = self.all_commands_are_pending() && self.execution.can_run_sequence(); + + // Reserve: 1 header above + 1 padding-to-controls + 1 controls + 1 below/ribbon + let max_visible_rows = rows.saturating_sub(4); let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport( self.execution.all_commands.len(), @@ -65,6 +111,37 @@ impl ZellijPlugin for State { self.execution.current_running_command_index, ); + // For the staging screen, center the content both horizontally and vertically. + // Content height: 1 header row + visible_count command rows + 1 gap + 1 help row = visible_count + 3 + let (base_x, base_y, table_max_width) = if is_staging { + let longest_cwd = self.execution.longest_cwd_display(&self.cwd); + let longest_cmd = self + .execution + .all_commands + .iter() + .map(|c| c.get_text().chars().count()) + .max() + .unwrap_or(1); + let (max_chain_w, max_status_w) = components::calculate_max_widths( + &self.execution.all_commands, + self.layout.spinner_frame, + ); + let content_width = components::calculate_longest_line( + &longest_cwd, + longest_cmd, + max_chain_w, + max_status_w, + ) + .min(cols); + // header + commands + gap + help + ribbon/below = visible_count + 4 + let content_height = visible_count + 4; + let bx = cols.saturating_sub(content_width) / 2; + let by = rows.saturating_sub(content_height) / 2; + (bx, by, Some(content_width)) + } else { + (1, 0, self.own_columns.map(|o| o.saturating_sub(1))) + }; + let mut table = components::build_table_header(hidden_above > 0 || hidden_below > 0); for index in offset..offset + visible_count { @@ -79,20 +156,45 @@ impl ZellijPlugin for State { ); } - print_table_with_coordinates( - table, - base_x, - base_y, - self.own_columns.map(|o| o.saturating_sub(base_x)), - None, - ); + print_table_with_coordinates(table, base_x, base_y, table_max_width, None); let help_y = base_y + visible_count + 2; - let (first_help, _, second_help) = components::render_help_lines(self, Some(cols)); - print_text_with_coordinates(first_help, base_x, help_y, None, None); + let (first_help, first_help_len, second_help) = + components::render_help_lines(self, Some(cols)); + let help_x = if is_staging { + cols.saturating_sub(first_help_len) / 2 + } else { + base_x + }; + print_text_with_coordinates(first_help, help_x, help_y, None, None); - if let Some((second_help_text, _)) = second_help { - print_text_with_coordinates(second_help_text, base_x, help_y + 1, None, None); + if let Some((second_help_text, second_help_len)) = second_help { + let second_help_x = if is_staging { + cols.saturating_sub(second_help_len) / 2 + } else { + base_x + }; + print_text_with_coordinates(second_help_text, second_help_x, help_y + 1, None, None); + } + + // Mode ribbon: "<Tab> [Single Pane] [Spread]" — only while running + if self.execution.is_running { + let ribbon_y = help_y + 1; + print_text_with_coordinates( + Text::new("<Tab>").color_all(3), + base_x, + ribbon_y, + None, + None, + ); + let ribbon_x = base_x + 6; // "<Tab>" (5) + space (1) + let (single_ribbon, spread_ribbon) = if self.mode == SequenceMode::SinglePane { + (Text::new("Single Pane").selected(), Text::new("Spread")) + } else { + (Text::new("Single Pane"), Text::new("Spread").selected()) + }; + print_ribbon_with_coordinates(single_ribbon, ribbon_x, ribbon_y, Some(15), None); + print_ribbon_with_coordinates(spread_ribbon, ribbon_x + 15, ribbon_y, Some(10), None); } } } @@ -108,789 +210,304 @@ pub fn handle_event(state: &mut State, event: Event) -> bool { }, Event::ModeUpdate(mode_info) => { state.shell = mode_info.shell.clone(); - false }, Event::Key(key) => { - let mut should_render = handle_key_event(state, key); + let should_render = handle_key_event(state, key); let repositioned = state.reposition_plugin(); if repositioned { // we only want to render once we have repositioned, we will do this in TabUpdate - should_render = false; + return false; } - update_cursor(state); + show_cursor(None); should_render }, - Event::SessionUpdate(session_infos, _resurrectable_sessions) => { - if state.is_first_run { - // Find the current session - let current_session = session_infos.iter().find(|s| s.is_current_session); - - if let Some(session) = current_session { - // Get the pane history for this client - if let Some(client_id) = state.client_id { - if let Some(pane_history) = session.pane_history.get(&client_id) { - let own_pane_id = state.plugin_id.map(|id| PaneId::Plugin(id)); - state.primary_pane_id = select_primary_pane_from_history( - pane_history, - &session.panes, - own_pane_id, - ); - state.original_pane_id = state.primary_pane_id; // pane id focused - // before plugin - // launched - state.is_first_run = false; - } - } - } - } - false - }, Event::Timer(_elapsed) => { - // Timer events are used for lock backoff - let should_render = update_spinner(state); - + update_title(state); should_render }, Event::PastedText(pasted_text) => { - // Split pasted text into lines - let mut should_render = true; - let lines: Vec<&str> = pasted_text - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - - if lines.is_empty() { + if state.execution.is_running { return false; } - state.pasted_lines(lines); - let repositioned = state.reposition_plugin(); - if repositioned { - // we only want to render once we have repositioned, we will do this in TabUpdate - should_render = false; + let payload = pasted_text.trim().to_string(); + if payload.is_empty() { + return false; } - update_cursor(state); - - should_render + state.load_commands(&payload, None); + let repositioned = state.reposition_plugin(); + !repositioned }, Event::TabUpdate(tab_infos) => { if let Some(tab_info) = tab_infos.iter().find(|t| t.active) { - let new_cols = Some(tab_info.viewport_columns); - let new_rows = Some(tab_info.viewport_rows); - - // Check if dimensions changed - let dimensions_changed = new_cols != state.total_viewport_columns - || new_rows != state.total_viewport_rows; - - state.total_viewport_columns = new_cols; - state.total_viewport_rows = new_rows; - - if dimensions_changed { - state.reposition_plugin(); - } - update_cursor(state); + state.total_viewport_columns = Some(tab_info.viewport_columns); + state.total_viewport_rows = Some(tab_info.viewport_rows); } - - false + state.reposition_plugin() }, - Event::PaneUpdate(pane_manifest) => handle_pane_update(state, pane_manifest), - Event::ActionComplete(_action, pane_id, context) => { - let should_render = handle_action_complete(state, pane_id, context); + Event::CommandPaneExited(terminal_pane_id, exit_code, _context) => { + let should_render = handle_command_pane_exited(state, terminal_pane_id, exit_code); + let repositioned = state.reposition_plugin(); update_title(state); - should_render + should_render || repositioned }, - Event::CommandPaneOpened(terminal_pane_id, context) => { - // we get this event immediately as the pane opens, we use it to associate the - // pane's id with our state - - if !is_running_sequence(&context, state.execution.sequence_id) { - // action from previous sequence or unrelated - return false; - } - if let Some(command_text) = context.get("command_text") { - state.update_pane_id_for_command(PaneId::Terminal(terminal_pane_id), command_text); - if !state.layout.spinner_timer_scheduled { - set_timeout(0.1); - state.layout.spinner_timer_scheduled = true; - } - update_title(state); + Event::CommandPaneOpened(_terminal_pane_id, _context) => { + // Schedule spinner on first pane open + if !state.layout.spinner_timer_scheduled { + set_timeout(0.1); + state.layout.spinner_timer_scheduled = true; } - + update_title(state); true }, + Event::EditPaneExited(terminal_pane_id, _exit_code, _context) => { + let should_render = handle_editor_pane_exited(state, terminal_pane_id); + let repositioned = state.reposition_plugin(); + should_render || repositioned + }, + Event::BeforeClose => { + if let Some(pipe_id) = state.blocking_pipe_id.take() { + unblock_cli_pipe_input(&pipe_id); + } + false + }, _ => false, } } -fn handle_key_event(state: &mut State, key: KeyWithModifier) -> bool { - // Ctrl+Enter - Insert command after current with AND chain type - if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Enter) { - let mut is_cd = false; - if let Some(current_text) = state.editing_input_text() { - if let Some(path) = state::detect_cd_command(¤t_text) { - if let Some(new_cwd) = path_formatting::resolve_path(state.cwd.as_ref(), &path) { - state.current_selected_command_mut().map(|c| { - c.set_cwd(Some(new_cwd)); - c.set_text("".to_owned()); - }); - state.editing.editing_input.as_mut().map(|i| i.clear()); - is_cd = true - } - } - }; - - state.add_empty_command_after_current_selected(); - if is_cd { - state.start_editing_selected(); - } - return true; - } - - // Ctrl+X - Cycle chain type - if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('x')) { - // return state.ui.command_sequence.cycle_current_chain_type(); - state.cycle_chain_type(); - return true; - } +fn handle_command_pane_exited( + state: &mut State, + terminal_pane_id: u32, + exit_code: Option<i32>, +) -> bool { + let pane_id = PaneId::Terminal(terminal_pane_id); - // Ctrl+Space, copy to clipboard - if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) { - state.copy_to_clipboard(); + // Find which command this pane belongs to — stale events produce no match + let Some(cmd_index) = + state.execution.all_commands.iter().position( + |c| matches!(c.get_status(), CommandStatus::Running(Some(id)) if id == pane_id), + ) + else { return false; - } - - // Ctrl+w, close all panes and clear sequence - if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('w')) { - if !state.execution.is_running && !state.all_commands_are_pending() { - close_panes_and_return_to_shell(state); - } - return true; - } - - if matches!(key.bare_key, BareKey::Up) - && !key.has_modifiers(&[KeyModifier::Ctrl]) - && !key.has_modifiers(&[KeyModifier::Alt]) - { - state.move_selection_up(); - return true; - } - - if matches!(key.bare_key, BareKey::Down) - && !key.has_modifiers(&[KeyModifier::Ctrl]) - && !key.has_modifiers(&[KeyModifier::Alt]) - { - state.move_selection_down(); - return true; - } - - // Del - delete current command - if matches!(key.bare_key, BareKey::Delete) - && !key.has_modifiers(&[KeyModifier::Ctrl]) - && !key.has_modifiers(&[KeyModifier::Alt]) - { - state.remove_current_selected_command(); - return true; - } - - // Ctrl+C - Clear currently focused command, or remove it if already empty - if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('c')) { - if state.execution.is_running { - interrupt_sequence(state); - return true; - } else if state.editing.editing_input.is_some() { - let is_empty = state.current_selected_command_is_empty(); - let has_more_than_one_command = state.execution.all_commands.len() > 1; + }; - if is_empty && has_more_than_one_command { - state.remove_current_selected_command(); - } else if is_empty { - // last command, return to shell - close_panes_and_return_to_shell(state); - } else { - // If not empty, clear it - state.clear_current_selected_command(); - } - return true; - } - } + state.execution.all_commands[cmd_index] + .set_status(CommandStatus::Exited(exit_code, Some(pane_id))); - if state.editing.editing_input.is_none() - && key.has_no_modifiers() - && matches!(key.bare_key, BareKey::Char('e')) - { - state.start_editing_selected(); - return true; - } - - if state.editing.editing_input.is_none() - && key.has_no_modifiers() - && matches!(key.bare_key, BareKey::Enter) - { - rerun_sequence(state); + if !state.execution.is_running { return true; } - // Handle input actions from TextInput - let is_backspace = matches!(key.bare_key, BareKey::Backspace); - - if let Some(action) = state - .editing - .editing_input - .as_mut() - .map(|i| i.handle_key(key)) - { - match action { - InputAction::Submit => { - return handle_submit(state); - }, - InputAction::Cancel => { - state.cancel_editing_selected(); - if state.all_commands_are_pending() { - // we always want to stay in editing mode in main screen - state.start_editing_selected(); - } - true - }, - InputAction::Complete => { - state - .editing_input_text() - .map(|current_text| { - if let Some(completed) = fuzzy_complete::fuzzy_complete( - ¤t_text, - &Default::default(), // TODO: get rid of this whoel thing? - state.cwd.as_ref(), - ) { - state.set_editing_input_text(completed); - true - } else { - false - } - }) - .unwrap_or(false) - }, - InputAction::Continue => { - if let Some(current_text) = state.editing_input_text() { - if let Some((cmd_text, chain_type)) = - state::detect_chain_operator_at_end(¤t_text) - { - let mut should_add_empty_line = true; - if let Some(path) = state::detect_cd_command(&cmd_text) { - if let Some(new_cwd) = - path_formatting::resolve_path(state.cwd.as_ref(), &path) - { - let remaining_text = - state::get_remaining_after_first_segment(¤t_text) - .unwrap_or_else(|| "".to_owned()); - if remaining_text.len() > 0 { - should_add_empty_line = false; - } - state.current_selected_command_mut().map(|c| { - c.set_cwd(Some(new_cwd)); - c.set_text(remaining_text); - }); - } - } else { - state.current_selected_command_mut().map(|c| { - c.set_text(cmd_text); - c.set_chain_type(chain_type); - }); - }; - state.cancel_editing_selected(); - if should_add_empty_line { - state.add_empty_command_after_current_selected(); - } - state.start_editing_selected(); - } - } - - // Handle backspace on empty command - if is_backspace { - let is_empty = state.current_selected_command_is_empty() - || state - .editing_input_text() - .map(|i| i.is_empty()) - .unwrap_or(false); - let has_more_than_one_command = state.execution.all_commands.len() > 1; - - if is_empty && has_more_than_one_command { - let current_index = - state.selection.current_selected_command_index.unwrap_or(0); - if current_index > 0 { - state.remove_current_selected_command(); - return true; - } - } else if is_empty { - // last command - close_panes_and_return_to_shell(state); - } - } + let chain_type = state.execution.all_commands[cmd_index].get_chain_type(); + let should_continue = match chain_type { + ChainType::And => exit_code.unwrap_or(0) == 0, + ChainType::Or => exit_code.unwrap_or(0) != 0, + ChainType::Then => true, + ChainType::None => false, + }; - true - }, - InputAction::NoAction => false, - } + let next_index = cmd_index + 1; + if should_continue && next_index < state.execution.all_commands.len() { + launch_command_at_index(state, next_index, Some(pane_id), false); } else { - false - } -} - -/// Handle Enter key - submit and execute the command sequence -fn handle_submit(state: &mut State) -> bool { - let cwd = state.cwd.clone(); - if state.handle_editing_submit(&cwd) { - // handled the command internally (eg. cd) no need to run sequence - return true; - } - - if state.execution.all_commands.len() == 1 && state.can_run_sequence() { - rerun_sequence(state); - } else if state.execution.all_commands.len() == 1 { - state.move_selection_down(); - state.start_editing_selected(); + state.execution.is_running = false; + state.selection.current_selected_command_index = None; + // Unblock blocking CLI pipe if one is waiting + if let Some(pipe_id) = state.blocking_pipe_id.take() { + unblock_cli_pipe_input(&pipe_id); + } } true } -pub fn handle_pane_update(state: &mut State, pane_manifest: PaneManifest) -> bool { - // Store the manifest for later use - state.pane_manifest = Some(pane_manifest.clone()); - let mut needs_rerender = false; - if state.update_exited_command_statuses(&pane_manifest) { - needs_rerender = true; - } - if state.update_sequence_stopped_state() { - needs_rerender = true; - } - state.reposition_plugin(); - update_primary_and_original_pane_ids(state, &pane_manifest); - needs_rerender -} +fn handle_editor_pane_exited(state: &mut State, terminal_pane_id: u32) -> bool { + let pane_id = PaneId::Terminal(terminal_pane_id); -pub fn calculate_cursor_position(state: &mut State) -> Option<(usize, usize)> { - // Get pane dimensions from state (must be set by render) - let Some(cols) = state.own_columns else { - eprintln!("Warning: own_columns not set"); - return None; - }; - let Some(rows) = state.own_rows else { - eprintln!("Warning: own_rows not set"); - return None; - }; + // Ignore events from unrelated panes + if state.editor_pane_id != Some(pane_id) { + return false; + } - // Only show cursor if in edit mode - let (text, cursor_pos) = if let Some(text_input) = &state.editing.editing_input { - ( - text_input.get_text().to_string(), - text_input.cursor_position(), - ) + // Read the temp file contents + let contents = if let Some(ref path) = state.editor_temp_file { + std::fs::read_to_string(path).unwrap_or_default() } else { - return None; - }; - - let Some(edit_index) = state.selection.current_selected_command_index else { - return None; + String::new() }; - // Calculate folder column width using the longest cwd across all commands - let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd); - let folder_display = format!("{} >", longest_cwd_display); - let folder_col_width = folder_display.width().max(1); - - let base_x = 1; - let base_y = 0; - - adjust_scroll_offset(state, rows); - - let max_visible_rows = state.own_rows.map(|r| r.saturating_sub(5)).unwrap_or(0); - - let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport( - state.execution.all_commands.len(), - max_visible_rows, - state.selection.current_selected_command_index, - state.execution.current_running_command_index, - ); + // Delete the temp file + if let Some(ref path) = state.editor_temp_file { + let _ = std::fs::remove_file(path); + } - let (max_chain_width, max_status_width) = - components::calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame); + // Clear editor state + state.editor_pane_id = None; + state.editor_temp_file = None; - let Some((_, available_cmd_width)) = components::calculate_row_layout_info( - edit_index, - offset, - visible_count, - hidden_above, - hidden_below, - cols, - folder_col_width, - max_chain_width, - max_status_width, - ) else { - return None; - }; + // Parse file into commands + let commands = parse_commands(&contents, state.cwd.clone()); - // Get cursor position in truncated text - let (_, cursor_in_truncated) = truncate_middle(&text, available_cmd_width, Some(cursor_pos)); - let Some(cursor_in_truncated) = cursor_in_truncated else { - return None; - }; - - // Calculate relative coordinates within the visible table - let relative_y = edit_index.saturating_sub(offset) + 1; // +1 for header row - let relative_x = folder_col_width + 1 + cursor_in_truncated; + if !commands.is_empty() { + state.execution.all_commands = commands; + state.execution.current_running_command_index = 0; + state.selection.current_selected_command_index = None; + state.execution.is_running = false; + } else { + // Reset to empty pending state + state.execution.all_commands = vec![CommandEntry::new("", state.cwd.clone())]; + state.execution.current_running_command_index = 0; + state.selection.current_selected_command_index = None; + state.execution.is_running = false; + } - let x = base_x + relative_x; - let y = base_y + relative_y; + show_self(true); - Some((x, y)) + true } -pub fn handle_action_complete( +fn launch_command_at_index( state: &mut State, - pane_id: Option<PaneId>, - context: BTreeMap<String, String>, -) -> bool { - // Update primary_pane_id to the pane that just completed the action - // This ensures we always have a valid pane to target for InPlace launches - // But only if it's not a floating pane - if let (Some(pane_id), Some(manifest)) = (pane_id, &state.pane_manifest) { - if !is_pane_floating(pane_id, manifest) { - state.primary_pane_id = Some(pane_id); - } else { - eprintln!("Not setting primary_pane_id to floating pane {:?}", pane_id); - } - } - - if !is_running_sequence(&context, state.execution.sequence_id) { - // action from previous sequence or unrelated - return false; - } - - // If the sequence has been stopped (e.g., via Ctrl+C), don't continue - if !state.execution.is_running { - return true; // Re-render to show the stopped state - } - - // Move to the next command - let next_index = state.execution.current_running_command_index + 1; - - if next_index < state.execution.all_commands.len() { - // Execute the next command - // let (next_command, next_chain_type) = &state.execution.all_commands[next_index]; - let Some(next_command) = state.execution.all_commands.get(next_index) else { - // invalid state - return true; - }; - let next_chain_type = next_command.get_chain_type(); - let next_command_cwd = next_command.get_cwd(); - let next_command_text = next_command.get_text(); - - let shell = state + index: usize, + replace_pane_id: Option<PaneId>, + force_visible: bool, +) { + let cmd = &state.execution.all_commands[index]; + let cmd_text = cmd.get_text().trim().to_string(); + let command = CommandToRun { + path: state .shell .clone() - .unwrap_or_else(|| PathBuf::from("/bin/bash")); - - let command = zellij_tile::prelude::actions::RunCommandAction { - command: shell, - args: vec!["-ic".to_string(), next_command_text.trim().to_string()], - cwd: next_command_cwd, - hold_on_close: true, - ..Default::default() - }; - - // Determine placement based on layout mode - let placement = NewPanePlacement::Stacked { - pane_id_to_stack_under: pane_id, - borderless: Some(false), - }; - - // Update status: mark current command as completed - // Use the pane_id from ActionComplete if the status is still Pending - let pane_id_to_mark = state - .execution - .all_commands - .get(state.execution.current_running_command_index) - .and_then(|c| match c.get_status() { - CommandStatus::Running(pid) => pid, - CommandStatus::Pending => pane_id, // Use the pane from ActionComplete - CommandStatus::Exited(_, pid) => pid, // Already marked, keep it - CommandStatus::Interrupted(pid) => pid, // Already marked, keep it - }); - - state - .execution - .all_commands - .get_mut(state.execution.current_running_command_index) - .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark))); - state.execution.current_running_command_index = next_index; - - // Determine unblock_condition based on whether this is the last command - let unblock_condition = if next_index < state.execution.all_commands.len() - 1 { - // Not the last command - use the chain type's unblock condition - next_chain_type.to_unblock_condition() - } else { - // Last command - use UnblockCondition::OnAnyExit - Some(UnblockCondition::OnAnyExit) - }; - - let action = Action::NewBlockingPane { - placement, - command: Some(command), - pane_name: Some(next_command_text.trim().to_string()), - unblock_condition, - near_current_pane: true, - }; - - // Pass the sequence ID in the context - let mut context = BTreeMap::new(); - context.insert( - "sequence_id".to_string(), - state.execution.sequence_id.to_string(), - ); - context.insert("command_text".to_string(), next_command_text.to_string()); - - // Put the sequence back with updated index - run_action(action, context); + .unwrap_or_else(|| PathBuf::from("/bin/bash")), + args: vec!["-ic".to_string(), cmd_text.clone()], + cwd: cmd.get_cwd(), + }; + let user_is_watching = + replace_pane_id.is_some() && replace_pane_id == state.execution.displayed_pane_id; + let new_pane_id = if state.mode == SequenceMode::Spread { + open_command_pane_near_plugin(command, BTreeMap::new()) + } else if user_is_watching { + let prev_pane_id = replace_pane_id.unwrap(); + let pane_id = + open_command_pane_in_place_of_pane_id(prev_pane_id, command, false, BTreeMap::new()); + if let Some(pane_id) = pane_id { + state.execution.displayed_pane_id = Some(pane_id); + } + pane_id + } else if force_visible { + let (tab_id, pane_id) = open_command_pane_in_new_tab(command, BTreeMap::new()); + if let Some(pane_id) = pane_id { + state.execution.displayed_pane_id = Some(pane_id); + } + if let (Some(tab_id), Some(plugin_id)) = (tab_id, state.plugin_id) { + state.sequence_tab_id = Some(tab_id); + break_panes_to_tab_with_id(&[PaneId::Plugin(plugin_id)], tab_id, true); + focus_pane_with_id(PaneId::Plugin(plugin_id), false, false); + } + pane_id } else { - // Sequence complete - mark the last command as Exited - - let pane_id_to_mark = state - .execution - .all_commands - .get(state.execution.current_running_command_index) - .and_then(|c| match c.get_status() { - CommandStatus::Running(pid) => pid, - CommandStatus::Pending => pane_id, // Use the pane from ActionComplete - CommandStatus::Exited(_, pid) => pid, // Already marked, keep it - CommandStatus::Interrupted(pid) => pid, // Already marked, keep it - }); - - state - .execution - .all_commands - .get_mut(state.execution.current_running_command_index) - .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark))); + // User navigated away — open in background, invisible until they navigate to it + open_command_pane_background(command, BTreeMap::new()) + }; + if let Some(pane_id) = new_pane_id { + rename_pane_with_id(pane_id, &cmd_text); + state.execution.all_commands[index].set_status(CommandStatus::Running(Some(pane_id))); + state.execution.current_running_command_index = index; + state.selection.current_selected_command_index = Some(index); + state.show_selected_pane(); } - true } -/// Adjust scroll offset to keep selected or running command visible -pub fn adjust_scroll_offset(sequence: &mut crate::state::State, rows: usize) { - let max_visible = rows.saturating_sub(6); - let total_commands = sequence.execution.all_commands.len(); - - if total_commands <= max_visible { - sequence.selection.scroll_offset = 0; - return; - } - - let focus_index = sequence - .selection - .current_selected_command_index - .unwrap_or(sequence.execution.current_running_command_index); - let half_visible = max_visible / 2; - - if focus_index < half_visible { - sequence.selection.scroll_offset = 0; - } else if focus_index >= total_commands.saturating_sub(half_visible) { - sequence.selection.scroll_offset = total_commands.saturating_sub(max_visible); - } else { - sequence.selection.scroll_offset = focus_index.saturating_sub(half_visible); +fn handle_key_event(state: &mut State, key: KeyWithModifier) -> bool { + // Ctrl+Space — copy to clipboard + if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) { + state.copy_to_clipboard(); + return false; } - sequence.selection.scroll_offset = sequence - .selection - .scroll_offset - .min(total_commands.saturating_sub(max_visible)); -} - -pub fn is_pane_floating(pane_id: PaneId, pane_manifest: &PaneManifest) -> bool { - pane_manifest - .panes - .iter() - .flat_map(|(_, panes)| panes.iter()) - .find(|pane_info| match pane_id { - PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id, - PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id, - }) - .map(|pane_info| pane_info.is_floating) - .unwrap_or(false) -} - -fn is_running_sequence(context: &BTreeMap<String, String>, running_sequence_id: u64) -> bool { - if let Some(context_sequence_id) = context.get("sequence_id") { - if let Ok(context_id) = context_sequence_id.parse::<u64>() { - if context_id != running_sequence_id { - // This action is from a different sequence, ignore it - return false; - } else { - return true; + // Ctrl+W — close tab if sequence has run; close plugin if staging; no-op if running + if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('w')) { + if state.all_commands_are_pending() { + if let Some(plugin_id) = state.plugin_id { + close_pane_with_id(PaneId::Plugin(plugin_id)); } - } else { - // Failed to parse sequence_id, ignore this action - return false; + } else if !state.execution.is_running { + close_panes_and_return_to_shell(state); } - } else { - return false; + return true; } -} - -fn update_primary_and_original_pane_ids(state: &mut State, pane_manifest: &PaneManifest) { - if let Some(primary_pane_id) = state.primary_pane_id { - let mut primary_pane_found = false; - // Check if the primary pane still exists - for (_tab_index, panes) in &pane_manifest.panes { - for pane_info in panes { - let pane_matches = match primary_pane_id { - PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id, - PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id, - }; - - if pane_matches { - primary_pane_found = true; - break; - } - } - if primary_pane_found { - break; + // Ctrl+C — interrupt if running; clear commands if staging; close plugin if empty + if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('c')) { + if state.execution.is_running { + interrupt_sequence(state); + return true; + } else if state.all_commands_are_pending() { + if state.execution.all_commands.iter().any(|c| !c.is_empty()) { + state.clear_all_commands(); + } else if let Some(plugin_id) = state.plugin_id { + close_pane_with_id(PaneId::Plugin(plugin_id)); } - } - - // If primary pane was not found, select a new one - if !primary_pane_found { - state.primary_pane_id = None; + return true; } } - if let Some(original_pane_id) = state.original_pane_id { - let mut original_pane_found = false; - // Check if the original pane still exists - for (_tab_index, panes) in &pane_manifest.panes { - for pane_info in panes { - let pane_matches = match original_pane_id { - PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id, - PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id, - }; + let is_staging = state.all_commands_are_pending() && state.execution.can_run_sequence(); - if pane_matches { - original_pane_found = true; - break; - } - } - if original_pane_found { - break; - } - } - - // If original pane was not found, select a new one - if !original_pane_found { - state.original_pane_id = None; + if matches!(key.bare_key, BareKey::Up) + && !key.has_modifiers(&[KeyModifier::Ctrl]) + && !key.has_modifiers(&[KeyModifier::Alt]) + { + if !is_staging { + state.move_selection_up(); + return true; } + return false; } -} -pub fn select_primary_pane_from_history( - pane_history: &[PaneId], - pane_manifest: &PaneManifest, - own_pane_id: Option<PaneId>, -) -> Option<PaneId> { - pane_history - .iter() - .rev() // Most recent first - .find(|&id| { - // Skip self - if Some(*id) == own_pane_id { - return false; - } - - // Skip floating panes - !is_pane_floating(*id, pane_manifest) - }) - .copied() -} - -fn close_panes_and_return_to_shell(state: &mut State) -> bool { - // Close all panes in the sequence before clearing it - // Handle the first pane: replace it with the primary_pane_id_before_sequence if available - if let Some(first_pane_id) = state.execution.all_commands.iter().find_map(|command| { - // we look for the first command in the sequence that has a pane that's - // actually open - match command.get_status() { - CommandStatus::Running(pane_id) => pane_id, - CommandStatus::Exited(_, pane_id) => pane_id, - CommandStatus::Interrupted(pane_id) => pane_id, - _ => None, - } - }) { - if let Some(original_pane_id) = state.original_pane_id { - replace_pane_with_existing_pane( - first_pane_id, - original_pane_id, - true, // suppress_replaced_pane (closes the first pane) - ); - } else { - // Fallback: if we don't have a saved primary pane, just close it - close_pane_with_id(first_pane_id); + if matches!(key.bare_key, BareKey::Down) + && !key.has_modifiers(&[KeyModifier::Ctrl]) + && !key.has_modifiers(&[KeyModifier::Alt]) + { + if !is_staging { + state.move_selection_down(); + return true; } + return false; } - // Close all other panes (starting from index 1) - for (idx, command) in state.execution.all_commands.iter().skip(1).enumerate() { - let pane_id = match command.get_status() { - CommandStatus::Running(pane_id) => pane_id, - CommandStatus::Exited(_, pane_id) => pane_id, - CommandStatus::Interrupted(pane_id) => pane_id, - _ => None, - }; + // e — open external editor (when not running) + if !state.execution.is_running + && key.has_no_modifiers() + && matches!(key.bare_key, BareKey::Char('e')) + { + state.open_editor(); + return true; + } - if let Some(pane_id) = pane_id { - close_pane_with_id(pane_id); - } else { - eprintln!( - "Warning: Cannot close pane at index {}: pane_id is None", - idx + 1 - ); - } + // Enter — rerun sequence + if key.has_no_modifiers() && matches!(key.bare_key, BareKey::Enter) { + rerun_sequence(state); + return true; } - state.clear_all_commands(); - // Transition back to shell screen - if let Some(plugin_id) = state.plugin_id { - change_floating_pane_coordinates( - plugin_id, - Some(25), - Some(25), - Some(50), - Some(50), - false, // pinned (this should unpin it) - ); + // Tab — toggle spread mode (when sequence has run) + if key.has_no_modifiers() + && matches!(key.bare_key, BareKey::Tab) + && !state.all_commands_are_pending() + { + toggle_spread_mode(state); + return true; } - update_title(state); - true + + false } -fn change_floating_pane_coordinates( - own_plugin_id: u32, - x: Option<usize>, - y: Option<usize>, - width: Option<usize>, - height: Option<usize>, - should_be_pinned: bool, -) { - let coordinates = FloatingPaneCoordinates::new( - x.map(|x| format!("{}%", x)), - y.map(|y| format!("{}%", y)), - width.map(|width| format!("{}%", width)), - height.map(|height| format!("{}%", height)), - Some(should_be_pinned), - Some(false), - ); - if let Some(coordinates) = coordinates { - // TODO: better - // show_self(true); - change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]); +fn close_panes_and_return_to_shell(state: &mut State) -> bool { + if let Some(tab_id) = state.sequence_tab_id { + close_tab_with_id(tab_id as u64); } + true } fn update_spinner(state: &mut State) -> bool { @@ -906,7 +523,7 @@ fn update_spinner(state: &mut State) -> bool { .all_commands .iter() .any(|command| matches!(command.get_status(), CommandStatus::Running(_))); - if has_running && !state.layout.spinner_timer_scheduled { + if has_running { set_timeout(0.1); state.layout.spinner_timer_scheduled = true; } @@ -930,148 +547,134 @@ fn rerun_sequence(state: &mut State) { .unwrap_or(0) .min(state.execution.all_commands.len().saturating_sub(1)); - let mut close_replaced_pane = true; + state.execution.is_running = true; + state.execution.current_running_command_index = selected_index; + ensure_spinner_running(state); + match state.execution.all_commands[selected_index].get_pane_id() { + Some(PaneId::Terminal(id)) => { + let pane_id = PaneId::Terminal(id); - // Extract the selected pane's ID BEFORE resetting statuses (needed for in-place replacement) - let selected_pane_id_for_replacement = &state - .execution - .all_commands - .get(selected_index) - .and_then(|c| match c.get_status() { - CommandStatus::Running(pane_id) => pane_id, - CommandStatus::Exited(_, pane_id) => pane_id, - CommandStatus::Interrupted(pane_id) => pane_id, - _ => None, - }) - .or_else(|| { - if state.all_commands_are_pending() { - close_replaced_pane = false; // we should never close the original pane_id - state.original_pane_id - } else { - None + // Replace the displayed pane with the starting pane first, while + // displayed_pane_id is still valid. The replaced pane is suppressed + // and can then be closed safely below. + if state.mode == SequenceMode::SinglePane { + if let Some(displayed) = state.execution.displayed_pane_id { + if displayed != pane_id { + replace_pane_with_existing_pane(displayed, pane_id, true); + } + } + state.execution.displayed_pane_id = Some(pane_id); } - }); - - // Close all panes AFTER the selected one (selected will be replaced in-place) - for i in (selected_index + 1)..state.execution.all_commands.len() { - // Extract pane_id from the command status - let pane_id = state - .execution - .all_commands - .get(i) - .and_then(|c| match c.get_status() { - CommandStatus::Running(pane_id) => pane_id, - CommandStatus::Exited(_, pane_id) => pane_id, - CommandStatus::Interrupted(pane_id) => pane_id, - _ => None, - }); - // Close the pane if we have a pane_id - if let Some(pane_id) = pane_id { - close_pane_with_id(pane_id); - } else { - eprintln!( - "Warning: Cannot close pane for command at index {}: pane_id is None", - i - ); - } + // Close and reset commands after selected (now suppressed). + for i in (selected_index + 1)..state.execution.all_commands.len() { + if let Some(p) = state.execution.all_commands[i].get_pane_id() { + close_pane_with_id(p); + } + state.execution.all_commands[i].set_status(CommandStatus::Pending); + } - // Reset the command status to Pending - state - .execution - .all_commands - .get_mut(i) - .map(|c| c.set_status(CommandStatus::Pending)); + state.execution.all_commands[selected_index] + .set_status(CommandStatus::Running(Some(pane_id))); + state.selection.current_selected_command_index = Some(selected_index); + rerun_command_pane(id); + }, + _ => { + // Close and reset commands after selected. + for i in (selected_index + 1)..state.execution.all_commands.len() { + if let Some(p) = state.execution.all_commands[i].get_pane_id() { + close_pane_with_id(p); + } + state.execution.all_commands[i].set_status(CommandStatus::Pending); + } + let replace_pane_id = state.execution.displayed_pane_id; + launch_command_at_index(state, selected_index, replace_pane_id, true); + }, } +} - // Reset the selected pane's status to Pending (it will be replaced in-place) - state - .execution - .all_commands - .get_mut(selected_index) - .map(|c| c.set_status(CommandStatus::Pending)); +fn toggle_spread_mode(state: &mut State) { + match state.mode { + SequenceMode::SinglePane => enter_spread_mode(state), + SequenceMode::Spread => exit_spread_mode(state), + } +} - // Set the current command index to the selected command - state.execution.current_running_command_index = selected_index; +fn enter_spread_mode(state: &mut State) { + state.mode = SequenceMode::Spread; - // Execute the command at the selected index - if let Some((command_text, chain_type, command_cwd)) = state + let first_pane_id = state .execution .all_commands - .get(selected_index) - .map(|c| (c.get_text(), c.get_chain_type(), c.get_cwd())) - { - let shell = state - .shell - .clone() - .unwrap_or_else(|| PathBuf::from("/bin/bash")); + .first() + .and_then(|c| c.get_pane_id()); + let Some(first_pane_id) = first_pane_id else { + return; + }; - let command = zellij_tile::prelude::actions::RunCommandAction { - command: shell, - args: vec!["-ic".to_string(), command_text.trim().to_string()], - cwd: command_cwd, - hold_on_close: true, - ..Default::default() - }; + if let Some(displayed) = state.execution.displayed_pane_id { + if displayed != first_pane_id { + replace_pane_with_existing_pane(displayed, first_pane_id, true); + } + } + state.execution.displayed_pane_id = Some(first_pane_id); - // Determine placement - always replace the selected pane in-place if we have its ID - let placement = if let Some(pane_id) = selected_pane_id_for_replacement { - NewPanePlacement::InPlace { - pane_id_to_replace: Some(*pane_id), - close_replaced_pane, - borderless: Some(false), - } - } else { - let pane_id_to_stack_under = state - .execution - .all_commands - .iter() - .find_map(|c| c.get_pane_id()); - NewPanePlacement::Stacked { - pane_id_to_stack_under, - borderless: Some(false), - } - }; + for cmd in state.execution.all_commands.iter().skip(1) { + if let Some(pane_id) = cmd.get_pane_id() { + show_pane_with_id(pane_id, false, false); + } + } + state.show_selected_pane(); +} - // Determine unblock_condition based on whether this is the last command - let unblock_condition = if selected_index < state.execution.all_commands.len() - 1 { - // Not the last command - use the chain type's unblock condition - chain_type.to_unblock_condition() - } else { - // Last command - use UnblockCondition::OnAnyExit - Some(UnblockCondition::OnAnyExit) - }; +fn exit_spread_mode(state: &mut State) { + // Remove all highlights before leaving spread mode + let all_pane_ids: Vec<PaneId> = state + .execution + .all_commands + .iter() + .filter_map(|c| c.get_pane_id()) + .collect(); + if !all_pane_ids.is_empty() { + highlight_and_unhighlight_panes(vec![], all_pane_ids); + } + state.mode = SequenceMode::SinglePane; - let action = Action::NewBlockingPane { - placement, - command: Some(command), - pane_name: Some(command_text.trim().to_string()), - unblock_condition, - near_current_pane: true, - }; + let first_pane_id = state + .execution + .all_commands + .first() + .and_then(|c| c.get_pane_id()); + let selected_pane_id = state + .selection + .current_selected_command_index + .and_then(|i| state.execution.all_commands.get(i)) + .and_then(|c| c.get_pane_id()) + .or(first_pane_id); - // Generate a new sequence ID for the restart - state.execution.sequence_id += 1; + let Some(selected_pane_id) = selected_pane_id else { + return; + }; - // Reset the stopped flag so the sequence can run - state.execution.is_running = true; - state.layout.needs_reposition = true; - state.execution.primary_pane_id_before_sequence = state.primary_pane_id; + if let Some(first_pane_id) = first_pane_id { + if first_pane_id != selected_pane_id { + replace_pane_with_existing_pane(first_pane_id, selected_pane_id, true); + } + } - // Pass the sequence ID in the context - let mut context = BTreeMap::new(); - context.insert( - "sequence_id".to_string(), - state.execution.sequence_id.to_string(), - ); - context.insert("command_text".to_string(), command_text.to_string()); - run_action(action, context); + for cmd in &state.execution.all_commands { + if let Some(pane_id) = cmd.get_pane_id() { + if pane_id != selected_pane_id { + hide_pane_with_id(pane_id); + } + } } + + state.execution.displayed_pane_id = Some(selected_pane_id); } fn interrupt_sequence(state: &mut State) { if state.execution.is_running { - // Ctrl-C press when running - interrupt the running command for command in state.execution.all_commands.iter_mut() { if let CommandStatus::Running(pane_id) = command.get_status() { if let Some(pane_id) = pane_id { @@ -1081,59 +684,115 @@ fn interrupt_sequence(state: &mut State) { } } state.execution.is_running = false; + state.selection.current_selected_command_index = None; + // Unblock blocking CLI pipe if interrupted + if let Some(pipe_id) = state.blocking_pipe_id.take() { + unblock_cli_pipe_input(&pipe_id); + } } else { eprintln!("Cannot interrupt a sequence that is not running."); } } -pub fn update_cursor(state: &mut State) { - // Calculate new cursor position - let new_coords = calculate_cursor_position(state); - - // Only update if the position changed - if new_coords != state.layout.cached_cursor_position { - match new_coords { - Some(coords) => show_cursor(Some(coords)), - None => show_cursor(None), - } - state.layout.cached_cursor_position = new_coords; - } -} - pub fn update_title(state: &mut State) { - let Some(plugin_id) = state.plugin_id else { + let Some(tab_id) = state.sequence_tab_id else { return; }; + let tab_id_u64 = tab_id as u64; - let title = if state.all_commands_are_pending() { - "Run one or more commands in sequence" - } else { - let has_running_commands = state - .execution - .all_commands - .iter() - .any(|c| matches!(c.get_status(), CommandStatus::Running(..))); - let has_interrupted_commands = state + // All pending and not yet running + if state.all_commands_are_pending() && !state.execution.is_running { + rename_tab_with_id(tab_id_u64, "Run one or more commands in sequence"); + return; + } + + // Currently running: "<cmd> <n>/<total> <spinner>" + let has_running = state + .execution + .all_commands + .iter() + .any(|c| matches!(c.get_status(), CommandStatus::Running(..))); + if has_running { + let idx = state.execution.current_running_command_index; + let total = state.execution.all_commands.len(); + let cmd_text = state .execution .all_commands - .iter() - .any(|c| matches!(c.get_status(), CommandStatus::Interrupted(..))); - let all_commands_complete = state + .get(idx) + .map(|c| c.get_text()) + .unwrap_or_default(); + let spinner = components::get_spinner_frame(state.layout.spinner_frame); + rename_tab_with_id( + tab_id_u64, + &format!("{} {}/{} {}", cmd_text, idx + 1, total, spinner), + ); + return; + } + + // Interrupted: "<cmd> [INTERRUPTED]" + if let Some(cmd) = state + .execution + .all_commands + .iter() + .find(|c| matches!(c.get_status(), CommandStatus::Interrupted(_))) + { + let status_str = + components::format_status_text(&cmd.get_status(), state.layout.spinner_frame); + rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str)); + return; + } + + // All exited + let all_exited = state + .execution + .all_commands + .iter() + .all(|c| matches!(c.get_status(), CommandStatus::Exited(..))); + if all_exited { + let all_success = state .execution .all_commands .iter() - .all(|c| matches!(c.get_status(), CommandStatus::Exited(..))); - if has_running_commands { - let current = state.execution.current_running_command_index + 1; - let total = state.execution.all_commands.len(); - return rename_plugin_pane(plugin_id, &format!("Running {}/{}", current, total)); - } else if has_interrupted_commands { - "Sequence Interrupted" - } else if all_commands_complete { - "Sequence Complete" + .all(|c| matches!(c.get_status(), CommandStatus::Exited(Some(0), _))); + if all_success { + rename_tab_with_id(tab_id_u64, "Sequence Complete"); } else { - "Stopped" + // Show first command with non-zero or unknown exit + if let Some(cmd) = state + .execution + .all_commands + .iter() + .find(|c| !matches!(c.get_status(), CommandStatus::Exited(Some(0), _))) + { + let status_str = + components::format_status_text(&cmd.get_status(), state.layout.spinner_frame); + rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str)); + } else { + rename_tab_with_id(tab_id_u64, "Sequence Complete"); + } } - }; - rename_plugin_pane(plugin_id, title); + return; + } + + // Partial (chain condition failed): show last exited command with its status + if let Some(cmd) = state + .execution + .all_commands + .iter() + .rev() + .find(|c| matches!(c.get_status(), CommandStatus::Exited(..))) + { + let status_str = + components::format_status_text(&cmd.get_status(), state.layout.spinner_frame); + rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str)); + } else { + rename_tab_with_id(tab_id_u64, "Stopped"); + } +} + +fn ensure_spinner_running(state: &mut State) { + if !state.layout.spinner_timer_scheduled { + set_timeout(0.1); + state.layout.spinner_timer_scheduled = true; + } } diff --git a/default-plugins/sequence/src/state/command_entry.rs b/default-plugins/sequence/src/state/command_entry.rs index 96a0020b..4ca9350a 100644 --- a/default-plugins/sequence/src/state/command_entry.rs +++ b/default-plugins/sequence/src/state/command_entry.rs @@ -1,6 +1,5 @@ use super::{ChainType, CommandStatus}; use std::path::PathBuf; -use std::time::Instant; use zellij_tile::prelude::PaneId; #[derive(Debug, Clone)] @@ -9,7 +8,6 @@ pub struct CommandEntry { cwd: Option<PathBuf>, pub(super) chain_type: ChainType, pub(super) status: CommandStatus, - pub(super) start_time: std::time::Instant, } impl Default for CommandEntry { @@ -19,7 +17,6 @@ impl Default for CommandEntry { cwd: None, chain_type: ChainType::default(), status: CommandStatus::default(), - start_time: Instant::now(), } } } @@ -32,10 +29,6 @@ impl CommandEntry { ..Default::default() } } - pub fn with_and(mut self) -> Self { - self.chain_type = ChainType::And; - self - } pub fn get_text(&self) -> String { self.text.clone() } @@ -74,9 +67,6 @@ impl CommandEntry { pub fn clear_status(&mut self) { self.status = CommandStatus::Pending; } - pub fn cycle_chain_type(&mut self) { - self.chain_type.cycle_next(); - } pub fn get_cwd(&self) -> Option<PathBuf> { self.cwd.clone() } diff --git a/default-plugins/sequence/src/state/command_parser.rs b/default-plugins/sequence/src/state/command_parser.rs index 7f0898f5..35935cd3 100644 --- a/default-plugins/sequence/src/state/command_parser.rs +++ b/default-plugins/sequence/src/state/command_parser.rs @@ -1,4 +1,4 @@ -use super::ChainType; +use super::{ChainType, CommandEntry}; #[derive(Debug, Clone, Copy, PartialEq)] enum ParseState { @@ -104,63 +104,105 @@ pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)> segments } -pub fn detect_chain_operator_at_end(text: &str) -> Option<(String, ChainType)> { - let segments = split_by_chain_operators(text); - - if segments.is_empty() { - return None; - } +pub fn detect_cd_command(text: &str) -> Option<String> { + let trimmed = text.trim(); - if segments.len() == 1 { - if let Some((text, Some(chain_type))) = segments.first() { - return Some((text.clone(), *chain_type)); - } - return None; + if trimmed == "cd" { + return Some("~".to_string()); } - if let Some((first_text, Some(chain_type))) = segments.first() { - return Some((first_text.clone(), *chain_type)); + if trimmed.starts_with("cd ") { + let path = trimmed[3..].trim(); + return Some(path.to_string()); } None } -pub fn get_remaining_after_first_segment(text: &str) -> Option<String> { - let segments = split_by_chain_operators(text); +/// Serialize a sequence of commands to editor format. +/// CWD changes are emitted as `cd <path>` lines; chain type as trailing ` &&`, ` ||`, ` ;`. +pub fn serialize_sequence_to_editor(commands: &[CommandEntry]) -> String { + let mut result = String::new(); - if segments.len() <= 1 { - return None; - } + result.push_str("# One cmd per line. Use && || ; to chain. cd <path> sets cwd.\n"); + result.push_str("\n"); - let mut result = String::new(); - for (i, (segment_text, chain_type_opt)) in segments.iter().enumerate().skip(1) { - if i > 1 { - if let Some(chain_type) = chain_type_opt { - result.push_str(&format!(" {} ", chain_type.as_str())); - } + let mut prev_cwd: Option<std::path::PathBuf> = None; + + for cmd in commands { + if cmd.get_text().trim().is_empty() { + continue; } - result.push_str(segment_text); - if let Some(chain_type) = chain_type_opt { - result.push_str(&format!(" {} ", chain_type.as_str())); + + let cmd_cwd = cmd.get_cwd(); + if cmd_cwd != prev_cwd && cmd_cwd.is_some() { + let path = cmd_cwd.as_ref().unwrap(); + result.push_str(&format!("cd {}\n", path.display())); } + prev_cwd = cmd_cwd; + + let op_suffix = match cmd.get_chain_type() { + ChainType::And => " &&", + ChainType::Or => " ||", + ChainType::Then => " ;", + ChainType::None => "", + }; + + result.push_str(&format!("{}{}\n", cmd.get_text(), op_suffix)); } - Some(result.trim().to_string()) + result } -pub fn detect_cd_command(text: &str) -> Option<String> { - let trimmed = text.trim(); +/// Parse command text into a Vec<CommandEntry>. +/// `cd <path>` lines set the cwd for subsequent commands. +/// Blank lines and `#` comment lines are ignored. +pub fn parse_commands( + contents: &str, + initial_cwd: Option<std::path::PathBuf>, +) -> Vec<CommandEntry> { + use crate::path_formatting; + let mut commands = Vec::new(); + let mut current_cwd = initial_cwd; - if trimmed == "cd" { - return Some("~".to_string()); + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Split by chain operators first so that "cd /path &&" doesn't absorb the operator + // into the path. Each resulting segment is then checked for cd independently. + let segments = split_by_chain_operators(trimmed); + for (text, chain_type_opt) in segments { + let text = text.trim().to_string(); + if text.is_empty() { + continue; + } + if let Some(path) = detect_cd_command(&text) { + if let Some(new_cwd) = path_formatting::resolve_path(current_cwd.as_ref(), &path) { + current_cwd = Some(new_cwd); + } + // cd segments are not added as command entries; their chain type is discarded + } else { + let mut entry = CommandEntry::new(&text, current_cwd.clone()); + if let Some(chain_type) = chain_type_opt { + entry.set_chain_type(chain_type); + } + commands.push(entry); + } + } } - if trimmed.starts_with("cd ") { - let path = trimmed[3..].trim(); - return Some(path.to_string()); + // Commands with no explicit operator default to && + if !commands.is_empty() { + let last_idx = commands.len() - 1; + for cmd in &mut commands[..last_idx] { + cmd.fill_chain_type_if_empty(); + } } - None + commands } #[cfg(test)] @@ -278,36 +320,6 @@ mod tests { } #[test] - fn test_detect_operator_just_typed_and() { - let result = detect_chain_operator_at_end("ls &&"); - assert_eq!(result, Some(("ls".to_string(), ChainType::And))); - } - - #[test] - fn test_detect_operator_just_typed_or() { - let result = detect_chain_operator_at_end("cmd ||"); - assert_eq!(result, Some(("cmd".to_string(), ChainType::Or))); - } - - #[test] - fn test_detect_operator_just_typed_semicolon() { - let result = detect_chain_operator_at_end("echo ;"); - assert_eq!(result, Some(("echo".to_string(), ChainType::Then))); - } - - #[test] - fn test_detect_operator_no_operator() { - let result = detect_chain_operator_at_end("ls"); - assert_eq!(result, None); - } - - #[test] - fn test_detect_operator_in_quotes() { - let result = detect_chain_operator_at_end("echo '&&'"); - assert_eq!(result, None); - } - - #[test] fn test_whitespace_trimming() { let result = split_by_chain_operators(" cmd1 && cmd2 "); assert_eq!( @@ -324,4 +336,35 @@ mod tests { let result = split_by_chain_operators("ls && "); assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]); } + + #[test] + fn test_editor_file_no_operator_defaults_to_and() { + let contents = "cmd1\ncmd2\ncmd3"; + let result = parse_commands(contents, None); + assert_eq!(result.len(), 3); + assert_eq!(result[0].get_chain_type(), ChainType::And); + assert_eq!(result[1].get_chain_type(), ChainType::And); + assert_eq!(result[2].get_chain_type(), ChainType::None); + } + + #[test] + fn test_editor_file_explicit_operator_preserved() { + let contents = "cmd1 ||\ncmd2"; + let result = parse_commands(contents, None); + assert_eq!(result.len(), 2); + assert_eq!(result[0].get_chain_type(), ChainType::Or); + assert_eq!(result[1].get_chain_type(), ChainType::None); + } + + #[test] + fn test_cd_with_chain_operator_does_not_absorb_operator_into_path() { + use super::super::CommandEntry; + use std::path::PathBuf; + + let contents = "cd /some/path && cargo build"; + let result = parse_commands(contents, None); + assert_eq!(result.len(), 1); + assert_eq!(result[0].get_text(), "cargo build"); + assert_eq!(result[0].get_cwd(), Some(PathBuf::from("/some/path"))); + } } diff --git a/default-plugins/sequence/src/state/editing.rs b/default-plugins/sequence/src/state/editing.rs deleted file mode 100644 index 3deac619..00000000 --- a/default-plugins/sequence/src/state/editing.rs +++ /dev/null @@ -1,80 +0,0 @@ -use crate::path_formatting; -use crate::state::CommandEntry; -use crate::ui::text_input::TextInput; -use std::path::PathBuf; - -pub struct Editing { - pub editing_input: Option<TextInput>, -} - -impl Editing { - pub fn new() -> Self { - Self { - editing_input: Some(TextInput::new("".to_owned())), - } - } - - pub fn start_editing(&mut self, text: String) { - self.editing_input = Some(TextInput::new(text)); - } - - pub fn cancel_editing(&mut self) { - self.editing_input = None; - } - - pub fn input_text(&self) -> Option<String> { - self.editing_input.as_ref().map(|e| e.get_text().to_owned()) - } - - pub fn set_input_text(&mut self, text: String) { - self.editing_input.as_mut().map(|e| e.set_text(text)); - } - - pub fn handle_submit( - &mut self, - selection_index: Option<usize>, - all_commands: &mut Vec<CommandEntry>, - current_cwd: &Option<PathBuf>, - ) -> (bool, Option<usize>) { - let mut handled_internally = false; - let mut new_selection_index = None; - - if let Some(current_text) = self.editing_input.as_ref().map(|i| i.get_text()) { - if let Some(index) = selection_index { - if let Some(command) = all_commands.get_mut(index) { - if current_text.starts_with("cd ") || current_text == "cd" { - let path = if current_text == "cd" { - "~" - } else { - current_text[3..].trim() - }; - - if let Some(new_cwd) = - path_formatting::resolve_path(current_cwd.as_ref(), path) - { - command.set_cwd(Some(new_cwd)); - } - - command.set_text("".to_owned()); - self.editing_input.as_mut().map(|c| c.clear()); - new_selection_index = selection_index; // remain editing after a cd command - handled_internally = true; - return (handled_internally, new_selection_index); // avoid setting edit_input to None below, we - // still want to be in editing mode after - // changing directory with cd - } else { - command.set_text(current_text.to_owned()); - } - } - } - } - self.editing_input = None; - (handled_internally, new_selection_index) - } -} - -impl Default for Editing { - fn default() -> Self { - Self::new() - } -} diff --git a/default-plugins/sequence/src/state/execution.rs b/default-plugins/sequence/src/state/execution.rs index fb194f61..42d4112a 100644 --- a/default-plugins/sequence/src/state/execution.rs +++ b/default-plugins/sequence/src/state/execution.rs @@ -1,16 +1,13 @@ use crate::path_formatting::format_cwd; -use crate::state::{ChainType, CommandEntry, CommandStatus}; -use std::collections::BTreeMap; +use crate::state::{CommandEntry, CommandStatus}; use std::path::PathBuf; -use std::time::Duration; use zellij_tile::prelude::*; pub struct Execution { pub all_commands: Vec<CommandEntry>, pub current_running_command_index: usize, pub is_running: bool, - pub sequence_id: u64, - pub primary_pane_id_before_sequence: Option<PaneId>, + pub displayed_pane_id: Option<PaneId>, } impl Execution { @@ -19,8 +16,7 @@ impl Execution { all_commands: vec![CommandEntry::default()], current_running_command_index: 0, is_running: false, - sequence_id: 0, - primary_pane_id_before_sequence: None, + displayed_pane_id: None, } } @@ -82,181 +78,6 @@ impl Execution { copy_to_clipboard(text_to_copy); } } - - pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) { - for command in self.all_commands.iter_mut() { - if let CommandStatus::Pending | CommandStatus::Running(None) = command.get_status() { - let cmd_text = command.get_text(); - if cmd_text == command_text { - command.set_status(CommandStatus::Running(Some(pane_id))); - break; - } - } - } - } - - pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool { - let mut updated = false; - for command in self.all_commands.iter_mut() { - let status = command.get_status(); - let pane_id_opt = match &status { - CommandStatus::Running(pid) => *pid, - CommandStatus::Exited(_, pid) => *pid, - CommandStatus::Interrupted(pid) => *pid, - _ => None, - }; - - if let Some(pane_id) = pane_id_opt { - let mut pane_found = false; - for (_tab_index, panes) in &pane_manifest.panes { - for pane_info in panes { - let pane_matches = match pane_id { - PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id, - PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id, - }; - - if pane_matches { - pane_found = true; - if pane_info.exited { - command.set_status(CommandStatus::Exited( - pane_info.exit_status, - Some(pane_id), - )); - updated = true; - } - break; - } - } - if pane_found { - break; - } - } - - if !pane_found && command.start_time.elapsed() > Duration::from_millis(400) { - match command.get_status() { - CommandStatus::Running(_) => { - eprintln!( - "Pane {:?} was closed while running, setting pane_id to None", - pane_id - ); - command.set_status(CommandStatus::Running(None)); - updated = true; - }, - CommandStatus::Exited(exit_code, _) => { - eprintln!( - "Pane {:?} was closed after exiting, setting pane_id to None", - pane_id - ); - command.set_status(CommandStatus::Exited(exit_code, None)); - updated = true; - }, - CommandStatus::Interrupted(_) => { - eprintln!("Pane {:?} was closed after being interrupted, setting pane_id to None", pane_id); - command.set_status(CommandStatus::Interrupted(None)); - updated = true; - }, - _ => {}, - } - } - } - } - updated - } - - pub fn update_sequence_stopped_state(&mut self) -> bool { - let mut needs_rerender = false; - if self.is_running { - let current_idx = self.current_running_command_index; - if let Some(command) = self.all_commands.get_mut(current_idx) { - if let CommandStatus::Exited(exit_code, _) = command.get_status() { - let should_stop; - if current_idx >= self.all_commands.len().saturating_sub(1) { - should_stop = true; - } else { - if let Some(chain_type) = &self - .all_commands - .get(current_idx) - .map(|c| c.get_chain_type()) - { - match chain_type { - ChainType::And => { - should_stop = exit_code.unwrap_or(0) != 0; - }, - ChainType::Or => { - should_stop = exit_code.unwrap_or(0) == 0; - }, - ChainType::Then => { - should_stop = false; - }, - ChainType::None => { - should_stop = true; - }, - } - } else { - should_stop = true; - } - }; - if should_stop { - self.is_running = false; - } - } - } - needs_rerender = true; - } - needs_rerender - } - - pub fn execute_command_sequence( - &mut self, - shell: &Option<PathBuf>, - global_cwd: &Option<PathBuf>, - primary_pane_id: Option<PaneId>, - ) { - use zellij_tile::prelude::actions::{Action, RunCommandAction}; - - self.all_commands.retain(|c| !c.is_empty()); - - let Some(first_active_sequence_command) = self.get_first_command() else { - return; - }; - - let shell = shell.clone().unwrap_or_else(|| PathBuf::from("/bin/bash")); - - let first_command = first_active_sequence_command.get_text(); - let first_chain_type = first_active_sequence_command.get_chain_type(); - let command_cwd = first_active_sequence_command - .get_cwd() - .or_else(|| global_cwd.clone()); - - let command = RunCommandAction { - command: shell.clone(), - args: vec!["-ic".to_string(), first_command.trim().to_string()], - cwd: command_cwd, - hold_on_close: true, - ..Default::default() - }; - - let placement = NewPanePlacement::InPlace { - pane_id_to_replace: primary_pane_id, - close_replaced_pane: false, - borderless: Some(false), - }; - - let action = Action::NewBlockingPane { - placement, - command: Some(command), - pane_name: Some(first_command.trim().to_string()), - unblock_condition: first_chain_type.to_unblock_condition(), - near_current_pane: true, - }; - - self.sequence_id += 1; - - let mut context = BTreeMap::new(); - context.insert("sequence_id".to_string(), self.sequence_id.to_string()); - run_action(action, context); - self.set_command_status(0, CommandStatus::Running(None)); - } } impl Default for Execution { diff --git a/default-plugins/sequence/src/state/layout.rs b/default-plugins/sequence/src/state/layout.rs index 65a267e0..6b070778 100644 --- a/default-plugins/sequence/src/state/layout.rs +++ b/default-plugins/sequence/src/state/layout.rs @@ -1,10 +1,4 @@ -use crate::state::positioning; -use std::path::PathBuf; -use zellij_tile::prelude::*; - pub struct Layout { - pub needs_reposition: bool, - pub cached_cursor_position: Option<(usize, usize)>, pub spinner_frame: usize, pub spinner_timer_scheduled: bool, } @@ -12,58 +6,10 @@ pub struct Layout { impl Layout { pub fn new() -> Self { Self { - needs_reposition: false, - cached_cursor_position: None, spinner_frame: 0, spinner_timer_scheduled: false, } } - - pub fn reposition_plugin_based_on_sequence_state( - &mut self, - is_running: bool, - first_command_pane_id: Option<PaneId>, - pane_manifest: &PaneManifest, - plugin_id: Option<u32>, - total_viewport_columns: Option<usize>, - total_viewport_rows: Option<usize>, - total_commands: usize, - _selected_index: Option<usize>, - _running_index: usize, - _cwd: &Option<PathBuf>, - ) { - let Some(plugin_id) = plugin_id else { - return; - }; - let (Some(total_cols), Some(total_rows)) = (total_viewport_columns, total_viewport_rows) - else { - return; - }; - - if !self.needs_reposition || !is_running { - return; - } - - let Some(first_pane_id) = first_command_pane_id else { - return; - }; - - for (_tab_index, panes) in &pane_manifest.panes { - for pane in panes { - if !pane.is_plugin && PaneId::Terminal(pane.id) == first_pane_id { - positioning::reposition_plugin_for_sequence( - plugin_id, - total_cols, - total_rows, - total_commands, - ); - - self.needs_reposition = false; - return; - } - } - } - } } impl Default for Layout { diff --git a/default-plugins/sequence/src/state/mod.rs b/default-plugins/sequence/src/state/mod.rs index e3fbf3ff..148e6702 100644 --- a/default-plugins/sequence/src/state/mod.rs +++ b/default-plugins/sequence/src/state/mod.rs @@ -2,28 +2,25 @@ mod chain_type; mod command_entry; mod command_parser; mod command_status; -mod editing; mod execution; mod layout; -pub mod positioning; mod selection; +mod sequence_mode; pub use chain_type::ChainType; pub use command_entry::CommandEntry; -pub use command_parser::{ - detect_cd_command, detect_chain_operator_at_end, get_remaining_after_first_segment, - split_by_chain_operators, -}; +pub use command_parser::{parse_commands, serialize_sequence_to_editor}; pub use command_status::CommandStatus; -pub use editing::Editing; pub use execution::Execution; pub use layout::Layout; pub use selection::Selection; +pub use sequence_mode::SequenceMode; -use crate::ui::text_input::TextInput; +use std::collections::BTreeMap; use std::path::PathBuf; use zellij_tile::prelude::*; +#[derive(Default)] pub struct State { pub shell: Option<PathBuf>, pub plugin_id: Option<u32>, @@ -31,291 +28,85 @@ pub struct State { pub own_rows: Option<usize>, pub own_columns: Option<usize>, pub cwd: Option<PathBuf>, - pub original_pane_id: Option<PaneId>, - pub primary_pane_id: Option<PaneId>, - pub is_first_run: bool, - pub pane_manifest: Option<PaneManifest>, pub total_viewport_columns: Option<usize>, pub total_viewport_rows: Option<usize>, pub selection: Selection, - pub editing: Editing, pub execution: Execution, pub layout: Layout, pub current_position: Option<FloatingPaneCoordinates>, + pub mode: SequenceMode, + pub editor_pane_id: Option<PaneId>, + pub editor_temp_file: Option<PathBuf>, + pub sequence_tab_id: Option<usize>, + pub blocking_pipe_id: Option<String>, } impl State { - pub fn new() -> Self { - Self { - shell: None, - plugin_id: None, - client_id: None, - own_rows: None, - own_columns: None, - cwd: None, - original_pane_id: None, - primary_pane_id: None, - is_first_run: true, - pane_manifest: None, - total_viewport_columns: None, - total_viewport_rows: None, - selection: Selection::new(), - editing: Editing::new(), - execution: Execution::new(), - layout: Layout::new(), - current_position: None, - } - } - pub fn set_plugin_id(&mut self, plugin_id: u32) { self.plugin_id = Some(plugin_id); } - /// Check if the sequence has finished executing (all commands are done) - pub fn has_finished(&self) -> bool { - self.execution - .all_commands - .iter() - .all(|command| matches!(command.status, CommandStatus::Exited(_, _))) - } - pub fn add_empty_command_after_current_selected(&mut self) { - self.selection.add_empty_command_after_current_selected( - &mut self.execution.all_commands, - &mut self.editing, - &self.cwd, - ); - } - - pub fn current_selected_command_is_empty(&self) -> bool { - self.selection - .current_selected_command_is_empty(&self.execution.all_commands, &self.editing) - } - - pub fn remove_current_selected_command(&mut self) { - let removed_pane_id = self - .selection - .remove_current_selected_command(&mut self.execution.all_commands, &mut self.editing); - if let Some(removed_pane_id) = removed_pane_id { - close_pane_with_id(removed_pane_id); - } - } - - pub fn clear_current_selected_command(&mut self) { - self.selection - .clear_current_selected_command(&mut self.execution.all_commands, &mut self.editing); - } - pub fn move_selection_up(&mut self) { - self.selection - .move_up(&mut self.execution.all_commands, &mut self.editing); - if let Some(pane_id) = self - .current_selected_command() - .and_then(|c| c.get_pane_id()) - { - show_pane_with_id(pane_id, true, false); - } + self.selection.move_up(&self.execution.all_commands); + self.show_selected_pane(); } pub fn move_selection_down(&mut self) { - self.selection - .move_down(&mut self.execution.all_commands, &mut self.editing); - if let Some(pane_id) = self - .current_selected_command() - .and_then(|c| c.get_pane_id()) - { - show_pane_with_id(pane_id, true, false); - } - } - pub fn start_editing_selected(&mut self) { - if let Some(current_command_text) = self.current_selected_command().map(|c| c.get_text()) { - self.editing.start_editing(current_command_text); - } - } - - pub fn cancel_editing_selected(&mut self) { - self.editing.cancel_editing(); - } - - pub fn editing_input_text(&self) -> Option<String> { - self.editing.input_text() - } - - pub fn set_editing_input_text(&mut self, text: String) { - self.editing.set_input_text(text); - } - fn handle_first_pasted_segment( - &mut self, - current_text: &str, - segment_text: &str, - chain_type_opt: &Option<ChainType>, - ) { - let new_text = if current_text.trim().is_empty() { - segment_text.to_string() - } else { - format!("{}{}", current_text, segment_text) - }; - - if let Some(path) = detect_cd_command(&new_text) { - use crate::path_formatting; - if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) { - self.current_selected_command_mut().map(|c| { - c.set_cwd(Some(new_cwd)); - c.set_text("".to_owned()); - if let Some(chain_type) = chain_type_opt { - c.set_chain_type(*chain_type); - } - }); - return; - } - } - - self.current_selected_command_mut().map(|c| { - c.set_text(new_text); - if let Some(chain_type) = chain_type_opt { - c.set_chain_type(*chain_type); - } - }); + self.selection.move_down(&self.execution.all_commands); + self.show_selected_pane(); } - fn insert_new_pasted_segment( - &mut self, - segment_text: &str, - chain_type_opt: &Option<ChainType>, - is_last_line: bool, - ) { - use crate::path_formatting; - - let cd_path = detect_cd_command(segment_text); - - if let Some(path) = cd_path { - if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) { - self.current_selected_command_mut().map(|c| { - c.set_cwd(Some(new_cwd)); - if let Some(chain_type) = chain_type_opt { - c.set_chain_type(*chain_type); - } - }); - return; + pub fn show_selected_pane(&mut self) { + if self.mode == SequenceMode::Spread { + let all_pane_ids: Vec<PaneId> = self + .execution + .all_commands + .iter() + .filter_map(|c| c.get_pane_id()) + .collect(); + let selected_pane_id = self + .current_selected_command() + .and_then(|c| c.get_pane_id()); + if let Some(selected) = selected_pane_id { + let to_unhighlight: Vec<PaneId> = all_pane_ids + .into_iter() + .filter(|&p| p != selected) + .collect(); + highlight_and_unhighlight_panes(vec![selected], to_unhighlight); } - } - - let Some(new_selected_index) = self.selection.current_selected_command_index.map(|i| i + 1) - else { return; - }; - - let mut new_command = CommandEntry::new(segment_text, self.cwd.clone()); - if let Some(chain_type) = chain_type_opt { - new_command.set_chain_type(*chain_type); - } else if !is_last_line { - new_command.set_chain_type(ChainType::And); } - - self.execution - .all_commands - .insert(new_selected_index, new_command); - self.selection.current_selected_command_index = Some(new_selected_index); - } - - fn ensure_line_end_chain_type(&mut self) { - if let Some(last_cmd_index) = self.selection.current_selected_command_index { - self.execution - .all_commands - .get_mut(last_cmd_index) - .map(|c| { - if matches!(c.get_chain_type(), ChainType::None) { - c.set_chain_type(ChainType::And); - } - }); - } - } - - pub fn pasted_lines(&mut self, lines: Vec<&str>) { - let Some(current_text) = self.editing_input_text() else { - return; - }; - - for (line_index, line) in lines.iter().enumerate() { - let segments = split_by_chain_operators(line); - let is_last_line = line_index == lines.len().saturating_sub(1); - - for (seg_index, (segment_text, chain_type_opt)) in segments.iter().enumerate() { - if line_index == 0 && seg_index == 0 { - self.handle_first_pasted_segment(¤t_text, segment_text, chain_type_opt); - } else { - self.insert_new_pasted_segment(segment_text, chain_type_opt, is_last_line); - } - } - - if !is_last_line { - self.ensure_line_end_chain_type(); + let target_pane_id = self + .current_selected_command() + .and_then(|c| c.get_pane_id()); + if let (Some(displayed), Some(target)) = (self.execution.displayed_pane_id, target_pane_id) + { + if displayed != target { + replace_pane_with_existing_pane(displayed, target, true); + self.execution.displayed_pane_id = Some(target); } } - - self.start_editing_selected(); } + pub fn remove_empty_commands(&mut self) { self.execution.remove_empty_commands(); } - pub fn get_first_command(&self) -> Option<CommandEntry> { - self.execution.get_first_command() - } - - pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) { - self.execution.set_command_status(command_index, status); - } - - pub fn set_current_running_command_status(&mut self, status: CommandStatus) { - self.execution.set_current_running_command_status(status); - } - - pub fn get_current_running_command_status(&mut self) -> Option<CommandStatus> { - self.execution.get_current_running_command_status() - } - - pub fn handle_editing_submit(&mut self, current_cwd: &Option<PathBuf>) -> bool { - let (handled_internally, new_selection_index) = self.editing.handle_submit( - self.selection.current_selected_command_index, - &mut self.execution.all_commands, - current_cwd, - ); - self.selection.current_selected_command_index = new_selection_index; - handled_internally - } - - pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) { - self.execution - .update_pane_id_for_command(pane_id, command_text); - } - pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool { - self.execution.update_exited_command_statuses(pane_manifest) - } - pub fn update_sequence_stopped_state(&mut self) -> bool { - self.execution.update_sequence_stopped_state() - } - - pub fn cycle_chain_type(&mut self) { - self.current_selected_command_mut() - .map(|c| c.cycle_chain_type()); - } - - pub fn set_primary_pane_id_before_sequence(&mut self, pane_id: Option<PaneId>) { - if pane_id.is_some() { - self.execution.primary_pane_id_before_sequence = pane_id; - } - } pub fn clear_all_commands(&mut self) { self.execution.all_commands = vec![CommandEntry::new("", self.cwd.clone())]; - self.editing.editing_input = Some(TextInput::new("".to_owned())); - self.selection.current_selected_command_index = Some(0); + self.selection.current_selected_command_index = None; self.execution.current_running_command_index = 0; } - pub fn update_running_state(&mut self, primary_pane_id: Option<PaneId>) { - self.remove_empty_commands(); - self.set_primary_pane_id_before_sequence(primary_pane_id); - self.layout.needs_reposition = true; - self.execution.is_running = true; + + pub fn load_commands(&mut self, command_string: &str, cwd_override: Option<PathBuf>) { + let effective_cwd = cwd_override.or_else(|| self.cwd.clone()); + let commands = parse_commands(command_string, effective_cwd); + if commands.is_empty() { + return; + } + self.execution.all_commands = commands; + self.execution.current_running_command_index = 0; + self.selection.current_selected_command_index = None; } pub fn can_run_sequence(&self) -> bool { @@ -332,6 +123,31 @@ impl State { }; self.execution.all_commands.get_mut(i) } + + /// Serialize current sequence to a temp file and open it in the user's $EDITOR. + /// Stores the editor pane id and temp file path for later retrieval. + pub fn open_editor(&mut self) { + if self.execution.is_running { + return; + } + let Some(plugin_id) = self.plugin_id else { + return; + }; + + let serialized = serialize_sequence_to_editor(&self.execution.all_commands); + let temp_path = PathBuf::from(format!("/tmp/zellij-sequence-{}.sh", plugin_id)); + + if let Err(e) = std::fs::write(&temp_path, &serialized) { + eprintln!("Failed to write sequence to temp file: {}", e); + return; + } + + let file_to_open = FileToOpen::new(temp_path.clone()); + let editor_pane_id = open_file_in_place_of_plugin(file_to_open, false, BTreeMap::new()); + self.editor_pane_id = editor_pane_id; + self.editor_temp_file = Some(temp_path); + } + pub fn reposition_plugin(&mut self) -> bool { let mut repositioned = false; let Some(plugin_id) = self.plugin_id else { @@ -343,82 +159,62 @@ impl State { let Some(total_viewport_columns) = self.total_viewport_columns else { return repositioned; }; + if self.all_commands_are_pending() { + return false; + } + let total_commands = std::cmp::max(self.execution.all_commands.iter().len(), 1); let height_padding = 7; - if self.all_commands_are_pending() { - let initial_height = total_commands + height_padding; - let y = total_viewport_rows.saturating_sub(initial_height) / 2; - let coordinates = FloatingPaneCoordinates::new( - Some(format!("25%")), - Some(format!("{}", y)), - Some(format!("50%")), - Some(format!("{}", initial_height)), - Some(false), // should not be pinned when running sequence - Some(false), - ); - coordinates.map(|coordinates| { - if Some(&coordinates) != self.current_position.as_ref() { - repositioned = true; - self.current_position = Some(coordinates.clone()); - change_floating_panes_coordinates(vec![( - PaneId::Plugin(plugin_id), - coordinates, - )]); - } - }); - } else { - // Calculate longest table row width - let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd); - let longest_command = self - .execution - .all_commands - .iter() - .map(|cmd| cmd.get_text().chars().count()) - .max() - .unwrap_or(1); - let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths( - &self.execution.all_commands, - self.layout.spinner_frame, - ); + // Calculate longest table row width + let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd); + let longest_command = self + .execution + .all_commands + .iter() + .map(|cmd| cmd.get_text().chars().count()) + .max() + .unwrap_or(1); - let longest_line = crate::ui::components::calculate_longest_line( - &longest_cwd_display, - longest_command, - max_chain_width, - max_status_width, - ); + let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths( + &self.execution.all_commands, + self.layout.spinner_frame, + ); - let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame - let width = std::cmp::min(ui_width, total_viewport_columns / 2); + let longest_line = crate::ui::components::calculate_longest_line( + &longest_cwd_display, + longest_command, + max_chain_width, + max_status_width, + ); - let height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100); + let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame + let width = std::cmp::min(ui_width, total_viewport_columns.saturating_sub(2)); - // Position: top-right with 1-space margins - let x = total_viewport_columns.saturating_sub(width); - let y = 1; + let height = (height_padding + total_commands).min((total_viewport_rows * 50) / 100); - let coordinates = FloatingPaneCoordinates::new( - Some(x.to_string()), - Some(y.to_string()), - Some(width.to_string()), - Some(height.to_string()), - Some(true), // should be pinned for sequence - Some(false), - ); - coordinates.map(|coordinates| { - if Some(&coordinates) != self.current_position.as_ref() { - self.current_position = Some(coordinates.clone()); - repositioned = true; - change_floating_panes_coordinates(vec![( - PaneId::Plugin(plugin_id), - coordinates, - )]); - } - }); - } + // Position: top-right with 1-space margins + let x = total_viewport_columns.saturating_sub(width); + let y = 1; + + let coordinates = FloatingPaneCoordinates::new( + Some(x.to_string()), + Some(y.to_string()), + Some(width.to_string()), + Some(height.to_string()), + Some(true), // should be pinned for sequence + Some(false), + ); + coordinates.map(|coordinates| { + if Some(&coordinates) != self.current_position.as_ref() { + self.current_position = Some(coordinates.clone()); + repositioned = true; + change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coordinates)]); + } + }); repositioned } + pub fn all_commands_are_pending(&self) -> bool { self.execution .all_commands @@ -432,17 +228,4 @@ impl State { }; self.execution.all_commands.get(i) } - - pub fn execute_command_sequence(&mut self) { - self.execution - .execute_command_sequence(&self.shell, &self.cwd, self.primary_pane_id); - } -} - -impl Default for State { - fn default() -> Self { - let mut state = Self::new(); - state.is_first_run = true; - state - } } diff --git a/default-plugins/sequence/src/state/positioning.rs b/default-plugins/sequence/src/state/positioning.rs deleted file mode 100644 index b724df62..00000000 --- a/default-plugins/sequence/src/state/positioning.rs +++ /dev/null @@ -1,49 +0,0 @@ -use zellij_tile::prelude::*; - -pub fn reposition_plugin_for_sequence( - plugin_id: u32, - total_cols: usize, - total_rows: usize, - total_commands: usize, -) { - // Width: 30% of viewport - let width = std::cmp::max((total_cols * 30) / 100, 50); - - // Height: UI overhead + commands, capped at 25% of viewport - let overhead_rows = 6; - let height = (overhead_rows + total_commands).min((total_rows * 25) / 100); - - // Position: top-right with 1-space margins - let x = total_cols.saturating_sub(width); - let y = 1; - - change_floating_pane_coordinates_absolute( - plugin_id, - Some(x), - Some(y), - Some(width), - Some(height), - true, - ); -} - -pub fn change_floating_pane_coordinates_absolute( - own_plugin_id: u32, - x: Option<usize>, - y: Option<usize>, - width: Option<usize>, - height: Option<usize>, - should_be_pinned: bool, -) { - let coordinates = FloatingPaneCoordinates::new( - x.map(|x| x.to_string()), - y.map(|y| y.to_string()), - width.map(|width| width.to_string()), - height.map(|height| height.to_string()), - Some(should_be_pinned), - Some(false), - ); - if let Some(coordinates) = coordinates { - change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]); - } -} diff --git a/default-plugins/sequence/src/state/selection.rs b/default-plugins/sequence/src/state/selection.rs index 18f83bcd..2137f47d 100644 --- a/default-plugins/sequence/src/state/selection.rs +++ b/default-plugins/sequence/src/state/selection.rs @@ -1,115 +1,17 @@ -use crate::state::{CommandEntry, Editing}; -use std::path::PathBuf; -use zellij_tile::prelude::*; +use super::CommandEntry; pub struct Selection { pub current_selected_command_index: Option<usize>, - pub scroll_offset: usize, } impl Selection { pub fn new() -> Self { Self { - current_selected_command_index: Some(0), - scroll_offset: 0, + current_selected_command_index: None, } } - pub fn add_empty_command_after_current_selected( - &mut self, - all_commands: &mut Vec<CommandEntry>, - editing: &mut Editing, - cwd: &Option<PathBuf>, - ) { - self.save_editing_buffer_to_current_selected(all_commands, editing); - editing.editing_input.as_mut().map(|i| i.clear()); - if !self.current_selected_command_is_empty(all_commands, editing) { - self.current_selected_command_mut(all_commands) - .map(|c| c.fill_chain_type_if_empty()); - - if let Some(current_selected_command_index) = self.current_selected_command_index.take() - { - let new_command = CommandEntry::new("", cwd.clone()); - if current_selected_command_index == all_commands.len().saturating_sub(1) { - all_commands.push(new_command); - } else { - all_commands.insert(current_selected_command_index + 1, new_command); - } - - self.current_selected_command_index = Some(current_selected_command_index + 1); - } - } - } - - pub fn current_selected_command_is_empty( - &self, - all_commands: &[CommandEntry], - editing: &Editing, - ) -> bool { - let text_input_is_empty = editing - .editing_input - .as_ref() - .map(|i| i.is_empty()) - .unwrap_or(true); - let current_selected_command_is_empty = self - .current_selected_command_index - .and_then(|i| all_commands.get(i).map(|c| c.is_empty())) - .unwrap_or(true); - text_input_is_empty && current_selected_command_is_empty - } - - pub fn remove_current_selected_command( - &mut self, - all_commands: &mut Vec<CommandEntry>, - editing: &mut Editing, - ) -> Option<PaneId> { - // returns the pane_id of the removed command, if any - let mut removed_pane_id = None; - let Some(mut current_selected_command_index) = self.current_selected_command_index else { - return removed_pane_id; - }; - if all_commands.len() > 1 && all_commands.len() > current_selected_command_index { - let command = all_commands.remove(current_selected_command_index); - removed_pane_id = command.get_pane_id(); - } else { - self.clear_current_selected_command(all_commands, editing); - } - if current_selected_command_index >= all_commands.len() - && current_selected_command_index > 0 - { - current_selected_command_index -= 1; - } - self.update_current_selected_command_index( - current_selected_command_index, - all_commands, - editing, - ); - if current_selected_command_index == all_commands.len().saturating_sub(1) { - self.current_selected_command_mut(all_commands) - .map(|c| c.clear_chain_type()); - } - removed_pane_id - } - - pub fn clear_current_selected_command( - &mut self, - all_commands: &mut [CommandEntry], - editing: &mut Editing, - ) { - let Some(current_selected_command_index) = self.current_selected_command_index else { - return; - }; - self.current_selected_command_mut(all_commands) - .map(|c| c.clear_text()); - self.clear_editing_buffer(editing); - if current_selected_command_index == all_commands.len().saturating_sub(1) { - self.current_selected_command_mut(all_commands) - .map(|c| c.clear_chain_type()); - } - } - - pub fn move_up(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) { - self.save_editing_buffer_to_current_selected(all_commands, editing); + pub fn move_up(&mut self, all_commands: &[CommandEntry]) { match self.current_selected_command_index.as_mut() { Some(i) if *i > 0 => { *i -= 1; @@ -121,11 +23,9 @@ impl Selection { self.current_selected_command_index = None; }, } - self.update_editing_buffer_to_current_selected(all_commands, editing); } - pub fn move_down(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) { - self.save_editing_buffer_to_current_selected(all_commands, editing); + pub fn move_down(&mut self, all_commands: &[CommandEntry]) { match self.current_selected_command_index.as_mut() { Some(i) if *i < all_commands.len().saturating_sub(1) => { *i += 1; @@ -137,73 +37,6 @@ impl Selection { self.current_selected_command_index = None; }, } - self.update_editing_buffer_to_current_selected(all_commands, editing); - } - - fn current_selected_command_mut<'a>( - &self, - all_commands: &'a mut [CommandEntry], - ) -> Option<&'a mut CommandEntry> { - let Some(i) = self.current_selected_command_index else { - return None; - }; - all_commands.get_mut(i) - } - - fn current_selected_command<'a>( - &self, - all_commands: &'a [CommandEntry], - ) -> Option<&'a CommandEntry> { - let Some(i) = self.current_selected_command_index else { - return None; - }; - all_commands.get(i) - } - - fn clear_editing_buffer(&self, editing: &mut Editing) { - editing.editing_input.as_mut().map(|c| c.clear()); - } - - fn update_current_selected_command_index( - &mut self, - new_index: usize, - all_commands: &[CommandEntry], - editing: &mut Editing, - ) { - self.current_selected_command_index = Some(new_index); - self.update_editing_buffer_to_current_selected(all_commands, editing); - } - - fn get_text_of_current_selected_command( - &self, - all_commands: &[CommandEntry], - ) -> Option<String> { - self.current_selected_command(all_commands) - .map(|c| c.get_text()) - } - - fn save_editing_buffer_to_current_selected( - &self, - all_commands: &mut [CommandEntry], - editing: &mut Editing, - ) { - if let Some(text_input) = editing.editing_input.as_ref().map(|i| i.get_text()) { - self.current_selected_command_mut(all_commands) - .map(|c| c.set_text(text_input.to_owned())); - } - } - - fn update_editing_buffer_to_current_selected( - &self, - all_commands: &[CommandEntry], - editing: &mut Editing, - ) { - let new_text_of_current_selected_command = self - .get_text_of_current_selected_command(all_commands) - .unwrap_or_else(|| String::new()); - if let Some(editing_input) = editing.editing_input.as_mut() { - editing_input.set_text(new_text_of_current_selected_command); - } } } diff --git a/default-plugins/sequence/src/state/sequence_mode.rs b/default-plugins/sequence/src/state/sequence_mode.rs new file mode 100644 index 00000000..3d079d70 --- /dev/null +++ b/default-plugins/sequence/src/state/sequence_mode.rs @@ -0,0 +1,6 @@ +#[derive(Default, Debug, Clone, PartialEq)] +pub enum SequenceMode { + #[default] + SinglePane, + Spread, +} diff --git a/default-plugins/sequence/src/ui/components.rs b/default-plugins/sequence/src/ui/components.rs index 3fe2ae1a..bc319567 100644 --- a/default-plugins/sequence/src/ui/components.rs +++ b/default-plugins/sequence/src/ui/components.rs @@ -6,6 +6,11 @@ use zellij_tile::prelude::*; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]; +const INTRO_PART1: &str = "Paste a series of commands to run in sequence"; +const INTRO_PART2: &str = "or <e> to edit in your editor"; +const INTRO_FULL: &str = + "Paste a series of commands to run in sequence or <e> to edit in your editor"; + const HELP_RUNNING_WITH_SELECTION: &str = "<Ctrl c> - interrupt, <Enter> - run from selected"; const HELP_RUNNING_NO_SELECTION: &str = "<Ctrl c> - interrupt, <↓↑> - navigate"; const HELP_STOPPED_WITH_SELECTION: &str = @@ -13,15 +18,7 @@ const HELP_STOPPED_WITH_SELECTION: &str = const HELP_STOPPED_NO_SELECTION: &str = "<Ctrl w> - close all, <↓↑> - navigate, <Enter> - run from first"; -const HELP_ONE_PENDING_COMMAND: &str = "<Enter> - run, <Ctrl Enter> - add command"; - -const HELP_ALL_COMMANDS_PENDING: &str = - "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate"; -const HELP_ALL_COMMANDS_PENDING_WITH_SELECTION: &str = - "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate, <e> - edit selected"; - -const HELP_EDITING_FIRST_LINE: &str = - "<Enter> - accept, <Ctrl Enter> - add command, <↓↑> - navigate"; +const HELP_PENDING: &str = "<e> - edit, <Enter> - run"; fn select_help_text(sequence: &State) -> &'static str { let is_running = sequence @@ -35,18 +32,9 @@ fn select_help_text(sequence: &State) -> &'static str { .iter() .all(|command| matches!(command.get_status(), CommandStatus::Pending)); let has_selection = sequence.selection.current_selected_command_index.is_some(); - let is_editing = sequence.editing.editing_input.is_some(); if all_pending && !sequence.execution.is_running { - if sequence.execution.all_commands.len() == 1 { - HELP_ONE_PENDING_COMMAND - } else if has_selection && !is_editing { - HELP_ALL_COMMANDS_PENDING_WITH_SELECTION - } else if is_editing { - HELP_EDITING_FIRST_LINE - } else { - HELP_ALL_COMMANDS_PENDING - } + HELP_PENDING } else if is_running { if has_selection { HELP_RUNNING_WITH_SELECTION @@ -54,10 +42,8 @@ fn select_help_text(sequence: &State) -> &'static str { HELP_RUNNING_NO_SELECTION } } else { - if has_selection && !is_editing { + if has_selection { HELP_STOPPED_WITH_SELECTION - } else if is_editing { - HELP_EDITING_FIRST_LINE } else { HELP_STOPPED_NO_SELECTION } @@ -71,7 +57,6 @@ fn style_help_text(text: &str) -> Text { .color_substring(3, "<↓↑>") .color_substring(3, "<Esc>") .color_substring(3, "<Enter>") - .color_substring(3, "<Ctrl Enter>") .color_substring(3, "<e>") } @@ -79,33 +64,6 @@ pub fn render_help_lines( sequence: &State, max_width: Option<usize>, ) -> (Text, usize, Option<(Text, usize)>) { - let is_editing = sequence.editing.editing_input.is_some(); - - if is_editing { - let help_text = select_help_text(sequence); - let (truncated_text, help_len) = if let Some(width) = max_width { - truncate_help_line(help_text, width) - } else { - (help_text.to_string(), help_text.chars().count()) - }; - let first_line = (style_help_text(&truncated_text).unbold_all(), help_len); - - let editing_help_text = "Navigate with cd, chain with ||, &&, ; "; - let editing_help = Text::new(editing_help_text) - .color_substring(3, "cd") - .color_substring(3, "||") - .color_substring(3, "&&") - .color_substring(3, ";") - .unbold_all(); - let editing_len = editing_help_text.len(); - - return ( - first_line.0, - first_line.1, - Some((editing_help, editing_len)), - ); - } - let help_text = select_help_text(sequence); if let Some(width) = max_width { @@ -139,6 +97,38 @@ pub fn render_help_lines( } } +/// Returns `(text, x, y)` tuples for the main screen intro hint. +/// The text is centered horizontally; if it does not fit on one line, two lines +/// are returned separated by one blank line (1-line padding). +pub fn render_intro_hint(rows: usize, cols: usize) -> Vec<(Text, usize, usize)> { + let full_len = INTRO_FULL.chars().count(); + + if full_len <= cols { + let x = cols.saturating_sub(full_len) / 2; + let y = rows / 2; + vec![(Text::new(INTRO_FULL).color_substring(3, "<e>"), x, y)] + } else { + let p1_len = INTRO_PART1.chars().count(); + let p2_len = INTRO_PART2.chars().count(); + let x1 = cols.saturating_sub(p1_len) / 2; + let x2 = cols.saturating_sub(p2_len) / 2; + // 3 display rows: line1, padding, line2 — center the group vertically + let start_y = rows.saturating_sub(3) / 2; + vec![ + ( + Text::new(INTRO_PART1).color_substring(3, "<e>"), + x1, + start_y, + ), + ( + Text::new(INTRO_PART2).color_substring(3, "<e>"), + x2, + start_y + 2, + ), + ] + } +} + fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> { let text_width = help_text.chars().count(); @@ -200,7 +190,7 @@ fn chain_cell(chain_type: &crate::state::ChainType) -> Text { } } -fn get_spinner_frame(frame: usize) -> &'static str { +pub fn get_spinner_frame(frame: usize) -> &'static str { SPINNER_FRAMES[frame % SPINNER_FRAMES.len()] } @@ -371,7 +361,8 @@ pub fn add_command_row( None => return table, }; - let cmd_text = &command.get_text(); + let cmd_text = command.get_text(); + let cmd_text = cmd_text.trim(); let chain_type = &command.get_chain_type(); let status = &command.get_status(); let command_cwd = command.get_cwd().or_else(|| state.cwd.clone()); @@ -398,26 +389,6 @@ pub fn add_command_row( return table; }; - if let Some(text_input) = &state.editing.editing_input { - if state.selection.current_selected_command_index == Some(index) { - let (truncated_editing_text, _) = truncate_middle( - text_input.get_text(), - available_cmd_width, - Some(text_input.cursor_position()), - ); - let (row, _) = command_sequence_row( - state, - &command_cwd, - &truncated_editing_text, - chain_type, - &CommandStatus::Pending, - false, - overflow_indicator, - ); - return table.add_styled_row(row); - } - } - let truncated_cmd_text = truncate_middle(cmd_text, available_cmd_width, None).0; let (row, _) = command_sequence_row( diff --git a/default-plugins/sequence/src/ui/fuzzy_complete.rs b/default-plugins/sequence/src/ui/fuzzy_complete.rs deleted file mode 100644 index 0feae7e4..00000000 --- a/default-plugins/sequence/src/ui/fuzzy_complete.rs +++ /dev/null @@ -1,321 +0,0 @@ -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher; -use std::collections::BTreeMap; -use std::fs; -use std::path::PathBuf; - -/// Result of a fuzzy completion operation -#[derive(Debug, Clone)] -pub struct CompletionResult { - pub completed_text: String, - pub score: i64, - pub is_directory: bool, - pub is_prefix_completion: bool, // True if this is the shortest of multiple matches with same prefix -} - -/// Type of completion -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CompletionType { - Command, - Path, -} - -/// Fuzzy match executables (commands) from the PATH -pub fn fuzzy_complete_command( - query: &str, - executables: &BTreeMap<String, PathBuf>, -) -> Option<CompletionResult> { - if query.is_empty() { - return None; - } - - let matcher = SkimMatcherV2::default().ignore_case(); - - // Collect all matches with their scores - let mut all_matches: Vec<(String, i64)> = Vec::new(); - - for executable_name in executables.keys() { - if let Some((score, _indices)) = matcher.fuzzy_indices(executable_name, query) { - all_matches.push((executable_name.clone(), score)); - } - } - - if all_matches.is_empty() { - return None; - } - - // Sort by score (highest first), then by length (shortest first) - all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len()))); - - // Get the best match - let (best_name, best_score) = &all_matches[0]; - - // Check if there are multiple matches that share the same prefix - let is_prefix_completion = check_for_prefix_matches(&all_matches, best_name); - - Some(CompletionResult { - completed_text: best_name.clone(), - score: *best_score, - is_directory: false, // Commands are not directories - is_prefix_completion, - }) -} - -/// Fuzzy match paths (directories and files) relative to the current working directory -pub fn fuzzy_complete_path(query: &str, cwd: Option<&PathBuf>) -> Option<CompletionResult> { - if query.is_empty() { - return None; - } - - let matcher = SkimMatcherV2::default().ignore_case(); - - // Determine the base directory to search in - let (base_dir, search_prefix) = if query.starts_with('/') { - // Absolute path - let parts: Vec<&str> = query.rsplitn(2, '/').collect(); - if parts.len() == 2 { - // Has a directory component - let dir = parts[1]; - let prefix = parts[0]; - (PathBuf::from(dir), prefix.to_string()) - } else { - // Just "/" - (PathBuf::from("/"), String::new()) - } - } else if query.starts_with("~/") { - // Home directory - let home_dir = std::env::var("HOME").ok()?; - let query_without_tilde = &query[2..]; - let parts: Vec<&str> = query_without_tilde.rsplitn(2, '/').collect(); - if parts.len() == 2 { - let dir = PathBuf::from(&home_dir).join(parts[1]); - let prefix = parts[0]; - (dir, prefix.to_string()) - } else { - // Just "~/" or "~/something" - (PathBuf::from(home_dir), query_without_tilde.to_string()) - } - } else { - // Relative path - let base = cwd?; - let parts: Vec<&str> = query.rsplitn(2, '/').collect(); - if parts.len() == 2 { - // Has a directory component - let dir = base.join(parts[1]); - let prefix = parts[0]; - (dir, prefix.to_string()) - } else { - // Just a name in the current directory - (base.clone(), query.to_string()) - } - }; - - // Convert to host path - let host_base_dir = - PathBuf::from("/host").join(base_dir.strip_prefix("/").unwrap_or(&base_dir)); - - // Read directory entries - let entries = fs::read_dir(&host_base_dir).ok()?; - - // Collect all matches with their scores and directory status - let mut all_matches: Vec<(String, i64, bool)> = Vec::new(); - - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - // Skip hidden files unless the query starts with a dot - if name.starts_with('.') && !search_prefix.starts_with('.') { - continue; - } - - if let Some((score, _indices)) = matcher.fuzzy_indices(name, &search_prefix) { - // Check if this entry is a directory - let is_dir = entry.metadata().ok().map(|m| m.is_dir()).unwrap_or(false); - all_matches.push((name.to_string(), score, is_dir)); - } - } - } - - if all_matches.is_empty() { - return None; - } - - // Sort by score (highest first), then by length (shortest first) - all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len()))); - - // Get the best match - let (name, score, is_directory) = all_matches[0].clone(); - - // Check if there are multiple matches that share the same prefix - let name_matches: Vec<(String, i64)> = all_matches - .iter() - .map(|(n, s, _)| (n.clone(), *s)) - .collect(); - let is_prefix_completion = check_for_prefix_matches(&name_matches, &name); - - // Reconstruct the full path - let completed_text = if query.starts_with('/') { - // Absolute path - let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - format!("/{}", name) - } else { - format!("{}/{}", dir_part, name) - } - } else if query.starts_with("~/") { - // Home directory - let query_without_tilde = &query[2..]; - let dir_part = query_without_tilde.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - format!("~/{}", name) - } else { - format!("~/{}/{}", dir_part, name) - } - } else { - // Relative path - let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - name.clone() - } else { - format!("{}/{}", dir_part, name) - } - }; - - Some(CompletionResult { - completed_text, - score, - is_directory, - is_prefix_completion, - }) -} - -/// Check if there are multiple matches that start with the best match's name -/// Returns true if the best match is a prefix of other matches -fn check_for_prefix_matches(matches: &[(String, i64)], best_match: &str) -> bool { - if matches.len() <= 1 { - return false; - } - - // Count how many matches start with the best_match text - let prefix_count = matches - .iter() - .filter(|(name, _)| name.starts_with(best_match) && name != best_match) - .count(); - - prefix_count > 0 -} - -/// Perform fuzzy completion on the current input -/// Returns the best completion (command or path) with appropriate suffix (space or slash) -pub fn fuzzy_complete( - input: &str, - executables: &BTreeMap<String, PathBuf>, - cwd: Option<&PathBuf>, -) -> Option<String> { - // If input is empty, nothing to complete - if input.is_empty() { - return None; - } - - // Extract the last word/token to complete - // For simplicity, we'll complete the entire input if it's a single word, - // or the last space-separated token if there are multiple words - let tokens: Vec<&str> = input.split_whitespace().collect(); - let to_complete = if tokens.is_empty() { - input - } else { - tokens.last().unwrap() - }; - - // Get both command and path completions - let command_result = fuzzy_complete_command(to_complete, executables); - let path_result = fuzzy_complete_path(to_complete, cwd); - - // Choose the better match based on score - match (command_result, path_result) { - (Some(cmd), Some(path)) => { - // Both matched, use the higher score - let (result, completion_type, is_dir, is_prefix) = if cmd.score >= path.score { - ( - cmd.completed_text, - CompletionType::Command, - false, - cmd.is_prefix_completion, - ) - } else { - ( - path.completed_text, - CompletionType::Path, - path.is_directory, - path.is_prefix_completion, - ) - }; - - // Add appropriate suffix (skip slash if this is a prefix completion) - let suffix = get_completion_suffix(completion_type, is_dir, is_prefix); - let completed = format!("{}{}", result, suffix); - - // Replace the last token with the completion - Some(replace_last_token(input, &completed)) - }, - (Some(cmd), None) => { - // Command completion - add space (unless it's a prefix completion) - let suffix = if cmd.is_prefix_completion { "" } else { " " }; - let completed = format!("{}{}", cmd.completed_text, suffix); - Some(replace_last_token(input, &completed)) - }, - (None, Some(path)) => { - // Path completion - add slash if directory (unless it's a prefix completion) - let suffix = if path.is_prefix_completion { - "" - } else if path.is_directory { - "/" - } else { - "" - }; - let completed = format!("{}{}", path.completed_text, suffix); - Some(replace_last_token(input, &completed)) - }, - (None, None) => None, - } -} - -/// Get the appropriate suffix for a completion -fn get_completion_suffix( - completion_type: CompletionType, - is_directory: bool, - is_prefix_completion: bool, -) -> &'static str { - // No suffix for prefix completions - if is_prefix_completion { - return ""; - } - - match completion_type { - CompletionType::Command => " ", - CompletionType::Path => { - if is_directory { - "/" - } else { - "" - } - }, - } -} - -/// Replace the last whitespace-separated token in the input with the completion -fn replace_last_token(input: &str, completion: &str) -> String { - let tokens: Vec<&str> = input.split_whitespace().collect(); - if tokens.is_empty() { - return completion.to_string(); - } - - if tokens.len() == 1 { - completion.to_string() - } else { - // Join all tokens except the last one, then add the completion - let mut result = tokens[..tokens.len() - 1].join(" "); - result.push(' '); - result.push_str(completion); - result - } -} diff --git a/default-plugins/sequence/src/ui/mod.rs b/default-plugins/sequence/src/ui/mod.rs index 6d1ec303..7255fa35 100644 --- a/default-plugins/sequence/src/ui/mod.rs +++ b/default-plugins/sequence/src/ui/mod.rs @@ -1,5 +1,3 @@ pub mod components; -pub mod fuzzy_complete; pub mod layout_calculations; -pub mod text_input; pub mod truncation; diff --git a/default-plugins/sequence/src/ui/text_input.rs b/default-plugins/sequence/src/ui/text_input.rs deleted file mode 100644 index 2694e17a..00000000 --- a/default-plugins/sequence/src/ui/text_input.rs +++ /dev/null @@ -1,997 +0,0 @@ -use zellij_tile::prelude::*; - -const MAX_UNDO_STACK_SIZE: usize = 100; - -/// Action returned by TextInput after handling a key event -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(test, derive(Eq))] -pub enum InputAction { - /// Continue editing - Continue, - /// User pressed Enter to submit - Submit, - /// User pressed Esc to cancel - Cancel, - /// User pressed Tab to request completion - Complete, - /// Key was not handled by the input - NoAction, -} - -/// A reusable text input component with cursor support and standard editing keybindings -#[derive(Debug, Clone)] -pub struct TextInput { - buffer: String, - cursor_position: usize, // Character position (0-based), NOT byte position - undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots - redo_stack: Vec<(String, usize)>, - last_edit_was_insert: bool, // For coalescing consecutive inserts -} - -impl TextInput { - /// Create a new TextInput with the given initial text - /// Cursor is positioned at the end of the text - pub fn new(initial_text: String) -> Self { - let cursor_position = initial_text.chars().count(); - Self { - buffer: initial_text, - cursor_position, - undo_stack: Vec::new(), - redo_stack: Vec::new(), - last_edit_was_insert: false, - } - } - - /// Create an empty TextInput - pub fn empty() -> Self { - Self::new(String::new()) - } - - /// Get the current text - pub fn get_text(&self) -> &str { - &self.buffer - } - - /// Get the cursor position (in characters, not bytes) - pub fn get_cursor_position(&self) -> usize { - self.cursor_position - } - - /// Check if the input is empty - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } - - /// Get a shorthand for cursor_position - pub fn cursor_position(&self) -> usize { - self.cursor_position - } - - /// Get mutable access to the underlying buffer for direct manipulation - pub fn get_text_mut(&mut self) -> &mut String { - &mut self.buffer - } - - /// Set the text and move cursor to the end - pub fn set_text(&mut self, text: String) { - self.break_coalescing(); - self.save_undo_state(); - self.cursor_position = text.chars().count(); - self.buffer = text; - } - - /// Set cursor position (clamped to text length) - pub fn set_cursor_position(&mut self, pos: usize) { - let text_len = self.buffer.chars().count(); - self.cursor_position = pos.min(text_len); - } - - /// Clear all text and reset cursor - pub fn clear(&mut self) { - self.break_coalescing(); - self.save_undo_state(); - self.buffer.clear(); - self.cursor_position = 0; - } - - /// Insert a character at the current cursor position - pub fn insert_char(&mut self, c: char) { - self.save_undo_state_unless_coalescing(); - // Convert cursor position (char index) to byte index - let byte_index = self.char_index_to_byte_index(self.cursor_position); - self.buffer.insert(byte_index, c); - self.cursor_position += 1; - } - - /// Delete the character before the cursor (backspace) - pub fn backspace(&mut self) { - if self.cursor_position > 0 { - self.break_coalescing(); - self.save_undo_state(); - self.cursor_position -= 1; - let byte_index = self.char_index_to_byte_index(self.cursor_position); - self.buffer.remove(byte_index); - } - } - - /// Delete the character at the cursor position (delete key) - pub fn delete(&mut self) { - let len = self.buffer.chars().count(); - if self.cursor_position < len { - self.break_coalescing(); - self.save_undo_state(); - let byte_index = self.char_index_to_byte_index(self.cursor_position); - self.buffer.remove(byte_index); - } - } - - /// Delete the word before the cursor (Ctrl/Alt + Backspace) - pub fn delete_word_backward(&mut self) { - if self.cursor_position == 0 { - return; - } - - self.break_coalescing(); - self.save_undo_state(); - - let old_position = self.cursor_position; - self.move_word_left(); - let new_position = self.cursor_position; - - // Delete from new position to old position - let start_byte = self.char_index_to_byte_index(new_position); - let end_byte = self.char_index_to_byte_index(old_position); - self.buffer.drain(start_byte..end_byte); - } - - /// Delete the word after the cursor (Ctrl/Alt + Delete) - pub fn delete_word_forward(&mut self) { - let chars: Vec<char> = self.buffer.chars().collect(); - let len = chars.len(); - - if self.cursor_position >= len { - return; - } - - self.break_coalescing(); - self.save_undo_state(); - - let start_position = self.cursor_position; - let mut end_position = start_position; - - // Skip the current word - while end_position < len && !chars[end_position].is_whitespace() { - end_position += 1; - } - - // Skip any whitespace after the word - while end_position < len && chars[end_position].is_whitespace() { - end_position += 1; - } - - // Delete from start to end position - let start_byte = self.char_index_to_byte_index(start_position); - let end_byte = self.char_index_to_byte_index(end_position); - self.buffer.drain(start_byte..end_byte); - } - - /// Move cursor one position to the left - pub fn move_left(&mut self) { - if self.cursor_position > 0 { - self.break_coalescing(); - self.cursor_position -= 1; - } - } - - /// Move cursor one position to the right - pub fn move_right(&mut self) { - let len = self.buffer.chars().count(); - if self.cursor_position < len { - self.break_coalescing(); - self.cursor_position += 1; - } - } - - /// Move cursor to the start of the text (Ctrl-A / Home) - pub fn move_to_start(&mut self) { - self.break_coalescing(); - self.cursor_position = 0; - } - - /// Move cursor to the end of the text (Ctrl-E / End) - pub fn move_to_end(&mut self) { - self.break_coalescing(); - self.cursor_position = self.buffer.chars().count(); - } - - /// Move cursor to the start of the previous word (Ctrl/Alt + Left) - pub fn move_word_left(&mut self) { - if self.cursor_position == 0 { - return; - } - - self.break_coalescing(); - - let chars: Vec<char> = self.buffer.chars().collect(); - let mut pos = self.cursor_position; - - // Skip any whitespace immediately to the left - while pos > 0 && chars[pos - 1].is_whitespace() { - pos -= 1; - } - - // Skip the word characters - while pos > 0 && !chars[pos - 1].is_whitespace() { - pos -= 1; - } - - self.cursor_position = pos; - } - - /// Move cursor to the start of the next word (Ctrl/Alt + Right) - pub fn move_word_right(&mut self) { - let chars: Vec<char> = self.buffer.chars().collect(); - let len = chars.len(); - - if self.cursor_position >= len { - return; - } - - self.break_coalescing(); - - let mut pos = self.cursor_position; - - // Skip the current word - while pos < len && !chars[pos].is_whitespace() { - pos += 1; - } - - // Skip any whitespace - while pos < len && chars[pos].is_whitespace() { - pos += 1; - } - - self.cursor_position = pos; - } - - /// Handle a key event and return the appropriate action - /// This is the main entry point for key handling - pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction { - // Check for Ctrl modifiers - if key.has_modifiers(&[KeyModifier::Ctrl]) { - match key.bare_key { - BareKey::Char('a') => { - self.move_to_start(); - return InputAction::Continue; - }, - BareKey::Char('e') => { - self.move_to_end(); - return InputAction::Continue; - }, - BareKey::Char('c') => { - // Ctrl-C clears the prompt - return InputAction::Cancel; - }, - BareKey::Char('z') => { - // Ctrl-Z: Undo - self.undo(); - return InputAction::Continue; - }, - BareKey::Char('y') => { - // Ctrl-Y: Redo - self.redo(); - return InputAction::Continue; - }, - BareKey::Left => { - self.move_word_left(); - return InputAction::Continue; - }, - BareKey::Right => { - self.move_word_right(); - return InputAction::Continue; - }, - BareKey::Backspace => { - self.delete_word_backward(); - return InputAction::Continue; - }, - BareKey::Delete => { - self.delete_word_forward(); - return InputAction::Continue; - }, - _ => {}, - } - } - - // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z) - if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) { - match key.bare_key { - BareKey::Char('Z') => { - // Ctrl-Shift-Z: Redo (alternative) - self.redo(); - return InputAction::Continue; - }, - _ => {}, - } - } - - // Check for Alt modifiers - if key.has_modifiers(&[KeyModifier::Alt]) { - match key.bare_key { - BareKey::Left => { - self.move_word_left(); - return InputAction::Continue; - }, - BareKey::Right => { - self.move_word_right(); - return InputAction::Continue; - }, - BareKey::Backspace => { - self.delete_word_backward(); - return InputAction::Continue; - }, - BareKey::Delete => { - self.delete_word_forward(); - return InputAction::Continue; - }, - _ => {}, - } - } - - // Handle bare keys (no modifiers) - match key.bare_key { - BareKey::Enter => InputAction::Submit, - BareKey::Esc => InputAction::Cancel, - BareKey::Tab => InputAction::Complete, - BareKey::Backspace => { - self.backspace(); - InputAction::Continue - }, - BareKey::Delete => { - self.delete(); - InputAction::Continue - }, - BareKey::Left => { - self.move_left(); - InputAction::Continue - }, - BareKey::Right => { - self.move_right(); - InputAction::Continue - }, - BareKey::Home => { - self.move_to_start(); - InputAction::Continue - }, - BareKey::End => { - self.move_to_end(); - InputAction::Continue - }, - BareKey::Char(c) => { - self.insert_char(c); - InputAction::Continue - }, - _ => InputAction::NoAction, - } - } - - /// Helper: Convert character index to byte index - fn char_index_to_byte_index(&self, char_index: usize) -> usize { - self.buffer - .char_indices() - .nth(char_index) - .map(|(byte_idx, _)| byte_idx) - .unwrap_or(self.buffer.len()) - } - - /// Save current state to undo stack before making changes - fn save_undo_state(&mut self) { - if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE { - self.undo_stack.remove(0); - } - self.undo_stack - .push((self.buffer.clone(), self.cursor_position)); - self.redo_stack.clear(); - } - - /// Save state only if not coalescing with previous insert - fn save_undo_state_unless_coalescing(&mut self) { - if !self.last_edit_was_insert { - self.save_undo_state(); - } - self.last_edit_was_insert = true; - } - - /// Mark that a non-insert edit occurred (breaks coalescing) - fn break_coalescing(&mut self) { - self.last_edit_was_insert = false; - } - - /// Undo last change - pub fn undo(&mut self) -> bool { - if let Some((buffer, cursor)) = self.undo_stack.pop() { - self.redo_stack - .push((self.buffer.clone(), self.cursor_position)); - self.buffer = buffer; - self.cursor_position = cursor; - self.break_coalescing(); - true - } else { - false - } - } - - /// Redo last undone change - pub fn redo(&mut self) -> bool { - if let Some((buffer, cursor)) = self.redo_stack.pop() { - self.undo_stack - .push((self.buffer.clone(), self.cursor_position)); - self.buffer = buffer; - self.cursor_position = cursor; - self.break_coalescing(); - true - } else { - false - } - } - - /// Check if undo is available - pub fn can_undo(&self) -> bool { - !self.undo_stack.is_empty() - } - - /// Check if redo is available - pub fn can_redo(&self) -> bool { - !self.redo_stack.is_empty() - } - - pub fn drain_text(&mut self) -> String { - self.cursor_position = 0; - self.buffer.drain(..).collect() - } -} - -// run with: -// cargo test --lib --target x86_64-unknown-linux-gnu -// -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_and_empty() { - let input = TextInput::new("hello".to_string()); - assert_eq!(input.get_text(), "hello"); - assert_eq!(input.get_cursor_position(), 5); - - let empty = TextInput::empty(); - assert_eq!(empty.get_text(), ""); - assert_eq!(empty.get_cursor_position(), 0); - } - - #[test] - fn test_insert_char() { - let mut input = TextInput::new("helo".to_string()); - input.cursor_position = 3; // Position after "hel" - input.insert_char('l'); - assert_eq!(input.get_text(), "hello"); - assert_eq!(input.get_cursor_position(), 4); - } - - #[test] - fn test_backspace() { - let mut input = TextInput::new("hello".to_string()); - input.backspace(); - assert_eq!(input.get_text(), "hell"); - assert_eq!(input.get_cursor_position(), 4); - - // Backspace at start does nothing - input.cursor_position = 0; - input.backspace(); - assert_eq!(input.get_text(), "hell"); - assert_eq!(input.get_cursor_position(), 0); - } - - #[test] - fn test_delete() { - let mut input = TextInput::new("hello".to_string()); - input.cursor_position = 0; - input.delete(); - assert_eq!(input.get_text(), "ello"); - assert_eq!(input.get_cursor_position(), 0); - - // Delete at end does nothing - input.move_to_end(); - input.delete(); - assert_eq!(input.get_text(), "ello"); - } - - #[test] - fn test_cursor_movement() { - let mut input = TextInput::new("hello".to_string()); - assert_eq!(input.get_cursor_position(), 5); - - input.move_left(); - assert_eq!(input.get_cursor_position(), 4); - - input.move_right(); - assert_eq!(input.get_cursor_position(), 5); - - input.move_to_start(); - assert_eq!(input.get_cursor_position(), 0); - - input.move_to_end(); - assert_eq!(input.get_cursor_position(), 5); - } - - #[test] - fn test_unicode_support() { - let mut input = TextInput::new("hello 🦀 world".to_string()); - assert_eq!(input.get_cursor_position(), 13); // 13 characters - - input.cursor_position = 6; // After "hello " - input.insert_char('🐱'); - assert_eq!(input.get_text(), "hello 🐱🦀 world"); - } - - #[test] - fn test_word_jump_right() { - let mut input = TextInput::new("hello world foo bar".to_string()); - input.cursor_position = 0; - - // Jump from start to "world" - input.move_word_right(); - assert_eq!(input.get_cursor_position(), 6); // After "hello " - - // Jump to "foo" - input.move_word_right(); - assert_eq!(input.get_cursor_position(), 12); // After "world " - - // Jump to "bar" - input.move_word_right(); - assert_eq!(input.get_cursor_position(), 16); // After "foo " - - // Jump to end - input.move_word_right(); - assert_eq!(input.get_cursor_position(), 19); // At end - } - - #[test] - fn test_word_jump_left() { - let mut input = TextInput::new("hello world foo bar".to_string()); - input.move_to_end(); - assert_eq!(input.get_cursor_position(), 19); - - // Jump back to "bar" - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 16); // Start of "bar" - - // Jump back to "foo" - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 12); // Start of "foo" - - // Jump back to "world" - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 6); // Start of "world" - - // Jump back to "hello" - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 0); // Start of "hello" - } - - #[test] - fn test_word_jump_with_multiple_spaces() { - let mut input = TextInput::new("hello world".to_string()); - input.cursor_position = 0; - - // Jump over multiple spaces - input.move_word_right(); - assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world" - - // Jump back should skip spaces - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello" - } - - #[test] - fn test_word_jump_boundaries() { - let mut input = TextInput::new("test".to_string()); - - // At start - word left does nothing - input.cursor_position = 0; - input.move_word_left(); - assert_eq!(input.get_cursor_position(), 0); - - // At end - word right does nothing - input.move_to_end(); - let end_pos = input.get_cursor_position(); - input.move_word_right(); - assert_eq!(input.get_cursor_position(), end_pos); - } - - #[test] - fn test_up_down_arrows() { - let mut input = TextInput::new("hello world".to_string()); - - // Start in the middle - input.cursor_position = 5; - assert_eq!(input.get_cursor_position(), 5); - - // Up arrow should go to start - input.move_to_start(); - assert_eq!(input.get_cursor_position(), 0); - - // Move back to middle - input.cursor_position = 5; - - // Down arrow should go to end - input.move_to_end(); - assert_eq!(input.get_cursor_position(), 11); - } - - #[test] - fn test_delete_word_backward() { - let mut input = TextInput::new("hello world foo".to_string()); - - // Delete "foo" from end - input.move_to_end(); - input.delete_word_backward(); - assert_eq!(input.get_text(), "hello world "); - assert_eq!(input.get_cursor_position(), 12); - - // Delete "world " - input.delete_word_backward(); - assert_eq!(input.get_text(), "hello "); - assert_eq!(input.get_cursor_position(), 6); - - // Delete "hello " - input.delete_word_backward(); - assert_eq!(input.get_text(), ""); - assert_eq!(input.get_cursor_position(), 0); - - // Delete on empty buffer does nothing - input.delete_word_backward(); - assert_eq!(input.get_text(), ""); - assert_eq!(input.get_cursor_position(), 0); - } - - #[test] - fn test_delete_word_backward_middle() { - let mut input = TextInput::new("hello world foo".to_string()); - - // Position in middle of "world" - input.cursor_position = 8; // After "hello wo" - input.delete_word_backward(); - assert_eq!(input.get_text(), "hello rld foo"); - assert_eq!(input.get_cursor_position(), 6); // After "hello " - } - - #[test] - fn test_delete_word_forward() { - let mut input = TextInput::new("hello world foo".to_string()); - - // Delete "hello " from start - input.cursor_position = 0; - input.delete_word_forward(); - assert_eq!(input.get_text(), "world foo"); - assert_eq!(input.get_cursor_position(), 0); - - // Delete "world " - input.delete_word_forward(); - assert_eq!(input.get_text(), "foo"); - assert_eq!(input.get_cursor_position(), 0); - - // Delete "foo" - input.delete_word_forward(); - assert_eq!(input.get_text(), ""); - assert_eq!(input.get_cursor_position(), 0); - - // Delete on empty buffer does nothing - input.delete_word_forward(); - assert_eq!(input.get_text(), ""); - assert_eq!(input.get_cursor_position(), 0); - } - - #[test] - fn test_delete_word_forward_middle() { - let mut input = TextInput::new("hello world foo".to_string()); - - // Position in middle of "world" - input.cursor_position = 8; // After "hello wo" - input.delete_word_forward(); - assert_eq!(input.get_text(), "hello wofoo"); - assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward - } - - #[test] - fn test_delete_word_with_multiple_spaces() { - let mut input = TextInput::new("hello world".to_string()); - - // Delete forward includes trailing spaces - input.cursor_position = 0; - input.delete_word_forward(); - assert_eq!(input.get_text(), "world"); - assert_eq!(input.get_cursor_position(), 0); - } - - #[test] - fn test_undo_redo_basic() { - let mut input = TextInput::empty(); - - // Type "hello" - input.insert_char('h'); - input.insert_char('e'); - input.insert_char('l'); - input.insert_char('l'); - input.insert_char('o'); - assert_eq!(input.get_text(), "hello"); - - // Undo should remove all characters (coalesced into one undo entry) - assert!(input.can_undo()); - assert!(input.undo()); - assert_eq!(input.get_text(), ""); - assert_eq!(input.get_cursor_position(), 0); - - // Redo should restore "hello" - assert!(input.can_redo()); - assert!(input.redo()); - assert_eq!(input.get_text(), "hello"); - assert_eq!(input.get_cursor_position(), 5); - } - - #[test] - fn test_undo_coalescing_breaks_on_cursor_move() { - let mut input = TextInput::empty(); - - // Type "he" - input.insert_char('h'); - input.insert_char('e'); - - // Move cursor to start (breaks coalescing) - input.move_to_start(); - - // Type "llo" - input.insert_char('l'); - input.insert_char('l'); - input.insert_char('o'); - - assert_eq!(input.get_text(), "llohe"); - - // First undo removes "llo" (second coalesced group) - input.undo(); - assert_eq!(input.get_text(), "he"); - - // Second undo removes "he" (first coalesced group) - input.undo(); - assert_eq!(input.get_text(), ""); - } - - #[test] - fn test_undo_backspace() { - let mut input = TextInput::new("hello".to_string()); - - // Backspace once - input.backspace(); - assert_eq!(input.get_text(), "hell"); - - // Undo should restore "hello" - input.undo(); - assert_eq!(input.get_text(), "hello"); - assert_eq!(input.get_cursor_position(), 5); - } - - #[test] - fn test_undo_delete() { - let mut input = TextInput::new("hello".to_string()); - input.cursor_position = 0; - - // Delete first character - input.delete(); - assert_eq!(input.get_text(), "ello"); - - // Undo should restore "hello" - input.undo(); - assert_eq!(input.get_text(), "hello"); - assert_eq!(input.get_cursor_position(), 0); - } - - #[test] - fn test_undo_word_delete() { - let mut input = TextInput::new("hello world".to_string()); - - // Delete "world" backward - input.delete_word_backward(); - assert_eq!(input.get_text(), "hello "); - - // Undo should restore "hello world" - input.undo(); - assert_eq!(input.get_text(), "hello world"); - assert_eq!(input.get_cursor_position(), 11); - } - - #[test] - fn test_undo_clear() { - let mut input = TextInput::new("hello world".to_string()); - - // Clear the buffer - input.clear(); - assert_eq!(input.get_text(), ""); - - // Undo should restore the text - input.undo(); - assert_eq!(input.get_text(), "hello world"); - } - - #[test] - fn test_undo_set_text() { - let mut input = TextInput::new("hello".to_string()); - - // Replace with new text - input.set_text("goodbye".to_string()); - assert_eq!(input.get_text(), "goodbye"); - - // Undo should restore "hello" - input.undo(); - assert_eq!(input.get_text(), "hello"); - } - - #[test] - fn test_redo_clears_on_new_edit() { - let mut input = TextInput::empty(); - - // Type "hello" - input.insert_char('h'); - input.insert_char('e'); - input.insert_char('l'); - input.insert_char('l'); - input.insert_char('o'); - - // Undo - input.undo(); - assert_eq!(input.get_text(), ""); - assert!(input.can_redo()); - - // Make a new edit (should clear redo stack) - input.insert_char('x'); - assert!(!input.can_redo()); - } - - #[test] - fn test_multiple_undo_redo() { - let mut input = TextInput::empty(); - - // First edit: type "hello" - for c in "hello".chars() { - input.insert_char(c); - } - - // Break coalescing - input.move_left(); - - // Second edit: type "world" - for c in "world".chars() { - input.insert_char(c); - } - - assert_eq!(input.get_text(), "hellworldo"); - - // Undo "world" - input.undo(); - assert_eq!(input.get_text(), "hello"); - - // Undo "hello" - input.undo(); - assert_eq!(input.get_text(), ""); - - // Redo "hello" - input.redo(); - assert_eq!(input.get_text(), "hello"); - - // Redo "world" - input.redo(); - assert_eq!(input.get_text(), "hellworldo"); - } - - #[test] - fn test_undo_stack_limit() { - let mut input = TextInput::empty(); - - // Perform 102 separate edits (breaking coalescing each time) - for _i in 0..102 { - input.backspace(); // Break coalescing - input.insert_char('x'); - } - - // Should have at most 100 undo entries - let mut undo_count = 0; - while input.undo() { - undo_count += 1; - } - - // We should have 100 undo entries (the stack limit) - // Plus the final state change from the last coalescing break - assert!( - undo_count <= 100, - "Undo count should be at most 100, got {}", - undo_count - ); - } - - #[test] - fn test_undo_redo_empty_stack() { - let mut input = TextInput::empty(); - - // Undo on empty stack should return false - assert!(!input.can_undo()); - assert!(!input.undo()); - - // Redo on empty stack should return false - assert!(!input.can_redo()); - assert!(!input.redo()); - } - - #[test] - fn test_undo_restores_cursor_position() { - let mut input = TextInput::new("hello world".to_string()); - - // Move cursor to position 5 (before "world") - input.cursor_position = 5; - - // Insert a space - input.insert_char(' '); - assert_eq!(input.get_text(), "hello world"); - assert_eq!(input.get_cursor_position(), 6); - - // Undo should restore both text and cursor position - input.undo(); - assert_eq!(input.get_text(), "hello world"); - assert_eq!(input.get_cursor_position(), 5); - } - - #[test] - fn test_coalescing_consecutive_inserts() { - let mut input = TextInput::empty(); - - // Type several characters - input.insert_char('a'); - input.insert_char('b'); - input.insert_char('c'); - - assert_eq!(input.get_text(), "abc"); - - // Single undo should remove all three (they were coalesced) - input.undo(); - assert_eq!(input.get_text(), ""); - - // No more undo available - assert!(!input.can_undo()); - } - - #[test] - fn test_backspace_breaks_coalescing() { - let mut input = TextInput::empty(); - - // Type "ab" - input.insert_char('a'); - input.insert_char('b'); - - // Backspace - input.backspace(); - assert_eq!(input.get_text(), "a"); - - // Type "c" - input.insert_char('c'); - assert_eq!(input.get_text(), "ac"); - - // Undo should remove just "c" - input.undo(); - assert_eq!(input.get_text(), "a"); - - // Undo should remove backspace operation - input.undo(); - assert_eq!(input.get_text(), "ab"); - - // Undo should remove "ab" - input.undo(); - assert_eq!(input.get_text(), ""); - } -} |
