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/main.rs | |
| 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/main.rs')
| -rw-r--r-- | default-plugins/sequence/src/main.rs | 1131 |
1 files changed, 1131 insertions, 0 deletions
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); +} |
