diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-12-11 14:25:42 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-11 14:25:42 +0100 |
| commit | 693fa082def5ef0fe030cd7fdc3b1df051b35dc6 (patch) | |
| tree | b3f651462bbc1cd2fa5b2d79207343413ef6b83c /default-plugins/sequence/src | |
| parent | fix(zellij-server): unblock building on 32bit targets (#4484) (diff) | |
| download | sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.gz sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.xz sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.zip | |
Command Sequences, Blocking CLI Commands, New Plugin APIs (#4546)
* fix deadlocks
* initial logging to plugin implementation
* only log actions, include keys as Action::Write
* send actions to plugins as a new Event::UserAction
* add Action::Write metadata to plugin API
* run_action plugin command and permission
* block-until-exit/success/failure in the cli
* fix tests
* add NewBlockingPane to the plugin API
* run actions in a different thread so as not to block plugin execution
* add custom context to run_action flow
* fixes for floating pane z_index sorting and unknown protobuf events not crashing plugins
* always render the frame of pinned panes
* macros module
* add macros to config
* KDL macro parsing
* thread macros to the plugin thread
* get/update/delete macros from plugins
* add macros updated event
* add ability to rename macros
* fix deadlock
* fix z_indices with pinned unselectable panes bug
* allow plugins to opt-in to show_cursor
* change show_cursor(bool) to show_cursor(Option<coordinates>)
* test
* upgrade strum to allow proper enum iteration and case insensitive fromstr implementation for Actions
* better cwd tracking for exited command panes
* add initial panes to NewTab Action (no-op for now)
* add initial panes to cli newtab action
* make initial panes work
* add close_on_exit and start_suspended to the cli
* properly convert newtab action (potentially breaking for plugins)
* allow first pane in tab to block
* make distinction between opening panes with the last active client and the current pane in the cli
* improve some apis
* fix fixed size closing pane bug
* fix plugin cursor coordinates
* fix issues with launch-or-focus-plugin having to do with in-place and suppressed panes
* fix: set affected pane in replace pane
* allow setting a pane in place when it is suppressed and being focused (tests failing)
* fix tests
* some fixes for unsuppression of panes
* some fixes for completion_tx in new tab action
* allow replace/suppressed rather than replace/close only
* fixes for inserting suppressed panes without conflicts
* pane focus history and explicit unsuppress plugin api
* fix: prefer explicitly provided pane id in placement in actions that provide it
* fix floating pane coordinate change and z_index
* fix plugin cache dir to persist between sessions
* fix some stack focusing issues when adding panes without a client id
* make sure pinned panes are always above non-pinned floating panes
* allow plugins to copy to clipboard
* initial text styling additions implementations - buggy
* fix dimming issues
* remove extras, stay with just dim and unbold
* unbold and dim as indices
* allow ui components to render success/error colors
* allow plugins to send sigint/sigkill to processes, as well as receive their pid
* ui component fixes and send originating plugin context with run_action
* originating plugin for new tab too
* make plugn show pane command also expand stacked panes
* add sequence plugin
* remove macros
* remove snapshots
* add shortcut for sequence plugin
* fix plugin tests
* plugin tests for new apis
* update snapshots for sequence shortcut
* cleanups and formatting
* Event::CwdChanged
* moar cleanups
* remove some warnings
* remove mroe warnings
* remove dead code
* remove more dead code
* fix minor bugs in sequence plugin
* update sequence help and format codebase
* fix conditional blocking cli commands
* fix initial-command new-tab cli flag
* add client_ids to CwdChanged Event
* last fixes
* add pr
Diffstat (limited to 'default-plugins/sequence/src')
19 files changed, 5137 insertions, 0 deletions
diff --git a/default-plugins/sequence/src/lib.rs b/default-plugins/sequence/src/lib.rs new file mode 100644 index 00000000..1e124979 --- /dev/null +++ b/default-plugins/sequence/src/lib.rs @@ -0,0 +1,5 @@ +pub mod path_formatting; +pub mod state; +pub mod ui; + +pub use state::State; diff --git a/default-plugins/sequence/src/main.rs b/default-plugins/sequence/src/main.rs new file mode 100644 index 00000000..0ac286d9 --- /dev/null +++ b/default-plugins/sequence/src/main.rs @@ -0,0 +1,1131 @@ +mod path_formatting; +mod state; +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 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::Timer, + EventType::PastedText, + EventType::TabUpdate, + EventType::CommandPaneOpened, + ]); + + // Store our own plugin ID and client ID + let plugin_ids = get_plugin_ids(); + self.plugin_id = Some(plugin_ids.plugin_id); + self.client_id = Some(plugin_ids.client_id); + self.cwd = Some(plugin_ids.initial_cwd); + update_title(self); + } + + fn update(&mut self, event: Event) -> bool { + handle_event(self, event) + } + + fn render(&mut self, rows: usize, cols: usize) { + // Store dimensions for use in cursor calculations + self.own_rows = Some(rows); + self.own_columns = Some(cols); + + let max_visible_rows = rows.saturating_sub(5); + + let base_x = 1; + let base_y = 0; + + let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport( + self.execution.all_commands.len(), + max_visible_rows, + self.selection.current_selected_command_index, + self.execution.current_running_command_index, + ); + + let mut table = components::build_table_header(hidden_above > 0 || hidden_below > 0); + + for index in offset..offset + visible_count { + table = components::add_command_row( + table, + self, + index, + offset, + visible_count, + hidden_above, + hidden_below, + ); + } + + print_table_with_coordinates( + table, + base_x, + base_y, + self.own_columns.map(|o| o.saturating_sub(base_x)), + 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); + + if let Some((second_help_text, _)) = second_help { + print_text_with_coordinates(second_help_text, base_x, help_y + 1, None, None); + } + } +} + +pub fn handle_event(state: &mut State, event: Event) -> bool { + use std::path::PathBuf; + + match event { + Event::PermissionRequestResult(_) => { + change_host_folder(PathBuf::from("/")); + update_title(state); + true + }, + Event::ModeUpdate(mode_info) => { + state.shell = mode_info.shell.clone(); + + false + }, + Event::Key(key) => { + let mut 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; + } + update_cursor(state); + 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); + + 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() { + 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; + } + update_cursor(state); + + should_render + }, + 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); + } + + false + }, + 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); + update_title(state); + should_render + }, + 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); + } + + true + }, + _ => 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; + } + + // Ctrl+Space, copy to clipboard + if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) { + state.copy_to_clipboard(); + 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; + } + } + + 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); + 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); + } + } + + true + }, + InputAction::NoAction => 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(); + } + 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 +} + +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; + }; + + // 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(), + ) + } else { + return None; + }; + + let Some(edit_index) = state.selection.current_selected_command_index else { + return None; + }; + + // 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, + ); + + let (max_chain_width, max_status_width) = + components::calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame); + + 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; + }; + + // 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; + + let x = base_x + relative_x; + let y = base_y + relative_y; + + Some((x, y)) +} + +pub fn handle_action_complete( + 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 + .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); + + // 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); + } 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))); + } + 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); + } + + 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; + } + } else { + // Failed to parse sequence_id, ignore this action + return false; + } + } else { + return false; + } +} + +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; + } + } + + // If primary pane was not found, select a new one + if !primary_pane_found { + state.primary_pane_id = None; + } + } + 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, + }; + + 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; + } + } +} + +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); + } + } + + // 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, + }; + + 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 + ); + } + } + + 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) + ); + } + update_title(state); + true +} + +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), + ); + if let Some(coordinates) = coordinates { + // TODO: better + // show_self(true); + change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]); + } +} + +fn update_spinner(state: &mut State) -> bool { + // Advance spinner frame for RUNNING animation + state.layout.spinner_frame = (state.layout.spinner_frame + 1) % 8; + + // Reset timer scheduled flag since this timer just fired + state.layout.spinner_timer_scheduled = false; + + // If we have a running command, schedule next timer event + let has_running = state + .execution + .all_commands + .iter() + .any(|command| matches!(command.get_status(), CommandStatus::Running(_))); + if has_running && !state.layout.spinner_timer_scheduled { + set_timeout(0.1); + state.layout.spinner_timer_scheduled = true; + } + has_running +} + +fn rerun_sequence(state: &mut State) { + if !state.can_run_sequence() { + return; + } + + state.execution.all_commands.retain(|c| !c.is_empty()); + + if state.execution.all_commands.is_empty() { + return; + } + + let selected_index = state + .selection + .current_selected_command_index + .unwrap_or(0) + .min(state.execution.all_commands.len().saturating_sub(1)); + + let mut close_replaced_pane = true; + + // 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 + } + }); + + // 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 + ); + } + + // Reset the command status to Pending + state + .execution + .all_commands + .get_mut(i) + .map(|c| c.set_status(CommandStatus::Pending)); + } + + // 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)); + + // Set the current command index to the selected command + state.execution.current_running_command_index = selected_index; + + // Execute the command at the selected index + if let Some((command_text, chain_type, command_cwd)) = 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")); + + 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() + }; + + // 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, + } + } 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) + }; + + // 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) + }; + + let action = Action::NewBlockingPane { + placement, + command: Some(command), + pane_name: Some(command_text.trim().to_string()), + unblock_condition, + near_current_pane: true, + }; + + // Generate a new sequence ID for the restart + state.execution.sequence_id += 1; + + // 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; + + // 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); + } +} + +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 { + send_sigkill_to_pane_id(pane_id); + } + command.set_status(CommandStatus::Interrupted(pane_id)); + } + } + state.execution.is_running = false; + } 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 { + return; + }; + + 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 + .execution + .all_commands + .iter() + .any(|c| matches!(c.get_status(), CommandStatus::Interrupted(..))); + let all_commands_complete = 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" + } else { + "Stopped" + } + }; + rename_plugin_pane(plugin_id, title); +} diff --git a/default-plugins/sequence/src/path_formatting.rs b/default-plugins/sequence/src/path_formatting.rs new file mode 100644 index 00000000..01bf50e6 --- /dev/null +++ b/default-plugins/sequence/src/path_formatting.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +/// Expand a path string to an absolute PathBuf +/// Handles ~, ./, .., relative and absolute paths +pub fn expand_path(path_str: &str, current_cwd: Option<&PathBuf>) -> Option<PathBuf> { + let expanded = if path_str.starts_with("~/") || path_str == "~" { + let home_dir = std::env::var("HOME").ok()?; + if path_str == "~" { + PathBuf::from(home_dir) + } else { + PathBuf::from(home_dir).join(&path_str[2..]) + } + } else if path_str.starts_with('/') { + PathBuf::from(path_str) + } else { + let base = current_cwd?; + base.join(path_str) + }; + + let mut normalized = PathBuf::new(); + for component in expanded.components() { + match component { + std::path::Component::ParentDir => { + normalized.pop(); + }, + std::path::Component::CurDir => {}, + _ => { + normalized.push(component); + }, + } + } + + Some(normalized) +} + +/// Format a path for display in the prompt +/// Example: /home/user/some/long/path -> ~/s/l/path +pub fn format_cwd(cwd: &PathBuf) -> String { + let path_str = cwd.to_string_lossy().to_string(); + + let home_dir = std::env::var("HOME").unwrap_or_default(); + let path_str = if !home_dir.is_empty() && path_str.starts_with(&home_dir) { + path_str.replacen(&home_dir, "~", 1) + } else { + path_str + }; + + let parts: Vec<&str> = path_str.split('/').collect(); + + if parts.len() <= 1 { + return path_str; + } + + let mut formatted_parts = Vec::new(); + for (i, part) in parts.iter().enumerate() { + if i == parts.len() - 1 { + formatted_parts.push(part.to_string()); + } else if part.is_empty() { + formatted_parts.push(String::new()); + } else { + formatted_parts.push(part.chars().next().unwrap_or_default().to_string()); + } + } + + formatted_parts.join("/") +} + +/// Alias for expand_path for backwards compatibility +pub fn resolve_path(current_cwd: Option<&PathBuf>, path_str: &str) -> Option<PathBuf> { + expand_path(path_str, current_cwd) +} diff --git a/default-plugins/sequence/src/state/chain_type.rs b/default-plugins/sequence/src/state/chain_type.rs new file mode 100644 index 00000000..3da3316c --- /dev/null +++ b/default-plugins/sequence/src/state/chain_type.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use zellij_tile::prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChainType { + And, + Or, + Then, + None, +} + +impl Default for ChainType { + fn default() -> Self { + ChainType::None + } +} + +impl ChainType { + pub fn to_unblock_condition(&self) -> Option<UnblockCondition> { + match self { + ChainType::And => Some(UnblockCondition::OnExitSuccess), + ChainType::Or => Some(UnblockCondition::OnExitFailure), + ChainType::Then => Some(UnblockCondition::OnAnyExit), + ChainType::None => None, + } + } + + pub fn as_str(&self) -> &str { + match self { + ChainType::And => "&&", + ChainType::Or => "||", + ChainType::Then => ";", + ChainType::None => "", + } + } + + pub fn cycle_next(&mut self) { + *self = match self { + ChainType::And => ChainType::Or, + ChainType::Or => ChainType::Then, + ChainType::Then => ChainType::And, + ChainType::None => ChainType::And, + }; + } +} diff --git a/default-plugins/sequence/src/state/command_entry.rs b/default-plugins/sequence/src/state/command_entry.rs new file mode 100644 index 00000000..96a0020b --- /dev/null +++ b/default-plugins/sequence/src/state/command_entry.rs @@ -0,0 +1,86 @@ +use super::{ChainType, CommandStatus}; +use std::path::PathBuf; +use std::time::Instant; +use zellij_tile::prelude::PaneId; + +#[derive(Debug, Clone)] +pub struct CommandEntry { + text: String, + cwd: Option<PathBuf>, + pub(super) chain_type: ChainType, + pub(super) status: CommandStatus, + pub(super) start_time: std::time::Instant, +} + +impl Default for CommandEntry { + fn default() -> Self { + CommandEntry { + text: String::default(), + cwd: None, + chain_type: ChainType::default(), + status: CommandStatus::default(), + start_time: Instant::now(), + } + } +} + +impl CommandEntry { + pub fn new(text: &str, cwd: Option<PathBuf>) -> Self { + CommandEntry { + text: text.to_owned(), + cwd, + ..Default::default() + } + } + pub fn with_and(mut self) -> Self { + self.chain_type = ChainType::And; + self + } + pub fn get_text(&self) -> String { + self.text.clone() + } + pub fn set_text(&mut self, text: String) { + self.text = text; + } + pub fn clear_text(&mut self) { + self.text.clear(); + } + pub fn get_chain_type(&self) -> ChainType { + self.chain_type + } + pub fn set_chain_type(&mut self, chain_type: ChainType) { + self.chain_type = chain_type; + } + pub fn get_status(&self) -> CommandStatus { + self.status.clone() + } + pub fn set_status(&mut self, status: CommandStatus) { + self.status = status; + } + pub fn get_pane_id(&self) -> Option<PaneId> { + self.status.get_pane_id() + } + pub fn is_empty(&self) -> bool { + self.text.trim().is_empty() + } + pub fn fill_chain_type_if_empty(&mut self) { + if let ChainType::None = self.chain_type { + self.chain_type = ChainType::And; + } + } + pub fn clear_chain_type(&mut self) { + self.chain_type = ChainType::None; + } + 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() + } + pub fn set_cwd(&mut self, cwd: Option<PathBuf>) { + self.cwd = cwd; + } +} diff --git a/default-plugins/sequence/src/state/command_parser.rs b/default-plugins/sequence/src/state/command_parser.rs new file mode 100644 index 00000000..7f0898f5 --- /dev/null +++ b/default-plugins/sequence/src/state/command_parser.rs @@ -0,0 +1,327 @@ +use super::ChainType; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ParseState { + Normal, + InSingleQuote, + InDoubleQuote, + Escaped(EscapeContext), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum EscapeContext { + Normal, + DoubleQuote, +} + +pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)> { + let mut segments = Vec::new(); + let mut current_segment = String::new(); + let mut state = ParseState::Normal; + let chars: Vec<char> = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + + match state { + ParseState::Normal => { + if ch == '\\' { + state = ParseState::Escaped(EscapeContext::Normal); + current_segment.push(ch); + i += 1; + } else if ch == '\'' { + state = ParseState::InSingleQuote; + current_segment.push(ch); + i += 1; + } else if ch == '"' { + state = ParseState::InDoubleQuote; + current_segment.push(ch); + i += 1; + } else if ch == '&' && i + 1 < chars.len() && chars[i + 1] == '&' { + let segment_text = current_segment.trim().to_string(); + if !segment_text.is_empty() { + segments.push((segment_text, Some(ChainType::And))); + } + current_segment.clear(); + i += 2; + } else if ch == '|' && i + 1 < chars.len() && chars[i + 1] == '|' { + let segment_text = current_segment.trim().to_string(); + if !segment_text.is_empty() { + segments.push((segment_text, Some(ChainType::Or))); + } + current_segment.clear(); + i += 2; + } else if ch == ';' { + let segment_text = current_segment.trim().to_string(); + if !segment_text.is_empty() { + segments.push((segment_text, Some(ChainType::Then))); + } + current_segment.clear(); + i += 1; + } else { + current_segment.push(ch); + i += 1; + } + }, + ParseState::InSingleQuote => { + current_segment.push(ch); + if ch == '\'' { + state = ParseState::Normal; + } + i += 1; + }, + ParseState::InDoubleQuote => { + if ch == '\\' { + state = ParseState::Escaped(EscapeContext::DoubleQuote); + current_segment.push(ch); + i += 1; + } else if ch == '"' { + current_segment.push(ch); + state = ParseState::Normal; + i += 1; + } else { + current_segment.push(ch); + i += 1; + } + }, + ParseState::Escaped(context) => { + current_segment.push(ch); + state = match context { + EscapeContext::Normal => ParseState::Normal, + EscapeContext::DoubleQuote => ParseState::InDoubleQuote, + }; + i += 1; + }, + } + } + + let final_segment = current_segment.trim().to_string(); + if !final_segment.is_empty() { + segments.push((final_segment, None)); + } + + 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; + } + + if segments.len() == 1 { + if let Some((text, Some(chain_type))) = segments.first() { + return Some((text.clone(), *chain_type)); + } + return None; + } + + if let Some((first_text, Some(chain_type))) = segments.first() { + return Some((first_text.clone(), *chain_type)); + } + + None +} + +pub fn get_remaining_after_first_segment(text: &str) -> Option<String> { + let segments = split_by_chain_operators(text); + + if segments.len() <= 1 { + return None; + } + + 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())); + } + } + result.push_str(segment_text); + if let Some(chain_type) = chain_type_opt { + result.push_str(&format!(" {} ", chain_type.as_str())); + } + } + + Some(result.trim().to_string()) +} + +pub fn detect_cd_command(text: &str) -> Option<String> { + let trimmed = text.trim(); + + if trimmed == "cd" { + return Some("~".to_string()); + } + + if trimmed.starts_with("cd ") { + let path = trimmed[3..].trim(); + return Some(path.to_string()); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_command_no_operator() { + let result = split_by_chain_operators("ls"); + assert_eq!(result, vec![("ls".to_string(), None)]); + } + + #[test] + fn test_two_commands_with_and() { + let result = split_by_chain_operators("ls && pwd"); + assert_eq!( + result, + vec![ + ("ls".to_string(), Some(ChainType::And)), + ("pwd".to_string(), None) + ] + ); + } + + #[test] + fn test_two_commands_with_or() { + let result = split_by_chain_operators("cmd1 || cmd2"); + assert_eq!( + result, + vec![ + ("cmd1".to_string(), Some(ChainType::Or)), + ("cmd2".to_string(), None) + ] + ); + } + + #[test] + fn test_two_commands_with_semicolon() { + let result = split_by_chain_operators("echo hi ; ls"); + assert_eq!( + result, + vec![ + ("echo hi".to_string(), Some(ChainType::Then)), + ("ls".to_string(), None) + ] + ); + } + + #[test] + fn test_three_commands_mixed() { + let result = split_by_chain_operators("a && b || c"); + assert_eq!( + result, + vec![ + ("a".to_string(), Some(ChainType::And)), + ("b".to_string(), Some(ChainType::Or)), + ("c".to_string(), None) + ] + ); + } + + #[test] + fn test_operator_in_single_quotes() { + let result = split_by_chain_operators("echo '&&' && ls"); + assert_eq!( + result, + vec![ + ("echo '&&'".to_string(), Some(ChainType::And)), + ("ls".to_string(), None) + ] + ); + } + + #[test] + fn test_operator_in_double_quotes() { + let result = split_by_chain_operators("echo \"||\" || pwd"); + assert_eq!( + result, + vec![ + ("echo \"||\"".to_string(), Some(ChainType::Or)), + ("pwd".to_string(), None) + ] + ); + } + + #[test] + fn test_escaped_operator() { + let result = split_by_chain_operators("echo \\&& test"); + assert_eq!(result, vec![("echo \\&& test".to_string(), None)]); + } + + #[test] + fn test_complex_quoting() { + let result = split_by_chain_operators("cmd1 && echo \"a || b\" && cmd2"); + assert_eq!( + result, + vec![ + ("cmd1".to_string(), Some(ChainType::And)), + ("echo \"a || b\"".to_string(), Some(ChainType::And)), + ("cmd2".to_string(), None) + ] + ); + } + + #[test] + fn test_empty_segments_filtered() { + let result = split_by_chain_operators("cmd1 && && cmd2"); + assert_eq!( + result, + vec![ + ("cmd1".to_string(), Some(ChainType::And)), + ("cmd2".to_string(), None) + ] + ); + } + + #[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!( + result, + vec![ + ("cmd1".to_string(), Some(ChainType::And)), + ("cmd2".to_string(), None) + ] + ); + } + + #[test] + fn test_trailing_operator_with_space() { + let result = split_by_chain_operators("ls && "); + assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]); + } +} diff --git a/default-plugins/sequence/src/state/command_status.rs b/default-plugins/sequence/src/state/command_status.rs new file mode 100644 index 00000000..39717e8b --- /dev/null +++ b/default-plugins/sequence/src/state/command_status.rs @@ -0,0 +1,26 @@ +use zellij_tile::prelude::*; + +#[derive(Debug, Clone)] +pub enum CommandStatus { + Exited(Option<i32>, Option<PaneId>), + Running(Option<PaneId>), + Pending, + Interrupted(Option<PaneId>), +} + +impl CommandStatus { + pub fn get_pane_id(&self) -> Option<PaneId> { + match self { + CommandStatus::Exited(_, pane_id) => *pane_id, + CommandStatus::Running(pane_id) => *pane_id, + CommandStatus::Pending => None, + CommandStatus::Interrupted(pane_id) => *pane_id, + } + } +} + +impl Default for CommandStatus { + fn default() -> Self { + CommandStatus::Pending + } +} diff --git a/default-plugins/sequence/src/state/editing.rs b/default-plugins/sequence/src/state/editing.rs new file mode 100644 index 00000000..3deac619 --- /dev/null +++ b/default-plugins/sequence/src/state/editing.rs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 00000000..ff630ec0 --- /dev/null +++ b/default-plugins/sequence/src/state/execution.rs @@ -0,0 +1,265 @@ +use crate::path_formatting::format_cwd; +use crate::state::{ChainType, CommandEntry, CommandStatus}; +use std::collections::BTreeMap; +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>, +} + +impl Execution { + pub fn new() -> Self { + Self { + all_commands: vec![CommandEntry::default()], + current_running_command_index: 0, + is_running: false, + sequence_id: 0, + primary_pane_id_before_sequence: None, + } + } + + pub fn longest_cwd_display(&self, global_cwd: &Option<PathBuf>) -> String { + self.all_commands + .iter() + .map(|cmd| { + let cwd = cmd.get_cwd().or_else(|| global_cwd.clone()); + if let Some(cwd) = &cwd { + format_cwd(cwd) + } else { + "~".to_string() + } + }) + .max_by_key(|s| s.len()) + .unwrap_or_else(|| "~".to_string()) + } + + pub fn remove_empty_commands(&mut self) { + if self.all_commands.iter().len() > 1 { + self.all_commands.retain(|c| !c.get_text().is_empty()); + } + } + + pub fn get_first_command(&self) -> Option<CommandEntry> { + self.all_commands.iter().next().cloned() + } + + pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) { + self.all_commands + .get_mut(command_index) + .map(|c| c.set_status(status)); + } + + pub fn set_current_running_command_status(&mut self, status: CommandStatus) { + self.all_commands + .get_mut(self.current_running_command_index) + .map(|c| c.set_status(status)); + } + + pub fn get_current_running_command_status(&self) -> Option<CommandStatus> { + self.all_commands + .get(self.current_running_command_index) + .map(|c| c.get_status()) + } + + pub fn can_run_sequence(&self) -> bool { + self.all_commands.iter().any(|command| !command.is_empty()) + } + + pub fn copy_to_clipboard(&self) { + let text_to_copy = self + .all_commands + .iter() + .map(|c| format!("{}", c.get_text())) + .collect::<Vec<_>>() + .join("\n"); + if !text_to_copy.is_empty() { + 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, + }; + + 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 { + fn default() -> Self { + Self::new() + } +} diff --git a/default-plugins/sequence/src/state/layout.rs b/default-plugins/sequence/src/state/layout.rs new file mode 100644 index 00000000..65a267e0 --- /dev/null +++ b/default-plugins/sequence/src/state/layout.rs @@ -0,0 +1,73 @@ +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, +} + +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 { + fn default() -> Self { + Self::new() + } +} diff --git a/default-plugins/sequence/src/state/mod.rs b/default-plugins/sequence/src/state/mod.rs new file mode 100644 index 00000000..19738be7 --- /dev/null +++ b/default-plugins/sequence/src/state/mod.rs @@ -0,0 +1,446 @@ +mod chain_type; +mod command_entry; +mod command_parser; +mod command_status; +mod editing; +mod execution; +mod layout; +pub mod positioning; +mod selection; + +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_status::CommandStatus; +pub use editing::Editing; +pub use execution::Execution; +pub use layout::Layout; +pub use selection::Selection; + +use crate::ui::text_input::TextInput; +use std::path::PathBuf; +use zellij_tile::prelude::*; + +pub struct State { + pub shell: Option<PathBuf>, + pub plugin_id: Option<u32>, + pub client_id: Option<u16>, + 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>, +} + +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); + } + } + + 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); + } + }); + } + + 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; + } + } + + 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(); + } + } + + 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.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 can_run_sequence(&self) -> bool { + self.execution.can_run_sequence() + } + + pub fn copy_to_clipboard(&mut self) { + self.execution.copy_to_clipboard(); + } + + pub fn current_selected_command_mut(&mut self) -> Option<&mut CommandEntry> { + let Some(i) = self.selection.current_selected_command_index else { + return None; + }; + self.execution.all_commands.get_mut(i) + } + pub fn reposition_plugin(&mut self) -> bool { + let mut repositioned = false; + let Some(plugin_id) = self.plugin_id else { + return repositioned; + }; + let Some(total_viewport_rows) = self.total_viewport_rows else { + return repositioned; + }; + let Some(total_viewport_columns) = self.total_viewport_columns else { + return repositioned; + }; + 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 + ); + 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, + ); + + let longest_line = crate::ui::components::calculate_longest_line( + &longest_cwd_display, + longest_command, + max_chain_width, + max_status_width, + ); + + 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 height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100); + + // 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 + ); + 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 + .iter() + .all(|command| matches!(command.get_status(), CommandStatus::Pending)) + } + + fn current_selected_command(&self) -> Option<&CommandEntry> { + let Some(i) = self.selection.current_selected_command_index else { + return None; + }; + 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 new file mode 100644 index 00000000..6acadfff --- /dev/null +++ b/default-plugins/sequence/src/state/positioning.rs @@ -0,0 +1,48 @@ +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), + ); + 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 new file mode 100644 index 00000000..18f83bcd --- /dev/null +++ b/default-plugins/sequence/src/state/selection.rs @@ -0,0 +1,214 @@ +use crate::state::{CommandEntry, Editing}; +use std::path::PathBuf; +use zellij_tile::prelude::*; + +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, + } + } + + 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); + match self.current_selected_command_index.as_mut() { + Some(i) if *i > 0 => { + *i -= 1; + }, + None => { + self.current_selected_command_index = Some(all_commands.len().saturating_sub(1)); + }, + _ => { + 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); + match self.current_selected_command_index.as_mut() { + Some(i) if *i < all_commands.len().saturating_sub(1) => { + *i += 1; + }, + None => { + self.current_selected_command_index = Some(0); + }, + _ => { + 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); + } + } +} + +impl Default for Selection { + fn default() -> Self { + Self::new() + } +} diff --git a/default-plugins/sequence/src/ui/components.rs b/default-plugins/sequence/src/ui/components.rs new file mode 100644 index 00000000..3fe2ae1a --- /dev/null +++ b/default-plugins/sequence/src/ui/components.rs @@ -0,0 +1,596 @@ +use crate::path_formatting::format_cwd; +use crate::state::{CommandStatus, State}; +use crate::ui::truncation::{calculate_available_cmd_width, truncate_middle}; +use std::path::PathBuf; +use zellij_tile::prelude::*; + +const SPINNER_FRAMES: &[&str] = &["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §"]; + +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 = + "<Ctrl w> - close all, <Enter> - run from selected, <e> - edit"; +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"; + +fn select_help_text(sequence: &State) -> &'static str { + let is_running = sequence + .execution + .all_commands + .iter() + .any(|command| matches!(command.get_status(), CommandStatus::Running(_))); + let all_pending = sequence + .execution + .all_commands + .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 + } + } else if is_running { + if has_selection { + HELP_RUNNING_WITH_SELECTION + } else { + HELP_RUNNING_NO_SELECTION + } + } else { + if has_selection && !is_editing { + HELP_STOPPED_WITH_SELECTION + } else if is_editing { + HELP_EDITING_FIRST_LINE + } else { + HELP_STOPPED_NO_SELECTION + } + } +} + +fn style_help_text(text: &str) -> Text { + Text::new(text) + .color_substring(3, "<Ctrl c>") + .color_substring(3, "<Ctrl w>") + .color_substring(3, "<ββ>") + .color_substring(3, "<Esc>") + .color_substring(3, "<Enter>") + .color_substring(3, "<Ctrl Enter>") + .color_substring(3, "<e>") +} + +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 { + if let Some((first_line, second_line)) = split_help_line(help_text, width) { + let first_len = first_line.chars().count(); + let first_styled = style_help_text(&first_line).unbold_all(); + + let second_line_width = second_line.chars().count(); + let (second_styled, second_len) = if second_line_width > width { + let (truncated, len) = truncate_help_line(&second_line, width); + (style_help_text(&truncated).unbold_all(), len) + } else { + ( + style_help_text(&second_line).unbold_all(), + second_line_width, + ) + }; + + return (first_styled, first_len, Some((second_styled, second_len))); + } + + let (truncated_text, help_len) = truncate_help_line(help_text, width); + ( + style_help_text(&truncated_text).unbold_all(), + help_len, + None, + ) + } else { + let help_len = help_text.chars().count(); + (style_help_text(help_text).unbold_all(), help_len, None) + } +} + +fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> { + let text_width = help_text.chars().count(); + + if text_width <= max_width { + return None; + } + + let keybindings = parse_help_keybindings(help_text); + + if keybindings.is_empty() { + return None; + } + + for split_point in 1..keybindings.len() { + let first_part: Vec<String> = keybindings + .iter() + .take(split_point) + .map(|(key, _, action)| format!("{} - {}", key, action)) + .collect(); + let first_line = first_part.join(", "); + + let second_part: Vec<String> = keybindings + .iter() + .skip(split_point) + .map(|(key, _, action)| format!("{} - {}", key, action)) + .collect(); + let second_line = second_part.join(", "); + + if first_line.chars().count() <= max_width && second_line.chars().count() <= max_width { + return Some((first_line, second_line)); + } + } + + None +} + +fn folder_cell(cwd: &Option<PathBuf>) -> (Text, String) { + let cwd_display = if let Some(cwd) = cwd { + format_cwd(cwd) + } else { + "~".to_string() + }; + let folder_display = format!("{} >", cwd_display); + let text = Text::new(&folder_display).color_range(0, 0..cwd_display.len()); + (text, folder_display) +} + +fn command_cell(text: &str) -> Text { + let text = if text.is_empty() { " " } else { text }; + Text::new(text) +} + +fn chain_cell(chain_type: &crate::state::ChainType) -> Text { + let text = chain_type_to_str(chain_type); + if !text.is_empty() { + Text::new(text).color_range(1, 0..text.len()) + } else { + Text::new(text) + } +} + +fn get_spinner_frame(frame: usize) -> &'static str { + SPINNER_FRAMES[frame % SPINNER_FRAMES.len()] +} + +pub fn format_status_text(status: &CommandStatus, spinner_frame: usize) -> String { + match status { + CommandStatus::Running(_) => { + let spinner = get_spinner_frame(spinner_frame); + format!("[RUNNING] {}", spinner) + }, + CommandStatus::Interrupted(_) => "[INTERRUPTED]".to_string(), + CommandStatus::Exited(Some(code), _) => format!("[EXIT CODE: {}]", code), + CommandStatus::Exited(None, _) => "[EXITED]".to_string(), + CommandStatus::Pending => " ".to_string(), + } +} + +fn apply_status_color(text: Text, status: &CommandStatus, status_str: &str) -> Text { + match status { + CommandStatus::Running(_) => text.color_range(3, 0..status_str.len()), + CommandStatus::Interrupted(_) => text.error_color_range(0..status_str.len()), + CommandStatus::Exited(Some(0), _) => { + let number_start = 12; + let number_end = status_str.len() - 1; + text.success_color_range(number_start..number_end) + }, + CommandStatus::Exited(Some(_), _) => { + let number_start = 12; + let number_end = status_str.len() - 1; + text.error_color_range(number_start..number_end) + }, + _ => text, + } +} + +fn status_cell(status: &CommandStatus, spinner_frame: usize) -> Text { + let status_str = format_status_text(status, spinner_frame); + let text = Text::new(&status_str); + apply_status_color(text, status, &status_str) +} + +fn apply_row_styles(cells: Vec<Text>, is_selected: bool) -> Vec<Text> { + let mut styled_cells = cells; + + if is_selected { + styled_cells = styled_cells.into_iter().map(|c| c.selected()).collect(); + } + + styled_cells +} + +fn overflow_cell(indicator: &str, is_selected: bool) -> Text { + let mut text = Text::new(indicator).dim_all(); + if is_selected { + text = text.selected(); + } + text +} + +fn command_sequence_row( + state: &State, + cwd: &Option<PathBuf>, + truncated_cmd_text: &str, + chain_type: &crate::state::ChainType, + status: &crate::state::CommandStatus, + is_selected: bool, + overflow_indicator: Option<String>, +) -> (Vec<Text>, usize) { + let (folder_text, folder_display) = folder_cell(cwd); + + let mut cells = vec![ + folder_text, + command_cell(truncated_cmd_text), + chain_cell(chain_type), + status_cell(status, state.layout.spinner_frame), + ]; + + cells = apply_row_styles(cells, is_selected); + + let truncated_cmd_text = if truncated_cmd_text.is_empty() { + " " + } else { + truncated_cmd_text + }; + let mut row_length = folder_display.chars().count() + + truncated_cmd_text.chars().count() + + chain_type_to_str(chain_type).chars().count() + + format_status_text(status, state.layout.spinner_frame) + .chars() + .count() + + 3; + + if let Some(indicator) = overflow_indicator { + let indicator_len = indicator.chars().count(); + cells.push(overflow_cell(&indicator, is_selected)); + row_length += indicator_len + 1; + } + + (cells, row_length) +} + +pub fn build_table_header(has_overflow: bool) -> Table { + let header_cols = if has_overflow { + vec![" ", " ", " ", " ", " "] + } else { + vec![" ", " ", " ", " "] + }; + Table::new().add_row(header_cols) +} + +fn calculate_overflow_indicator( + visible_idx: usize, + visible_count: usize, + hidden_above: usize, + hidden_below: usize, +) -> Option<String> { + if visible_idx == 0 && hidden_above > 0 { + Some(format!("[+{}]", hidden_above)) + } else if visible_idx == visible_count.saturating_sub(1) && hidden_below > 0 { + Some(format!("[+{}]", hidden_below)) + } else if hidden_above > 0 || hidden_below > 0 { + Some(format!(" ")) + } else { + None + } +} + +pub fn calculate_row_layout_info( + index: usize, + offset: usize, + visible_count: usize, + hidden_above: usize, + hidden_below: usize, + cols: usize, + folder_width: usize, + max_chain_width: usize, + max_status_width: usize, +) -> Option<(Option<String>, usize)> { + if index < offset || index >= offset + visible_count { + return None; + } + + let visible_idx = index.saturating_sub(offset); + let overflow_indicator = + calculate_overflow_indicator(visible_idx, visible_count, hidden_above, hidden_below); + + let available_cmd_width = calculate_available_cmd_width( + cols, + folder_width, + overflow_indicator.as_ref(), + max_chain_width, + max_status_width, + ); + + Some((overflow_indicator, available_cmd_width)) +} + +pub fn add_command_row( + table: Table, + state: &State, + index: usize, + offset: usize, + visible_count: usize, + hidden_above: usize, + hidden_below: usize, +) -> Table { + let command = match state.execution.all_commands.get(index) { + Some(cmd) => cmd, + None => return table, + }; + + let cmd_text = &command.get_text(); + let chain_type = &command.get_chain_type(); + let status = &command.get_status(); + let command_cwd = command.get_cwd().or_else(|| state.cwd.clone()); + + let cols = state.own_columns.unwrap_or(80); + let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd); + let folder_display = format!("{} >", longest_cwd_display); + let folder_width = folder_display.chars().count(); + + let (max_chain_width, max_status_width) = + calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame); + + let Some((overflow_indicator, available_cmd_width)) = calculate_row_layout_info( + index, + offset, + visible_count, + hidden_above, + hidden_below, + cols, + folder_width, + max_chain_width, + max_status_width, + ) else { + 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( + state, + &command_cwd, + &truncated_cmd_text, + chain_type, + status, + state.selection.current_selected_command_index == Some(index), + overflow_indicator, + ); + + table.add_styled_row(row) +} + +pub fn calculate_max_widths( + commands: &[crate::state::CommandEntry], + spinner_frame: usize, +) -> (usize, usize) { + let max_chain_width = commands + .iter() + .map(|cmd| chain_type_to_str(&cmd.get_chain_type()).chars().count()) + .max() + .unwrap_or(0); + + let max_status_width = commands + .iter() + .map(|cmd| { + format_status_text(&cmd.get_status(), spinner_frame) + .chars() + .count() + }) + .max() + .unwrap_or(0); + + (max_chain_width, max_status_width) +} + +pub fn calculate_longest_line( + longest_cwd_display: &str, + longest_command: usize, + max_chain_width: usize, + max_status_width: usize, +) -> usize { + let folder_display = format!("{} >", longest_cwd_display); + let cell_padding = 3; + folder_display.chars().count() + + longest_command + + max_chain_width + + max_status_width + + cell_padding +} + +pub fn chain_type_to_str(chain_type: &crate::state::ChainType) -> &'static str { + match chain_type { + crate::state::ChainType::And => "AND", + crate::state::ChainType::Or => "OR", + crate::state::ChainType::Then => "THEN", + crate::state::ChainType::None => " ", + } +} + +pub fn truncate_help_line(help_text: &str, max_width: usize) -> (String, usize) { + let text_width = help_text.chars().count(); + + if text_width <= max_width { + return (help_text.to_string(), text_width); + } + + let keybindings = parse_help_keybindings(help_text); + + if let Some(result) = try_shortened_help(&keybindings, max_width) { + return result; + } + + if let Some(result) = try_keys_spaced(&keybindings, max_width) { + return result; + } + + if let Some(result) = try_keys_tight(&keybindings, max_width) { + return result; + } + + truncate_with_ellipsis( + &keybindings + .iter() + .map(|(k, _, _)| *k) + .collect::<Vec<_>>() + .join("/"), + max_width, + ) +} + +fn parse_help_keybindings(help_text: &str) -> Vec<(&str, &str, &str)> { + help_text + .split(", ") + .filter_map(|part| { + part.find(" - ").map(|dash_pos| { + let key = &part[..dash_pos]; + let action = &part[dash_pos + 3..]; + let first_word = action.split_whitespace().next().unwrap_or(action); + (key, first_word, action) + }) + }) + .collect() +} + +fn try_shortened_help( + keybindings: &[(&str, &str, &str)], + max_width: usize, +) -> Option<(String, usize)> { + let shortened: Vec<String> = keybindings + .iter() + .map(|(key, first_word, _)| format!("{} - {}", key, first_word)) + .collect(); + let shortened_text = shortened.join(", "); + let shortened_width = shortened_text.chars().count(); + + if shortened_width <= max_width { + Some((shortened_text, shortened_width)) + } else { + None + } +} + +fn try_keys_spaced( + keybindings: &[(&str, &str, &str)], + max_width: usize, +) -> Option<(String, usize)> { + let keys_spaced_text = keybindings + .iter() + .map(|(key, _, _)| *key) + .collect::<Vec<_>>() + .join(" / "); + let keys_spaced_width = keys_spaced_text.chars().count(); + + if keys_spaced_width <= max_width { + Some((keys_spaced_text, keys_spaced_width)) + } else { + None + } +} + +fn try_keys_tight(keybindings: &[(&str, &str, &str)], max_width: usize) -> Option<(String, usize)> { + let keys_tight_text = keybindings + .iter() + .map(|(key, _, _)| *key) + .collect::<Vec<_>>() + .join("/"); + let keys_tight_width = keys_tight_text.chars().count(); + + if keys_tight_width <= max_width { + Some((keys_tight_text, keys_tight_width)) + } else { + None + } +} + +fn truncate_with_ellipsis(text: &str, max_width: usize) -> (String, usize) { + let available = max_width.saturating_sub(3); + let mut truncated = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + if current_width + 1 <= available { + truncated.push(ch); + current_width += 1; + } else { + break; + } + } + + truncated.push_str("..."); + let truncated_width = truncated.chars().count(); + (truncated, truncated_width) +} diff --git a/default-plugins/sequence/src/ui/fuzzy_complete.rs b/default-plugins/sequence/src/ui/fuzzy_complete.rs new file mode 100644 index 00000000..0feae7e4 --- /dev/null +++ b/default-plugins/sequence/src/ui/fuzzy_complete.rs @@ -0,0 +1,321 @@ +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/layout_calculations.rs b/default-plugins/sequence/src/ui/layout_calculations.rs new file mode 100644 index 00000000..911dbc62 --- /dev/null +++ b/default-plugins/sequence/src/ui/layout_calculations.rs @@ -0,0 +1,28 @@ +pub fn calculate_viewport( + total_commands: usize, + max_visible: usize, + selected_index: Option<usize>, + running_index: usize, +) -> (usize, usize, usize, usize) { + if total_commands <= max_visible { + return (0, total_commands, 0, 0); + } + + let focus_index = selected_index.unwrap_or(running_index); + let half_visible = max_visible / 2; + + let offset = if focus_index < half_visible { + 0 + } else if focus_index >= total_commands.saturating_sub(half_visible) { + total_commands.saturating_sub(max_visible) + } else { + focus_index.saturating_sub(half_visible) + }; + + let offset = offset.min(total_commands.saturating_sub(max_visible)); + let visible_count = max_visible.min(total_commands.saturating_sub(offset)); + let hidden_above = offset; + let hidden_below = total_commands.saturating_sub(offset + visible_count); + + (offset, visible_count, hidden_above, hidden_below) +} diff --git a/default-plugins/sequence/src/ui/mod.rs b/default-plugins/sequence/src/ui/mod.rs new file mode 100644 index 00000000..6d1ec303 --- /dev/null +++ b/default-plugins/sequence/src/ui/mod.rs @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..2694e17a --- /dev/null +++ b/default-plugins/sequence/src/ui/text_input.rs @@ -0,0 +1,997 @@ +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(), ""); + } +} diff --git a/default-plugins/sequence/src/ui/truncation.rs b/default-plugins/sequence/src/ui/truncation.rs new file mode 100644 index 00000000..0c4073a3 --- /dev/null +++ b/default-plugins/sequence/src/ui/truncation.rs @@ -0,0 +1,373 @@ +use unicode_width::UnicodeWidthStr; + +const BASE_SEPARATOR_WIDTH: usize = 2; + +pub fn calculate_available_cmd_width( + cols: usize, + folder_width: usize, + overflow_indicator: Option<&String>, + chain_width: usize, + status_width: usize, +) -> usize { + let mut separator_width = BASE_SEPARATOR_WIDTH; + if overflow_indicator.is_some() { + separator_width += 1; + } + if status_width > 0 { + separator_width += 1; + } + + let overflow_width = overflow_indicator.map(|s| s.chars().count()).unwrap_or(0); + + cols.saturating_sub(folder_width) + .saturating_sub(chain_width) + .saturating_sub(status_width) + .saturating_sub(separator_width) + .saturating_sub(overflow_width) + .saturating_sub(2) + .max(1) +} + +pub fn truncate_middle( + text: &str, + max_width: usize, + cursor_position: Option<usize>, +) -> (String, Option<usize>) { + let text_width = text.width(); + + if text_width <= max_width { + return (text.to_string(), cursor_position); + } + + if max_width < 5 { + return truncate_minimal(text, max_width, cursor_position); + } + + if let Some(cursor_char_idx) = cursor_position { + return truncate_with_cursor(text, max_width, cursor_char_idx); + } + + truncate_no_cursor(text, max_width) +} + +fn truncate_minimal( + text: &str, + max_width: usize, + cursor_position: Option<usize>, +) -> (String, Option<usize>) { + let mut result = String::new(); + let mut current_width = 0; + let mut new_cursor_pos = None; + let mut char_pos = 0; + + for ch in text.chars() { + let ch_width = ch.to_string().width(); + if current_width + ch_width <= max_width { + result.push(ch); + if let Some(cursor_char) = cursor_position { + if char_pos == cursor_char { + new_cursor_pos = Some(char_pos); + } + } + char_pos += 1; + current_width += ch_width; + } else { + break; + } + } + (result, new_cursor_pos) +} + +fn truncate_no_cursor(text: &str, max_width: usize) -> (String, Option<usize>) { + let available_for_text = max_width.saturating_sub(3); + let left_width = available_for_text / 2; + let right_width = available_for_text - left_width; + + let mut left_part = String::new(); + let mut current_width = 0; + for ch in text.chars() { + let ch_width = ch.to_string().width(); + if current_width + ch_width <= left_width { + left_part.push(ch); + current_width += ch_width; + } else { + break; + } + } + + let chars: Vec<char> = text.chars().collect(); + let mut right_part = String::new(); + let mut current_width = 0; + for ch in chars.iter().rev() { + let ch_width = ch.to_string().width(); + if current_width + ch_width <= right_width { + right_part.insert(0, *ch); + current_width += ch_width; + } else { + break; + } + } + + (format!("{}...{}", left_part, right_part), None) +} + +fn truncate_with_cursor( + text: &str, + max_width: usize, + cursor_char_idx: usize, +) -> (String, Option<usize>) { + let chars: Vec<char> = text.chars().collect(); + let char_widths: Vec<usize> = chars.iter().map(|ch| ch.to_string().width()).collect(); + + let width_before_cursor: usize = char_widths[..cursor_char_idx].iter().sum(); + let width_after_cursor: usize = char_widths[cursor_char_idx..].iter().sum(); + let available_one_ellipsis = max_width.saturating_sub(3); + + let (start_idx, end_idx) = if width_before_cursor <= available_one_ellipsis { + calculate_end_truncation(&chars, &char_widths, available_one_ellipsis) + } else if width_after_cursor <= available_one_ellipsis { + calculate_start_truncation(&chars, &char_widths, available_one_ellipsis) + } else { + calculate_middle_truncation(&char_widths, max_width, cursor_char_idx) + }; + + let (start_idx, end_idx) = + adjust_small_truncations(start_idx, end_idx, &char_widths, cursor_char_idx, &chars); + let (start_idx, end_idx) = + trim_excess(&char_widths, start_idx, end_idx, max_width, cursor_char_idx); + + build_truncated_result(&chars, start_idx, end_idx, cursor_char_idx) +} + +fn calculate_end_truncation( + chars: &[char], + char_widths: &[usize], + available: usize, +) -> (usize, usize) { + let mut end_idx = 0; + let mut width = 0; + while end_idx < chars.len() && width + char_widths[end_idx] <= available { + width += char_widths[end_idx]; + end_idx += 1; + } + (0, end_idx) +} + +fn calculate_start_truncation( + chars: &[char], + char_widths: &[usize], + available: usize, +) -> (usize, usize) { + let mut start_idx = chars.len(); + let mut width = 0; + while start_idx > 0 && width + char_widths[start_idx - 1] <= available { + start_idx -= 1; + width += char_widths[start_idx]; + } + (start_idx, chars.len()) +} + +fn calculate_middle_truncation( + char_widths: &[usize], + max_width: usize, + cursor_char_idx: usize, +) -> (usize, usize) { + let available_both_ellipsis = max_width.saturating_sub(6); + let target_before = available_both_ellipsis / 2; + let target_after = available_both_ellipsis - target_before; + + let mut start_idx = cursor_char_idx; + let mut width_before = 0; + while start_idx > 0 && width_before + char_widths[start_idx - 1] <= target_before { + start_idx -= 1; + width_before += char_widths[start_idx]; + } + + let mut end_idx = cursor_char_idx; + let mut width_after = 0; + while end_idx < char_widths.len() && width_after + char_widths[end_idx] <= target_after { + width_after += char_widths[end_idx]; + end_idx += 1; + } + + let leftover_before = target_before.saturating_sub(width_before); + let leftover_after = target_after.saturating_sub(width_after); + + if leftover_before > 0 { + let mut extra = leftover_before; + while end_idx < char_widths.len() && char_widths[end_idx] <= extra { + extra -= char_widths[end_idx]; + end_idx += 1; + } + } + if leftover_after > 0 { + let mut extra = leftover_after; + while start_idx > 0 && char_widths[start_idx - 1] <= extra { + start_idx -= 1; + extra -= char_widths[start_idx]; + } + } + + (start_idx, end_idx) +} + +fn adjust_small_truncations( + start_idx: usize, + end_idx: usize, + char_widths: &[usize], + cursor_char_idx: usize, + chars: &[char], +) -> (usize, usize) { + let width_truncated_start: usize = char_widths[..start_idx].iter().sum(); + let width_truncated_end: usize = char_widths[end_idx..].iter().sum(); + + let mut start_idx = start_idx; + let mut end_idx = end_idx; + + if width_truncated_start > 0 && width_truncated_start < 3 && end_idx < chars.len() { + let gained = width_truncated_start; + start_idx = 0; + let mut removed = 0; + while end_idx > cursor_char_idx + 1 && removed < gained { + end_idx -= 1; + removed += char_widths[end_idx]; + } + } else if width_truncated_end > 0 && width_truncated_end < 3 && start_idx > 0 { + let gained = width_truncated_end; + end_idx = chars.len(); + let mut removed = 0; + while start_idx < cursor_char_idx && removed < gained { + removed += char_widths[start_idx]; + start_idx += 1; + } + } + + (start_idx, end_idx) +} + +fn trim_excess( + char_widths: &[usize], + start_idx: usize, + end_idx: usize, + max_width: usize, + cursor_char_idx: usize, +) -> (usize, usize) { + let truncate_start = start_idx > 0; + let truncate_end = end_idx < char_widths.len(); + let ellipsis_width = match (truncate_start, truncate_end) { + (true, true) => 6, + (true, false) | (false, true) => 3, + (false, false) => 0, + }; + + let visible_width: usize = char_widths[start_idx..end_idx].iter().sum(); + let mut start_idx = start_idx; + let mut end_idx = end_idx; + + if visible_width + ellipsis_width > max_width { + let mut excess = visible_width + ellipsis_width - max_width; + if truncate_end { + while excess > 0 && end_idx > cursor_char_idx + 1 { + end_idx -= 1; + excess = excess.saturating_sub(char_widths[end_idx]); + } + } + if excess > 0 && truncate_start { + while excess > 0 && start_idx < cursor_char_idx { + excess = excess.saturating_sub(char_widths[start_idx]); + start_idx += 1; + } + } + } + + (start_idx, end_idx) +} + +fn build_truncated_result( + chars: &[char], + start_idx: usize, + end_idx: usize, + cursor_char_idx: usize, +) -> (String, Option<usize>) { + let mut result = String::new(); + let mut new_cursor_char_pos = 0; + let mut current_char_pos = 0; + + if start_idx > 0 { + result.push_str("..."); + current_char_pos = 3; + } + + for i in start_idx..end_idx { + if i == cursor_char_idx { + new_cursor_char_pos = current_char_pos; + } + result.push(chars[i]); + current_char_pos += 1; + } + + if cursor_char_idx >= end_idx { + new_cursor_char_pos = current_char_pos; + } + + if end_idx < chars.len() { + result.push_str("..."); + } + + (result, Some(new_cursor_char_pos)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_middle_no_truncation_needed() { + let text = "hello world"; + let (result, cursor_pos) = truncate_middle(text, 20, Some(6)); + assert_eq!(result, "hello world"); + assert_eq!(cursor_pos, Some(6)); + } + + #[test] + fn test_truncate_middle_no_cursor() { + let text = "this is a very long string that needs truncation"; + let (result, cursor_pos) = truncate_middle(text, 20, None); + assert!(result.contains("...")); + assert_eq!(cursor_pos, None); + } + + #[test] + fn test_truncate_middle_cursor_at_start() { + let text = "this is a very long string that needs truncation"; + let (result, cursor_pos) = truncate_middle(text, 20, Some(0)); + assert!(result.starts_with("this")); + assert!(result.ends_with("...")); + assert_eq!(cursor_pos, Some(0)); + } + + #[test] + fn test_truncate_middle_cursor_at_end() { + let text = "this is a very long string that needs truncation"; + let cursor_char = text.chars().count(); + let (result, _cursor_pos) = truncate_middle(text, 20, Some(cursor_char)); + assert!(result.starts_with("...")); + assert!(result.ends_with("truncation")); + } + + #[test] + fn test_truncate_middle_cursor_in_middle() { + let text = "this is a very long string that needs truncation"; + let cursor_char = "this is a very ".chars().count(); + let (result, cursor_pos) = truncate_middle(text, 20, Some(cursor_char)); + assert!(result.contains("very")); + assert!(cursor_pos.is_some()); + } + + #[test] + fn test_truncate_middle_wide_chars() { + let text = "γγγ«γ‘γ―δΈη"; + let (result, _) = truncate_middle(text, 10, None); + assert!(result.width() <= 10); + } +} |
