aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/sequence/src/state
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-12-11 14:25:42 +0100
committerGitHub <noreply@github.com>2025-12-11 14:25:42 +0100
commit693fa082def5ef0fe030cd7fdc3b1df051b35dc6 (patch)
treeb3f651462bbc1cd2fa5b2d79207343413ef6b83c /default-plugins/sequence/src/state
parentfix(zellij-server): unblock building on 32bit targets (#4484) (diff)
downloadsl0ppij-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/state')
-rw-r--r--default-plugins/sequence/src/state/chain_type.rs45
-rw-r--r--default-plugins/sequence/src/state/command_entry.rs86
-rw-r--r--default-plugins/sequence/src/state/command_parser.rs327
-rw-r--r--default-plugins/sequence/src/state/command_status.rs26
-rw-r--r--default-plugins/sequence/src/state/editing.rs80
-rw-r--r--default-plugins/sequence/src/state/execution.rs265
-rw-r--r--default-plugins/sequence/src/state/layout.rs73
-rw-r--r--default-plugins/sequence/src/state/mod.rs446
-rw-r--r--default-plugins/sequence/src/state/positioning.rs48
-rw-r--r--default-plugins/sequence/src/state/selection.rs214
10 files changed, 1610 insertions, 0 deletions
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(&current_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()
+ }
+}