Revamp the sequence plugin (#4713)

* initial pipe interface

* initial open_x_pane_in_new_tab plugin api implementation

* fix: send CommandPaneExited for command panes opened as initial_terminals in new tabs from plugins

* open panes in place of specific pane id plugin commands - initial implementation

* sequence and plugin api ux

* focus self when starting

* combine ui and pipe paths

* highlight selected pane

* initial sequence removal of editing capabilities

* fix: translate plugin paths in api

* properly trim commands when loaded from pipe or pasted text

* minor ux fixes

* get ctrl c to work properly

* behavior fixes

* more ui fixes

* fix tests

* close tab and end sequence properly

* default to AND

* zellij sequence alias

* add alias

* consolidate paths

* rustfmt

* fix ui padding

* add pr

* fix: reset scrolling when resetting terminal state
This commit is contained in:
Aram Drevekenin
2026-02-17 15:43:28 +01:00
committed by GitHub
parent 7531106690
commit 4e66a0beff
32 changed files with 2182 additions and 3336 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* infra: migrate wasm runtime from wasmtime to wasmi (https://github.com/zellij-org/zellij/pull/4449)
* feat: allow attaching to sessions as "read-only" (https://github.com/zellij-org/zellij/pull/4487)
* feat: improve cli interface (add detach, switch-session and more) (https://github.com/zellij-org/zellij/pull/4492)
* feat: command sequences, conditionally blocking CLI commands, new plugin APIs (https://github.com/zellij-org/zellij/pull/4546)
* feat: command sequences, conditionally blocking CLI commands, new plugin APIs (https://github.com/zellij-org/zellij/pull/4546 and https://github.com/zellij-org/zellij/pull/4713)
* feat: read-only web authentication-tokens (https://github.com/zellij-org/zellij/pull/4551)
* feat: allow overriding layouts at runtime (https://github.com/zellij-org/zellij/pull/4566)
* build: Update Rust toolchain to 1.92.0 (https://github.com/zellij-org/zellij/pull/4579)
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
use super::{ChainType, CommandStatus};
use std::path::PathBuf;
use std::time::Instant;
use zellij_tile::prelude::PaneId;
#[derive(Debug, Clone)]
@@ -9,7 +8,6 @@ pub struct CommandEntry {
cwd: Option<PathBuf>,
pub(super) chain_type: ChainType,
pub(super) status: CommandStatus,
pub(super) start_time: std::time::Instant,
}
impl Default for CommandEntry {
@@ -19,7 +17,6 @@ impl Default for CommandEntry {
cwd: None,
chain_type: ChainType::default(),
status: CommandStatus::default(),
start_time: Instant::now(),
}
}
}
@@ -32,10 +29,6 @@ impl CommandEntry {
..Default::default()
}
}
pub fn with_and(mut self) -> Self {
self.chain_type = ChainType::And;
self
}
pub fn get_text(&self) -> String {
self.text.clone()
}
@@ -74,9 +67,6 @@ impl CommandEntry {
pub fn clear_status(&mut self) {
self.status = CommandStatus::Pending;
}
pub fn cycle_chain_type(&mut self) {
self.chain_type.cycle_next();
}
pub fn get_cwd(&self) -> Option<PathBuf> {
self.cwd.clone()
}
@@ -1,4 +1,4 @@
use super::ChainType;
use super::{ChainType, CommandEntry};
#[derive(Debug, Clone, Copy, PartialEq)]
enum ParseState {
@@ -104,50 +104,6 @@ pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)>
segments
}
pub fn detect_chain_operator_at_end(text: &str) -> Option<(String, ChainType)> {
let segments = split_by_chain_operators(text);
if segments.is_empty() {
return None;
}
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();
@@ -163,6 +119,92 @@ pub fn detect_cd_command(text: &str) -> Option<String> {
None
}
/// Serialize a sequence of commands to editor format.
/// CWD changes are emitted as `cd <path>` lines; chain type as trailing ` &&`, ` ||`, ` ;`.
pub fn serialize_sequence_to_editor(commands: &[CommandEntry]) -> String {
let mut result = String::new();
result.push_str("# One cmd per line. Use && || ; to chain. cd <path> sets cwd.\n");
result.push_str("\n");
let mut prev_cwd: Option<std::path::PathBuf> = None;
for cmd in commands {
if cmd.get_text().trim().is_empty() {
continue;
}
let cmd_cwd = cmd.get_cwd();
if cmd_cwd != prev_cwd && cmd_cwd.is_some() {
let path = cmd_cwd.as_ref().unwrap();
result.push_str(&format!("cd {}\n", path.display()));
}
prev_cwd = cmd_cwd;
let op_suffix = match cmd.get_chain_type() {
ChainType::And => " &&",
ChainType::Or => " ||",
ChainType::Then => " ;",
ChainType::None => "",
};
result.push_str(&format!("{}{}\n", cmd.get_text(), op_suffix));
}
result
}
/// Parse command text into a Vec<CommandEntry>.
/// `cd <path>` lines set the cwd for subsequent commands.
/// Blank lines and `#` comment lines are ignored.
pub fn parse_commands(
contents: &str,
initial_cwd: Option<std::path::PathBuf>,
) -> Vec<CommandEntry> {
use crate::path_formatting;
let mut commands = Vec::new();
let mut current_cwd = initial_cwd;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Split by chain operators first so that "cd /path &&" doesn't absorb the operator
// into the path. Each resulting segment is then checked for cd independently.
let segments = split_by_chain_operators(trimmed);
for (text, chain_type_opt) in segments {
let text = text.trim().to_string();
if text.is_empty() {
continue;
}
if let Some(path) = detect_cd_command(&text) {
if let Some(new_cwd) = path_formatting::resolve_path(current_cwd.as_ref(), &path) {
current_cwd = Some(new_cwd);
}
// cd segments are not added as command entries; their chain type is discarded
} else {
let mut entry = CommandEntry::new(&text, current_cwd.clone());
if let Some(chain_type) = chain_type_opt {
entry.set_chain_type(chain_type);
}
commands.push(entry);
}
}
}
// Commands with no explicit operator default to &&
if !commands.is_empty() {
let last_idx = commands.len() - 1;
for cmd in &mut commands[..last_idx] {
cmd.fill_chain_type_if_empty();
}
}
commands
}
#[cfg(test)]
mod tests {
use super::*;
@@ -277,36 +319,6 @@ mod tests {
);
}
#[test]
fn test_detect_operator_just_typed_and() {
let result = detect_chain_operator_at_end("ls &&");
assert_eq!(result, Some(("ls".to_string(), ChainType::And)));
}
#[test]
fn test_detect_operator_just_typed_or() {
let result = detect_chain_operator_at_end("cmd ||");
assert_eq!(result, Some(("cmd".to_string(), ChainType::Or)));
}
#[test]
fn test_detect_operator_just_typed_semicolon() {
let result = detect_chain_operator_at_end("echo ;");
assert_eq!(result, Some(("echo".to_string(), ChainType::Then)));
}
#[test]
fn test_detect_operator_no_operator() {
let result = detect_chain_operator_at_end("ls");
assert_eq!(result, None);
}
#[test]
fn test_detect_operator_in_quotes() {
let result = detect_chain_operator_at_end("echo '&&'");
assert_eq!(result, None);
}
#[test]
fn test_whitespace_trimming() {
let result = split_by_chain_operators(" cmd1 && cmd2 ");
@@ -324,4 +336,35 @@ mod tests {
let result = split_by_chain_operators("ls && ");
assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]);
}
#[test]
fn test_editor_file_no_operator_defaults_to_and() {
let contents = "cmd1\ncmd2\ncmd3";
let result = parse_commands(contents, None);
assert_eq!(result.len(), 3);
assert_eq!(result[0].get_chain_type(), ChainType::And);
assert_eq!(result[1].get_chain_type(), ChainType::And);
assert_eq!(result[2].get_chain_type(), ChainType::None);
}
#[test]
fn test_editor_file_explicit_operator_preserved() {
let contents = "cmd1 ||\ncmd2";
let result = parse_commands(contents, None);
assert_eq!(result.len(), 2);
assert_eq!(result[0].get_chain_type(), ChainType::Or);
assert_eq!(result[1].get_chain_type(), ChainType::None);
}
#[test]
fn test_cd_with_chain_operator_does_not_absorb_operator_into_path() {
use super::super::CommandEntry;
use std::path::PathBuf;
let contents = "cd /some/path && cargo build";
let result = parse_commands(contents, None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].get_text(), "cargo build");
assert_eq!(result[0].get_cwd(), Some(PathBuf::from("/some/path")));
}
}
@@ -1,80 +0,0 @@
use crate::path_formatting;
use crate::state::CommandEntry;
use crate::ui::text_input::TextInput;
use std::path::PathBuf;
pub struct Editing {
pub editing_input: Option<TextInput>,
}
impl Editing {
pub fn new() -> Self {
Self {
editing_input: Some(TextInput::new("".to_owned())),
}
}
pub fn start_editing(&mut self, text: String) {
self.editing_input = Some(TextInput::new(text));
}
pub fn cancel_editing(&mut self) {
self.editing_input = None;
}
pub fn input_text(&self) -> Option<String> {
self.editing_input.as_ref().map(|e| e.get_text().to_owned())
}
pub fn set_input_text(&mut self, text: String) {
self.editing_input.as_mut().map(|e| e.set_text(text));
}
pub fn handle_submit(
&mut self,
selection_index: Option<usize>,
all_commands: &mut Vec<CommandEntry>,
current_cwd: &Option<PathBuf>,
) -> (bool, Option<usize>) {
let mut handled_internally = false;
let mut new_selection_index = None;
if let Some(current_text) = self.editing_input.as_ref().map(|i| i.get_text()) {
if let Some(index) = selection_index {
if let Some(command) = all_commands.get_mut(index) {
if current_text.starts_with("cd ") || current_text == "cd" {
let path = if current_text == "cd" {
"~"
} else {
current_text[3..].trim()
};
if let Some(new_cwd) =
path_formatting::resolve_path(current_cwd.as_ref(), path)
{
command.set_cwd(Some(new_cwd));
}
command.set_text("".to_owned());
self.editing_input.as_mut().map(|c| c.clear());
new_selection_index = selection_index; // remain editing after a cd command
handled_internally = true;
return (handled_internally, new_selection_index); // avoid setting edit_input to None below, we
// still want to be in editing mode after
// changing directory with cd
} else {
command.set_text(current_text.to_owned());
}
}
}
}
self.editing_input = None;
(handled_internally, new_selection_index)
}
}
impl Default for Editing {
fn default() -> Self {
Self::new()
}
}
+3 -182
View File
@@ -1,16 +1,13 @@
use crate::path_formatting::format_cwd;
use crate::state::{ChainType, CommandEntry, CommandStatus};
use std::collections::BTreeMap;
use crate::state::{CommandEntry, CommandStatus};
use std::path::PathBuf;
use std::time::Duration;
use zellij_tile::prelude::*;
pub struct Execution {
pub all_commands: Vec<CommandEntry>,
pub current_running_command_index: usize,
pub is_running: bool,
pub sequence_id: u64,
pub primary_pane_id_before_sequence: Option<PaneId>,
pub displayed_pane_id: Option<PaneId>,
}
impl Execution {
@@ -19,8 +16,7 @@ impl Execution {
all_commands: vec![CommandEntry::default()],
current_running_command_index: 0,
is_running: false,
sequence_id: 0,
primary_pane_id_before_sequence: None,
displayed_pane_id: None,
}
}
@@ -82,181 +78,6 @@ impl Execution {
copy_to_clipboard(text_to_copy);
}
}
pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
for command in self.all_commands.iter_mut() {
if let CommandStatus::Pending | CommandStatus::Running(None) = command.get_status() {
let cmd_text = command.get_text();
if cmd_text == command_text {
command.set_status(CommandStatus::Running(Some(pane_id)));
break;
}
}
}
}
pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
let mut updated = false;
for command in self.all_commands.iter_mut() {
let status = command.get_status();
let pane_id_opt = match &status {
CommandStatus::Running(pid) => *pid,
CommandStatus::Exited(_, pid) => *pid,
CommandStatus::Interrupted(pid) => *pid,
_ => None,
};
if let Some(pane_id) = pane_id_opt {
let mut pane_found = false;
for (_tab_index, panes) in &pane_manifest.panes {
for pane_info in panes {
let pane_matches = match pane_id {
PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
};
if pane_matches {
pane_found = true;
if pane_info.exited {
command.set_status(CommandStatus::Exited(
pane_info.exit_status,
Some(pane_id),
));
updated = true;
}
break;
}
}
if pane_found {
break;
}
}
if !pane_found && command.start_time.elapsed() > Duration::from_millis(400) {
match command.get_status() {
CommandStatus::Running(_) => {
eprintln!(
"Pane {:?} was closed while running, setting pane_id to None",
pane_id
);
command.set_status(CommandStatus::Running(None));
updated = true;
},
CommandStatus::Exited(exit_code, _) => {
eprintln!(
"Pane {:?} was closed after exiting, setting pane_id to None",
pane_id
);
command.set_status(CommandStatus::Exited(exit_code, None));
updated = true;
},
CommandStatus::Interrupted(_) => {
eprintln!("Pane {:?} was closed after being interrupted, setting pane_id to None", pane_id);
command.set_status(CommandStatus::Interrupted(None));
updated = true;
},
_ => {},
}
}
}
}
updated
}
pub fn update_sequence_stopped_state(&mut self) -> bool {
let mut needs_rerender = false;
if self.is_running {
let current_idx = self.current_running_command_index;
if let Some(command) = self.all_commands.get_mut(current_idx) {
if let CommandStatus::Exited(exit_code, _) = command.get_status() {
let should_stop;
if current_idx >= self.all_commands.len().saturating_sub(1) {
should_stop = true;
} else {
if let Some(chain_type) = &self
.all_commands
.get(current_idx)
.map(|c| c.get_chain_type())
{
match chain_type {
ChainType::And => {
should_stop = exit_code.unwrap_or(0) != 0;
},
ChainType::Or => {
should_stop = exit_code.unwrap_or(0) == 0;
},
ChainType::Then => {
should_stop = false;
},
ChainType::None => {
should_stop = true;
},
}
} else {
should_stop = true;
}
};
if should_stop {
self.is_running = false;
}
}
}
needs_rerender = true;
}
needs_rerender
}
pub fn execute_command_sequence(
&mut self,
shell: &Option<PathBuf>,
global_cwd: &Option<PathBuf>,
primary_pane_id: Option<PaneId>,
) {
use zellij_tile::prelude::actions::{Action, RunCommandAction};
self.all_commands.retain(|c| !c.is_empty());
let Some(first_active_sequence_command) = self.get_first_command() else {
return;
};
let shell = shell.clone().unwrap_or_else(|| PathBuf::from("/bin/bash"));
let first_command = first_active_sequence_command.get_text();
let first_chain_type = first_active_sequence_command.get_chain_type();
let command_cwd = first_active_sequence_command
.get_cwd()
.or_else(|| global_cwd.clone());
let command = RunCommandAction {
command: shell.clone(),
args: vec!["-ic".to_string(), first_command.trim().to_string()],
cwd: command_cwd,
hold_on_close: true,
..Default::default()
};
let placement = NewPanePlacement::InPlace {
pane_id_to_replace: primary_pane_id,
close_replaced_pane: false,
borderless: Some(false),
};
let action = Action::NewBlockingPane {
placement,
command: Some(command),
pane_name: Some(first_command.trim().to_string()),
unblock_condition: first_chain_type.to_unblock_condition(),
near_current_pane: true,
};
self.sequence_id += 1;
let mut context = BTreeMap::new();
context.insert("sequence_id".to_string(), self.sequence_id.to_string());
run_action(action, context);
self.set_command_status(0, CommandStatus::Running(None));
}
}
impl Default for Execution {
@@ -1,10 +1,4 @@
use crate::state::positioning;
use std::path::PathBuf;
use zellij_tile::prelude::*;
pub struct Layout {
pub needs_reposition: bool,
pub cached_cursor_position: Option<(usize, usize)>,
pub spinner_frame: usize,
pub spinner_timer_scheduled: bool,
}
@@ -12,58 +6,10 @@ pub struct Layout {
impl Layout {
pub fn new() -> Self {
Self {
needs_reposition: false,
cached_cursor_position: None,
spinner_frame: 0,
spinner_timer_scheduled: false,
}
}
pub fn reposition_plugin_based_on_sequence_state(
&mut self,
is_running: bool,
first_command_pane_id: Option<PaneId>,
pane_manifest: &PaneManifest,
plugin_id: Option<u32>,
total_viewport_columns: Option<usize>,
total_viewport_rows: Option<usize>,
total_commands: usize,
_selected_index: Option<usize>,
_running_index: usize,
_cwd: &Option<PathBuf>,
) {
let Some(plugin_id) = plugin_id else {
return;
};
let (Some(total_cols), Some(total_rows)) = (total_viewport_columns, total_viewport_rows)
else {
return;
};
if !self.needs_reposition || !is_running {
return;
}
let Some(first_pane_id) = first_command_pane_id else {
return;
};
for (_tab_index, panes) in &pane_manifest.panes {
for pane in panes {
if !pane.is_plugin && PaneId::Terminal(pane.id) == first_pane_id {
positioning::reposition_plugin_for_sequence(
plugin_id,
total_cols,
total_rows,
total_commands,
);
self.needs_reposition = false;
return;
}
}
}
}
}
impl Default for Layout {
+123 -340
View File
@@ -2,28 +2,25 @@ mod chain_type;
mod command_entry;
mod command_parser;
mod command_status;
mod editing;
mod execution;
mod layout;
pub mod positioning;
mod selection;
mod sequence_mode;
pub use chain_type::ChainType;
pub use command_entry::CommandEntry;
pub use command_parser::{
detect_cd_command, detect_chain_operator_at_end, get_remaining_after_first_segment,
split_by_chain_operators,
};
pub use command_parser::{parse_commands, serialize_sequence_to_editor};
pub use command_status::CommandStatus;
pub use editing::Editing;
pub use execution::Execution;
pub use layout::Layout;
pub use selection::Selection;
pub use sequence_mode::SequenceMode;
use crate::ui::text_input::TextInput;
use std::collections::BTreeMap;
use std::path::PathBuf;
use zellij_tile::prelude::*;
#[derive(Default)]
pub struct State {
pub shell: Option<PathBuf>,
pub plugin_id: Option<u32>,
@@ -31,291 +28,85 @@ pub struct State {
pub own_rows: Option<usize>,
pub own_columns: Option<usize>,
pub cwd: Option<PathBuf>,
pub original_pane_id: Option<PaneId>,
pub primary_pane_id: Option<PaneId>,
pub is_first_run: bool,
pub pane_manifest: Option<PaneManifest>,
pub total_viewport_columns: Option<usize>,
pub total_viewport_rows: Option<usize>,
pub selection: Selection,
pub editing: Editing,
pub execution: Execution,
pub layout: Layout,
pub current_position: Option<FloatingPaneCoordinates>,
pub mode: SequenceMode,
pub editor_pane_id: Option<PaneId>,
pub editor_temp_file: Option<PathBuf>,
pub sequence_tab_id: Option<usize>,
pub blocking_pipe_id: Option<String>,
}
impl State {
pub fn new() -> Self {
Self {
shell: None,
plugin_id: None,
client_id: None,
own_rows: None,
own_columns: None,
cwd: None,
original_pane_id: None,
primary_pane_id: None,
is_first_run: true,
pane_manifest: None,
total_viewport_columns: None,
total_viewport_rows: None,
selection: Selection::new(),
editing: Editing::new(),
execution: Execution::new(),
layout: Layout::new(),
current_position: None,
}
}
pub fn set_plugin_id(&mut self, plugin_id: u32) {
self.plugin_id = Some(plugin_id);
}
/// Check if the sequence has finished executing (all commands are done)
pub fn has_finished(&self) -> bool {
self.execution
.all_commands
.iter()
.all(|command| matches!(command.status, CommandStatus::Exited(_, _)))
}
pub fn add_empty_command_after_current_selected(&mut self) {
self.selection.add_empty_command_after_current_selected(
&mut self.execution.all_commands,
&mut self.editing,
&self.cwd,
);
}
pub fn current_selected_command_is_empty(&self) -> bool {
self.selection
.current_selected_command_is_empty(&self.execution.all_commands, &self.editing)
}
pub fn remove_current_selected_command(&mut self) {
let removed_pane_id = self
.selection
.remove_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
if let Some(removed_pane_id) = removed_pane_id {
close_pane_with_id(removed_pane_id);
}
}
pub fn clear_current_selected_command(&mut self) {
self.selection
.clear_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
}
pub fn move_selection_up(&mut self) {
self.selection
.move_up(&mut self.execution.all_commands, &mut self.editing);
if let Some(pane_id) = self
.current_selected_command()
.and_then(|c| c.get_pane_id())
{
show_pane_with_id(pane_id, true, false);
}
self.selection.move_up(&self.execution.all_commands);
self.show_selected_pane();
}
pub fn move_selection_down(&mut self) {
self.selection
.move_down(&mut self.execution.all_commands, &mut self.editing);
if let Some(pane_id) = self
.current_selected_command()
.and_then(|c| c.get_pane_id())
{
show_pane_with_id(pane_id, true, false);
}
}
pub fn start_editing_selected(&mut self) {
if let Some(current_command_text) = self.current_selected_command().map(|c| c.get_text()) {
self.editing.start_editing(current_command_text);
}
self.selection.move_down(&self.execution.all_commands);
self.show_selected_pane();
}
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
pub fn show_selected_pane(&mut self) {
if self.mode == SequenceMode::Spread {
let all_pane_ids: Vec<PaneId> = 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 {
.iter()
.filter_map(|c| c.get_pane_id())
.collect();
let selected_pane_id = self
.current_selected_command()
.and_then(|c| c.get_pane_id());
if let Some(selected) = selected_pane_id {
let to_unhighlight: Vec<PaneId> = all_pane_ids
.into_iter()
.filter(|&p| p != selected)
.collect();
highlight_and_unhighlight_panes(vec![selected], to_unhighlight);
}
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();
}
let target_pane_id = self
.current_selected_command()
.and_then(|c| c.get_pane_id());
if let (Some(displayed), Some(target)) = (self.execution.displayed_pane_id, target_pane_id)
{
if displayed != target {
replace_pane_with_existing_pane(displayed, target, true);
self.execution.displayed_pane_id = Some(target);
}
}
self.start_editing_selected();
}
pub fn remove_empty_commands(&mut self) {
self.execution.remove_empty_commands();
}
pub fn get_first_command(&self) -> Option<CommandEntry> {
self.execution.get_first_command()
}
pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
self.execution.set_command_status(command_index, status);
}
pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
self.execution.set_current_running_command_status(status);
}
pub fn get_current_running_command_status(&mut self) -> Option<CommandStatus> {
self.execution.get_current_running_command_status()
}
pub fn handle_editing_submit(&mut self, current_cwd: &Option<PathBuf>) -> bool {
let (handled_internally, new_selection_index) = self.editing.handle_submit(
self.selection.current_selected_command_index,
&mut self.execution.all_commands,
current_cwd,
);
self.selection.current_selected_command_index = new_selection_index;
handled_internally
}
pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
self.execution
.update_pane_id_for_command(pane_id, command_text);
}
pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
self.execution.update_exited_command_statuses(pane_manifest)
}
pub fn update_sequence_stopped_state(&mut self) -> bool {
self.execution.update_sequence_stopped_state()
}
pub fn cycle_chain_type(&mut self) {
self.current_selected_command_mut()
.map(|c| c.cycle_chain_type());
}
pub fn set_primary_pane_id_before_sequence(&mut self, pane_id: Option<PaneId>) {
if pane_id.is_some() {
self.execution.primary_pane_id_before_sequence = pane_id;
}
}
pub fn clear_all_commands(&mut self) {
self.execution.all_commands = vec![CommandEntry::new("", self.cwd.clone())];
self.editing.editing_input = Some(TextInput::new("".to_owned()));
self.selection.current_selected_command_index = Some(0);
self.selection.current_selected_command_index = None;
self.execution.current_running_command_index = 0;
}
pub fn update_running_state(&mut self, primary_pane_id: Option<PaneId>) {
self.remove_empty_commands();
self.set_primary_pane_id_before_sequence(primary_pane_id);
self.layout.needs_reposition = true;
self.execution.is_running = true;
pub fn load_commands(&mut self, command_string: &str, cwd_override: Option<PathBuf>) {
let effective_cwd = cwd_override.or_else(|| self.cwd.clone());
let commands = parse_commands(command_string, effective_cwd);
if commands.is_empty() {
return;
}
self.execution.all_commands = commands;
self.execution.current_running_command_index = 0;
self.selection.current_selected_command_index = None;
}
pub fn can_run_sequence(&self) -> bool {
@@ -332,6 +123,31 @@ impl State {
};
self.execution.all_commands.get_mut(i)
}
/// Serialize current sequence to a temp file and open it in the user's $EDITOR.
/// Stores the editor pane id and temp file path for later retrieval.
pub fn open_editor(&mut self) {
if self.execution.is_running {
return;
}
let Some(plugin_id) = self.plugin_id else {
return;
};
let serialized = serialize_sequence_to_editor(&self.execution.all_commands);
let temp_path = PathBuf::from(format!("/tmp/zellij-sequence-{}.sh", plugin_id));
if let Err(e) = std::fs::write(&temp_path, &serialized) {
eprintln!("Failed to write sequence to temp file: {}", e);
return;
}
let file_to_open = FileToOpen::new(temp_path.clone());
let editor_pane_id = open_file_in_place_of_plugin(file_to_open, false, BTreeMap::new());
self.editor_pane_id = editor_pane_id;
self.editor_temp_file = Some(temp_path);
}
pub fn reposition_plugin(&mut self) -> bool {
let mut repositioned = false;
let Some(plugin_id) = self.plugin_id else {
@@ -343,82 +159,62 @@ impl State {
let Some(total_viewport_columns) = self.total_viewport_columns else {
return repositioned;
};
if self.all_commands_are_pending() {
return false;
}
let total_commands = std::cmp::max(self.execution.all_commands.iter().len(), 1);
let height_padding = 7;
if self.all_commands_are_pending() {
let initial_height = total_commands + height_padding;
let y = total_viewport_rows.saturating_sub(initial_height) / 2;
let coordinates = FloatingPaneCoordinates::new(
Some(format!("25%")),
Some(format!("{}", y)),
Some(format!("50%")),
Some(format!("{}", initial_height)),
Some(false), // should not be pinned when running sequence
Some(false),
);
coordinates.map(|coordinates| {
if Some(&coordinates) != self.current_position.as_ref() {
repositioned = true;
self.current_position = Some(coordinates.clone());
change_floating_panes_coordinates(vec![(
PaneId::Plugin(plugin_id),
coordinates,
)]);
}
});
} else {
// Calculate longest table row width
let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
let longest_command = self
.execution
.all_commands
.iter()
.map(|cmd| cmd.get_text().chars().count())
.max()
.unwrap_or(1);
let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
&self.execution.all_commands,
self.layout.spinner_frame,
);
// Calculate longest table row width
let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
let longest_command = self
.execution
.all_commands
.iter()
.map(|cmd| cmd.get_text().chars().count())
.max()
.unwrap_or(1);
let longest_line = crate::ui::components::calculate_longest_line(
&longest_cwd_display,
longest_command,
max_chain_width,
max_status_width,
);
let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
&self.execution.all_commands,
self.layout.spinner_frame,
);
let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
let width = std::cmp::min(ui_width, total_viewport_columns / 2);
let longest_line = crate::ui::components::calculate_longest_line(
&longest_cwd_display,
longest_command,
max_chain_width,
max_status_width,
);
let height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100);
let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
let width = std::cmp::min(ui_width, total_viewport_columns.saturating_sub(2));
// Position: top-right with 1-space margins
let x = total_viewport_columns.saturating_sub(width);
let y = 1;
let height = (height_padding + total_commands).min((total_viewport_rows * 50) / 100);
let coordinates = FloatingPaneCoordinates::new(
Some(x.to_string()),
Some(y.to_string()),
Some(width.to_string()),
Some(height.to_string()),
Some(true), // should be pinned for sequence
Some(false),
);
coordinates.map(|coordinates| {
if Some(&coordinates) != self.current_position.as_ref() {
self.current_position = Some(coordinates.clone());
repositioned = true;
change_floating_panes_coordinates(vec![(
PaneId::Plugin(plugin_id),
coordinates,
)]);
}
});
}
// Position: top-right with 1-space margins
let x = total_viewport_columns.saturating_sub(width);
let y = 1;
let coordinates = FloatingPaneCoordinates::new(
Some(x.to_string()),
Some(y.to_string()),
Some(width.to_string()),
Some(height.to_string()),
Some(true), // should be pinned for sequence
Some(false),
);
coordinates.map(|coordinates| {
if Some(&coordinates) != self.current_position.as_ref() {
self.current_position = Some(coordinates.clone());
repositioned = true;
change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coordinates)]);
}
});
repositioned
}
pub fn all_commands_are_pending(&self) -> bool {
self.execution
.all_commands
@@ -432,17 +228,4 @@ impl State {
};
self.execution.all_commands.get(i)
}
pub fn execute_command_sequence(&mut self) {
self.execution
.execute_command_sequence(&self.shell, &self.cwd, self.primary_pane_id);
}
}
impl Default for State {
fn default() -> Self {
let mut state = Self::new();
state.is_first_run = true;
state
}
}
@@ -1,49 +0,0 @@
use zellij_tile::prelude::*;
pub fn reposition_plugin_for_sequence(
plugin_id: u32,
total_cols: usize,
total_rows: usize,
total_commands: usize,
) {
// Width: 30% of viewport
let width = std::cmp::max((total_cols * 30) / 100, 50);
// Height: UI overhead + commands, capped at 25% of viewport
let overhead_rows = 6;
let height = (overhead_rows + total_commands).min((total_rows * 25) / 100);
// Position: top-right with 1-space margins
let x = total_cols.saturating_sub(width);
let y = 1;
change_floating_pane_coordinates_absolute(
plugin_id,
Some(x),
Some(y),
Some(width),
Some(height),
true,
);
}
pub fn change_floating_pane_coordinates_absolute(
own_plugin_id: u32,
x: Option<usize>,
y: Option<usize>,
width: Option<usize>,
height: Option<usize>,
should_be_pinned: bool,
) {
let coordinates = FloatingPaneCoordinates::new(
x.map(|x| x.to_string()),
y.map(|y| y.to_string()),
width.map(|width| width.to_string()),
height.map(|height| height.to_string()),
Some(should_be_pinned),
Some(false),
);
if let Some(coordinates) = coordinates {
change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
}
}
+4 -171
View File
@@ -1,115 +1,17 @@
use crate::state::{CommandEntry, Editing};
use std::path::PathBuf;
use zellij_tile::prelude::*;
use super::CommandEntry;
pub struct Selection {
pub current_selected_command_index: Option<usize>,
pub scroll_offset: usize,
}
impl Selection {
pub fn new() -> Self {
Self {
current_selected_command_index: Some(0),
scroll_offset: 0,
current_selected_command_index: None,
}
}
pub fn add_empty_command_after_current_selected(
&mut self,
all_commands: &mut Vec<CommandEntry>,
editing: &mut Editing,
cwd: &Option<PathBuf>,
) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
editing.editing_input.as_mut().map(|i| i.clear());
if !self.current_selected_command_is_empty(all_commands, editing) {
self.current_selected_command_mut(all_commands)
.map(|c| c.fill_chain_type_if_empty());
if let Some(current_selected_command_index) = self.current_selected_command_index.take()
{
let new_command = CommandEntry::new("", cwd.clone());
if current_selected_command_index == all_commands.len().saturating_sub(1) {
all_commands.push(new_command);
} else {
all_commands.insert(current_selected_command_index + 1, new_command);
}
self.current_selected_command_index = Some(current_selected_command_index + 1);
}
}
}
pub fn current_selected_command_is_empty(
&self,
all_commands: &[CommandEntry],
editing: &Editing,
) -> bool {
let text_input_is_empty = editing
.editing_input
.as_ref()
.map(|i| i.is_empty())
.unwrap_or(true);
let current_selected_command_is_empty = self
.current_selected_command_index
.and_then(|i| all_commands.get(i).map(|c| c.is_empty()))
.unwrap_or(true);
text_input_is_empty && current_selected_command_is_empty
}
pub fn remove_current_selected_command(
&mut self,
all_commands: &mut Vec<CommandEntry>,
editing: &mut Editing,
) -> Option<PaneId> {
// returns the pane_id of the removed command, if any
let mut removed_pane_id = None;
let Some(mut current_selected_command_index) = self.current_selected_command_index else {
return removed_pane_id;
};
if all_commands.len() > 1 && all_commands.len() > current_selected_command_index {
let command = all_commands.remove(current_selected_command_index);
removed_pane_id = command.get_pane_id();
} else {
self.clear_current_selected_command(all_commands, editing);
}
if current_selected_command_index >= all_commands.len()
&& current_selected_command_index > 0
{
current_selected_command_index -= 1;
}
self.update_current_selected_command_index(
current_selected_command_index,
all_commands,
editing,
);
if current_selected_command_index == all_commands.len().saturating_sub(1) {
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_chain_type());
}
removed_pane_id
}
pub fn clear_current_selected_command(
&mut self,
all_commands: &mut [CommandEntry],
editing: &mut Editing,
) {
let Some(current_selected_command_index) = self.current_selected_command_index else {
return;
};
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_text());
self.clear_editing_buffer(editing);
if current_selected_command_index == all_commands.len().saturating_sub(1) {
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_chain_type());
}
}
pub fn move_up(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
pub fn move_up(&mut self, all_commands: &[CommandEntry]) {
match self.current_selected_command_index.as_mut() {
Some(i) if *i > 0 => {
*i -= 1;
@@ -121,11 +23,9 @@ impl Selection {
self.current_selected_command_index = None;
},
}
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
pub fn move_down(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
pub fn move_down(&mut self, all_commands: &[CommandEntry]) {
match self.current_selected_command_index.as_mut() {
Some(i) if *i < all_commands.len().saturating_sub(1) => {
*i += 1;
@@ -137,73 +37,6 @@ impl Selection {
self.current_selected_command_index = None;
},
}
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
fn current_selected_command_mut<'a>(
&self,
all_commands: &'a mut [CommandEntry],
) -> Option<&'a mut CommandEntry> {
let Some(i) = self.current_selected_command_index else {
return None;
};
all_commands.get_mut(i)
}
fn current_selected_command<'a>(
&self,
all_commands: &'a [CommandEntry],
) -> Option<&'a CommandEntry> {
let Some(i) = self.current_selected_command_index else {
return None;
};
all_commands.get(i)
}
fn clear_editing_buffer(&self, editing: &mut Editing) {
editing.editing_input.as_mut().map(|c| c.clear());
}
fn update_current_selected_command_index(
&mut self,
new_index: usize,
all_commands: &[CommandEntry],
editing: &mut Editing,
) {
self.current_selected_command_index = Some(new_index);
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
fn get_text_of_current_selected_command(
&self,
all_commands: &[CommandEntry],
) -> Option<String> {
self.current_selected_command(all_commands)
.map(|c| c.get_text())
}
fn save_editing_buffer_to_current_selected(
&self,
all_commands: &mut [CommandEntry],
editing: &mut Editing,
) {
if let Some(text_input) = editing.editing_input.as_ref().map(|i| i.get_text()) {
self.current_selected_command_mut(all_commands)
.map(|c| c.set_text(text_input.to_owned()));
}
}
fn update_editing_buffer_to_current_selected(
&self,
all_commands: &[CommandEntry],
editing: &mut Editing,
) {
let new_text_of_current_selected_command = self
.get_text_of_current_selected_command(all_commands)
.unwrap_or_else(|| String::new());
if let Some(editing_input) = editing.editing_input.as_mut() {
editing_input.set_text(new_text_of_current_selected_command);
}
}
}
@@ -0,0 +1,6 @@
#[derive(Default, Debug, Clone, PartialEq)]
pub enum SequenceMode {
#[default]
SinglePane,
Spread,
}
+43 -72
View File
@@ -6,6 +6,11 @@ use zellij_tile::prelude::*;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
const INTRO_PART1: &str = "Paste a series of commands to run in sequence";
const INTRO_PART2: &str = "or <e> to edit in your editor";
const INTRO_FULL: &str =
"Paste a series of commands to run in sequence or <e> to edit in your editor";
const HELP_RUNNING_WITH_SELECTION: &str = "<Ctrl c> - interrupt, <Enter> - run from selected";
const HELP_RUNNING_NO_SELECTION: &str = "<Ctrl c> - interrupt, <↓↑> - navigate";
const HELP_STOPPED_WITH_SELECTION: &str =
@@ -13,15 +18,7 @@ const HELP_STOPPED_WITH_SELECTION: &str =
const HELP_STOPPED_NO_SELECTION: &str =
"<Ctrl w> - close all, <↓↑> - navigate, <Enter> - run from first";
const HELP_ONE_PENDING_COMMAND: &str = "<Enter> - run, <Ctrl Enter> - add command";
const HELP_ALL_COMMANDS_PENDING: &str =
"<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate";
const HELP_ALL_COMMANDS_PENDING_WITH_SELECTION: &str =
"<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate, <e> - edit selected";
const HELP_EDITING_FIRST_LINE: &str =
"<Enter> - accept, <Ctrl Enter> - add command, <↓↑> - navigate";
const HELP_PENDING: &str = "<e> - edit, <Enter> - run";
fn select_help_text(sequence: &State) -> &'static str {
let is_running = sequence
@@ -35,18 +32,9 @@ fn select_help_text(sequence: &State) -> &'static str {
.iter()
.all(|command| matches!(command.get_status(), CommandStatus::Pending));
let has_selection = sequence.selection.current_selected_command_index.is_some();
let is_editing = sequence.editing.editing_input.is_some();
if all_pending && !sequence.execution.is_running {
if sequence.execution.all_commands.len() == 1 {
HELP_ONE_PENDING_COMMAND
} else if has_selection && !is_editing {
HELP_ALL_COMMANDS_PENDING_WITH_SELECTION
} else if is_editing {
HELP_EDITING_FIRST_LINE
} else {
HELP_ALL_COMMANDS_PENDING
}
HELP_PENDING
} else if is_running {
if has_selection {
HELP_RUNNING_WITH_SELECTION
@@ -54,10 +42,8 @@ fn select_help_text(sequence: &State) -> &'static str {
HELP_RUNNING_NO_SELECTION
}
} else {
if has_selection && !is_editing {
if has_selection {
HELP_STOPPED_WITH_SELECTION
} else if is_editing {
HELP_EDITING_FIRST_LINE
} else {
HELP_STOPPED_NO_SELECTION
}
@@ -71,7 +57,6 @@ fn style_help_text(text: &str) -> Text {
.color_substring(3, "<↓↑>")
.color_substring(3, "<Esc>")
.color_substring(3, "<Enter>")
.color_substring(3, "<Ctrl Enter>")
.color_substring(3, "<e>")
}
@@ -79,33 +64,6 @@ pub fn render_help_lines(
sequence: &State,
max_width: Option<usize>,
) -> (Text, usize, Option<(Text, usize)>) {
let is_editing = sequence.editing.editing_input.is_some();
if is_editing {
let help_text = select_help_text(sequence);
let (truncated_text, help_len) = if let Some(width) = max_width {
truncate_help_line(help_text, width)
} else {
(help_text.to_string(), help_text.chars().count())
};
let first_line = (style_help_text(&truncated_text).unbold_all(), help_len);
let editing_help_text = "Navigate with cd, chain with ||, &&, ; ";
let editing_help = Text::new(editing_help_text)
.color_substring(3, "cd")
.color_substring(3, "||")
.color_substring(3, "&&")
.color_substring(3, ";")
.unbold_all();
let editing_len = editing_help_text.len();
return (
first_line.0,
first_line.1,
Some((editing_help, editing_len)),
);
}
let help_text = select_help_text(sequence);
if let Some(width) = max_width {
@@ -139,6 +97,38 @@ pub fn render_help_lines(
}
}
/// Returns `(text, x, y)` tuples for the main screen intro hint.
/// The text is centered horizontally; if it does not fit on one line, two lines
/// are returned separated by one blank line (1-line padding).
pub fn render_intro_hint(rows: usize, cols: usize) -> Vec<(Text, usize, usize)> {
let full_len = INTRO_FULL.chars().count();
if full_len <= cols {
let x = cols.saturating_sub(full_len) / 2;
let y = rows / 2;
vec![(Text::new(INTRO_FULL).color_substring(3, "<e>"), x, y)]
} else {
let p1_len = INTRO_PART1.chars().count();
let p2_len = INTRO_PART2.chars().count();
let x1 = cols.saturating_sub(p1_len) / 2;
let x2 = cols.saturating_sub(p2_len) / 2;
// 3 display rows: line1, padding, line2 — center the group vertically
let start_y = rows.saturating_sub(3) / 2;
vec![
(
Text::new(INTRO_PART1).color_substring(3, "<e>"),
x1,
start_y,
),
(
Text::new(INTRO_PART2).color_substring(3, "<e>"),
x2,
start_y + 2,
),
]
}
}
fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> {
let text_width = help_text.chars().count();
@@ -200,7 +190,7 @@ fn chain_cell(chain_type: &crate::state::ChainType) -> Text {
}
}
fn get_spinner_frame(frame: usize) -> &'static str {
pub fn get_spinner_frame(frame: usize) -> &'static str {
SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
}
@@ -371,7 +361,8 @@ pub fn add_command_row(
None => return table,
};
let cmd_text = &command.get_text();
let cmd_text = command.get_text();
let cmd_text = cmd_text.trim();
let chain_type = &command.get_chain_type();
let status = &command.get_status();
let command_cwd = command.get_cwd().or_else(|| state.cwd.clone());
@@ -398,26 +389,6 @@ pub fn add_command_row(
return table;
};
if let Some(text_input) = &state.editing.editing_input {
if state.selection.current_selected_command_index == Some(index) {
let (truncated_editing_text, _) = truncate_middle(
text_input.get_text(),
available_cmd_width,
Some(text_input.cursor_position()),
);
let (row, _) = command_sequence_row(
state,
&command_cwd,
&truncated_editing_text,
chain_type,
&CommandStatus::Pending,
false,
overflow_indicator,
);
return table.add_styled_row(row);
}
}
let truncated_cmd_text = truncate_middle(cmd_text, available_cmd_width, None).0;
let (row, _) = command_sequence_row(
@@ -1,321 +0,0 @@
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
/// Result of a fuzzy completion operation
#[derive(Debug, Clone)]
pub struct CompletionResult {
pub completed_text: String,
pub score: i64,
pub is_directory: bool,
pub is_prefix_completion: bool, // True if this is the shortest of multiple matches with same prefix
}
/// Type of completion
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CompletionType {
Command,
Path,
}
/// Fuzzy match executables (commands) from the PATH
pub fn fuzzy_complete_command(
query: &str,
executables: &BTreeMap<String, PathBuf>,
) -> Option<CompletionResult> {
if query.is_empty() {
return None;
}
let matcher = SkimMatcherV2::default().ignore_case();
// Collect all matches with their scores
let mut all_matches: Vec<(String, i64)> = Vec::new();
for executable_name in executables.keys() {
if let Some((score, _indices)) = matcher.fuzzy_indices(executable_name, query) {
all_matches.push((executable_name.clone(), score));
}
}
if all_matches.is_empty() {
return None;
}
// Sort by score (highest first), then by length (shortest first)
all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
// Get the best match
let (best_name, best_score) = &all_matches[0];
// Check if there are multiple matches that share the same prefix
let is_prefix_completion = check_for_prefix_matches(&all_matches, best_name);
Some(CompletionResult {
completed_text: best_name.clone(),
score: *best_score,
is_directory: false, // Commands are not directories
is_prefix_completion,
})
}
/// Fuzzy match paths (directories and files) relative to the current working directory
pub fn fuzzy_complete_path(query: &str, cwd: Option<&PathBuf>) -> Option<CompletionResult> {
if query.is_empty() {
return None;
}
let matcher = SkimMatcherV2::default().ignore_case();
// Determine the base directory to search in
let (base_dir, search_prefix) = if query.starts_with('/') {
// Absolute path
let parts: Vec<&str> = query.rsplitn(2, '/').collect();
if parts.len() == 2 {
// Has a directory component
let dir = parts[1];
let prefix = parts[0];
(PathBuf::from(dir), prefix.to_string())
} else {
// Just "/"
(PathBuf::from("/"), String::new())
}
} else if query.starts_with("~/") {
// Home directory
let home_dir = std::env::var("HOME").ok()?;
let query_without_tilde = &query[2..];
let parts: Vec<&str> = query_without_tilde.rsplitn(2, '/').collect();
if parts.len() == 2 {
let dir = PathBuf::from(&home_dir).join(parts[1]);
let prefix = parts[0];
(dir, prefix.to_string())
} else {
// Just "~/" or "~/something"
(PathBuf::from(home_dir), query_without_tilde.to_string())
}
} else {
// Relative path
let base = cwd?;
let parts: Vec<&str> = query.rsplitn(2, '/').collect();
if parts.len() == 2 {
// Has a directory component
let dir = base.join(parts[1]);
let prefix = parts[0];
(dir, prefix.to_string())
} else {
// Just a name in the current directory
(base.clone(), query.to_string())
}
};
// Convert to host path
let host_base_dir =
PathBuf::from("/host").join(base_dir.strip_prefix("/").unwrap_or(&base_dir));
// Read directory entries
let entries = fs::read_dir(&host_base_dir).ok()?;
// Collect all matches with their scores and directory status
let mut all_matches: Vec<(String, i64, bool)> = Vec::new();
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
// Skip hidden files unless the query starts with a dot
if name.starts_with('.') && !search_prefix.starts_with('.') {
continue;
}
if let Some((score, _indices)) = matcher.fuzzy_indices(name, &search_prefix) {
// Check if this entry is a directory
let is_dir = entry.metadata().ok().map(|m| m.is_dir()).unwrap_or(false);
all_matches.push((name.to_string(), score, is_dir));
}
}
}
if all_matches.is_empty() {
return None;
}
// Sort by score (highest first), then by length (shortest first)
all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
// Get the best match
let (name, score, is_directory) = all_matches[0].clone();
// Check if there are multiple matches that share the same prefix
let name_matches: Vec<(String, i64)> = all_matches
.iter()
.map(|(n, s, _)| (n.clone(), *s))
.collect();
let is_prefix_completion = check_for_prefix_matches(&name_matches, &name);
// Reconstruct the full path
let completed_text = if query.starts_with('/') {
// Absolute path
let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
if dir_part.is_empty() {
format!("/{}", name)
} else {
format!("{}/{}", dir_part, name)
}
} else if query.starts_with("~/") {
// Home directory
let query_without_tilde = &query[2..];
let dir_part = query_without_tilde.rsplitn(2, '/').nth(1).unwrap_or("");
if dir_part.is_empty() {
format!("~/{}", name)
} else {
format!("~/{}/{}", dir_part, name)
}
} else {
// Relative path
let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
if dir_part.is_empty() {
name.clone()
} else {
format!("{}/{}", dir_part, name)
}
};
Some(CompletionResult {
completed_text,
score,
is_directory,
is_prefix_completion,
})
}
/// Check if there are multiple matches that start with the best match's name
/// Returns true if the best match is a prefix of other matches
fn check_for_prefix_matches(matches: &[(String, i64)], best_match: &str) -> bool {
if matches.len() <= 1 {
return false;
}
// Count how many matches start with the best_match text
let prefix_count = matches
.iter()
.filter(|(name, _)| name.starts_with(best_match) && name != best_match)
.count();
prefix_count > 0
}
/// Perform fuzzy completion on the current input
/// Returns the best completion (command or path) with appropriate suffix (space or slash)
pub fn fuzzy_complete(
input: &str,
executables: &BTreeMap<String, PathBuf>,
cwd: Option<&PathBuf>,
) -> Option<String> {
// If input is empty, nothing to complete
if input.is_empty() {
return None;
}
// Extract the last word/token to complete
// For simplicity, we'll complete the entire input if it's a single word,
// or the last space-separated token if there are multiple words
let tokens: Vec<&str> = input.split_whitespace().collect();
let to_complete = if tokens.is_empty() {
input
} else {
tokens.last().unwrap()
};
// Get both command and path completions
let command_result = fuzzy_complete_command(to_complete, executables);
let path_result = fuzzy_complete_path(to_complete, cwd);
// Choose the better match based on score
match (command_result, path_result) {
(Some(cmd), Some(path)) => {
// Both matched, use the higher score
let (result, completion_type, is_dir, is_prefix) = if cmd.score >= path.score {
(
cmd.completed_text,
CompletionType::Command,
false,
cmd.is_prefix_completion,
)
} else {
(
path.completed_text,
CompletionType::Path,
path.is_directory,
path.is_prefix_completion,
)
};
// Add appropriate suffix (skip slash if this is a prefix completion)
let suffix = get_completion_suffix(completion_type, is_dir, is_prefix);
let completed = format!("{}{}", result, suffix);
// Replace the last token with the completion
Some(replace_last_token(input, &completed))
},
(Some(cmd), None) => {
// Command completion - add space (unless it's a prefix completion)
let suffix = if cmd.is_prefix_completion { "" } else { " " };
let completed = format!("{}{}", cmd.completed_text, suffix);
Some(replace_last_token(input, &completed))
},
(None, Some(path)) => {
// Path completion - add slash if directory (unless it's a prefix completion)
let suffix = if path.is_prefix_completion {
""
} else if path.is_directory {
"/"
} else {
""
};
let completed = format!("{}{}", path.completed_text, suffix);
Some(replace_last_token(input, &completed))
},
(None, None) => None,
}
}
/// Get the appropriate suffix for a completion
fn get_completion_suffix(
completion_type: CompletionType,
is_directory: bool,
is_prefix_completion: bool,
) -> &'static str {
// No suffix for prefix completions
if is_prefix_completion {
return "";
}
match completion_type {
CompletionType::Command => " ",
CompletionType::Path => {
if is_directory {
"/"
} else {
""
}
},
}
}
/// Replace the last whitespace-separated token in the input with the completion
fn replace_last_token(input: &str, completion: &str) -> String {
let tokens: Vec<&str> = input.split_whitespace().collect();
if tokens.is_empty() {
return completion.to_string();
}
if tokens.len() == 1 {
completion.to_string()
} else {
// Join all tokens except the last one, then add the completion
let mut result = tokens[..tokens.len() - 1].join(" ");
result.push(' ');
result.push_str(completion);
result
}
}
-2
View File
@@ -1,5 +1,3 @@
pub mod components;
pub mod fuzzy_complete;
pub mod layout_calculations;
pub mod text_input;
pub mod truncation;
@@ -1,997 +0,0 @@
use zellij_tile::prelude::*;
const MAX_UNDO_STACK_SIZE: usize = 100;
/// Action returned by TextInput after handling a key event
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(test, derive(Eq))]
pub enum InputAction {
/// Continue editing
Continue,
/// User pressed Enter to submit
Submit,
/// User pressed Esc to cancel
Cancel,
/// User pressed Tab to request completion
Complete,
/// Key was not handled by the input
NoAction,
}
/// A reusable text input component with cursor support and standard editing keybindings
#[derive(Debug, Clone)]
pub struct TextInput {
buffer: String,
cursor_position: usize, // Character position (0-based), NOT byte position
undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots
redo_stack: Vec<(String, usize)>,
last_edit_was_insert: bool, // For coalescing consecutive inserts
}
impl TextInput {
/// Create a new TextInput with the given initial text
/// Cursor is positioned at the end of the text
pub fn new(initial_text: String) -> Self {
let cursor_position = initial_text.chars().count();
Self {
buffer: initial_text,
cursor_position,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
last_edit_was_insert: false,
}
}
/// Create an empty TextInput
pub fn empty() -> Self {
Self::new(String::new())
}
/// Get the current text
pub fn get_text(&self) -> &str {
&self.buffer
}
/// Get the cursor position (in characters, not bytes)
pub fn get_cursor_position(&self) -> usize {
self.cursor_position
}
/// Check if the input is empty
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
/// Get a shorthand for cursor_position
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
/// Get mutable access to the underlying buffer for direct manipulation
pub fn get_text_mut(&mut self) -> &mut String {
&mut self.buffer
}
/// Set the text and move cursor to the end
pub fn set_text(&mut self, text: String) {
self.break_coalescing();
self.save_undo_state();
self.cursor_position = text.chars().count();
self.buffer = text;
}
/// Set cursor position (clamped to text length)
pub fn set_cursor_position(&mut self, pos: usize) {
let text_len = self.buffer.chars().count();
self.cursor_position = pos.min(text_len);
}
/// Clear all text and reset cursor
pub fn clear(&mut self) {
self.break_coalescing();
self.save_undo_state();
self.buffer.clear();
self.cursor_position = 0;
}
/// Insert a character at the current cursor position
pub fn insert_char(&mut self, c: char) {
self.save_undo_state_unless_coalescing();
// Convert cursor position (char index) to byte index
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.insert(byte_index, c);
self.cursor_position += 1;
}
/// Delete the character before the cursor (backspace)
pub fn backspace(&mut self) {
if self.cursor_position > 0 {
self.break_coalescing();
self.save_undo_state();
self.cursor_position -= 1;
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.remove(byte_index);
}
}
/// Delete the character at the cursor position (delete key)
pub fn delete(&mut self) {
let len = self.buffer.chars().count();
if self.cursor_position < len {
self.break_coalescing();
self.save_undo_state();
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.remove(byte_index);
}
}
/// Delete the word before the cursor (Ctrl/Alt + Backspace)
pub fn delete_word_backward(&mut self) {
if self.cursor_position == 0 {
return;
}
self.break_coalescing();
self.save_undo_state();
let old_position = self.cursor_position;
self.move_word_left();
let new_position = self.cursor_position;
// Delete from new position to old position
let start_byte = self.char_index_to_byte_index(new_position);
let end_byte = self.char_index_to_byte_index(old_position);
self.buffer.drain(start_byte..end_byte);
}
/// Delete the word after the cursor (Ctrl/Alt + Delete)
pub fn delete_word_forward(&mut self) {
let chars: Vec<char> = self.buffer.chars().collect();
let len = chars.len();
if self.cursor_position >= len {
return;
}
self.break_coalescing();
self.save_undo_state();
let start_position = self.cursor_position;
let mut end_position = start_position;
// Skip the current word
while end_position < len && !chars[end_position].is_whitespace() {
end_position += 1;
}
// Skip any whitespace after the word
while end_position < len && chars[end_position].is_whitespace() {
end_position += 1;
}
// Delete from start to end position
let start_byte = self.char_index_to_byte_index(start_position);
let end_byte = self.char_index_to_byte_index(end_position);
self.buffer.drain(start_byte..end_byte);
}
/// Move cursor one position to the left
pub fn move_left(&mut self) {
if self.cursor_position > 0 {
self.break_coalescing();
self.cursor_position -= 1;
}
}
/// Move cursor one position to the right
pub fn move_right(&mut self) {
let len = self.buffer.chars().count();
if self.cursor_position < len {
self.break_coalescing();
self.cursor_position += 1;
}
}
/// Move cursor to the start of the text (Ctrl-A / Home)
pub fn move_to_start(&mut self) {
self.break_coalescing();
self.cursor_position = 0;
}
/// Move cursor to the end of the text (Ctrl-E / End)
pub fn move_to_end(&mut self) {
self.break_coalescing();
self.cursor_position = self.buffer.chars().count();
}
/// Move cursor to the start of the previous word (Ctrl/Alt + Left)
pub fn move_word_left(&mut self) {
if self.cursor_position == 0 {
return;
}
self.break_coalescing();
let chars: Vec<char> = self.buffer.chars().collect();
let mut pos = self.cursor_position;
// Skip any whitespace immediately to the left
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Skip the word characters
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
self.cursor_position = pos;
}
/// Move cursor to the start of the next word (Ctrl/Alt + Right)
pub fn move_word_right(&mut self) {
let chars: Vec<char> = self.buffer.chars().collect();
let len = chars.len();
if self.cursor_position >= len {
return;
}
self.break_coalescing();
let mut pos = self.cursor_position;
// Skip the current word
while pos < len && !chars[pos].is_whitespace() {
pos += 1;
}
// Skip any whitespace
while pos < len && chars[pos].is_whitespace() {
pos += 1;
}
self.cursor_position = pos;
}
/// Handle a key event and return the appropriate action
/// This is the main entry point for key handling
pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction {
// Check for Ctrl modifiers
if key.has_modifiers(&[KeyModifier::Ctrl]) {
match key.bare_key {
BareKey::Char('a') => {
self.move_to_start();
return InputAction::Continue;
},
BareKey::Char('e') => {
self.move_to_end();
return InputAction::Continue;
},
BareKey::Char('c') => {
// Ctrl-C clears the prompt
return InputAction::Cancel;
},
BareKey::Char('z') => {
// Ctrl-Z: Undo
self.undo();
return InputAction::Continue;
},
BareKey::Char('y') => {
// Ctrl-Y: Redo
self.redo();
return InputAction::Continue;
},
BareKey::Left => {
self.move_word_left();
return InputAction::Continue;
},
BareKey::Right => {
self.move_word_right();
return InputAction::Continue;
},
BareKey::Backspace => {
self.delete_word_backward();
return InputAction::Continue;
},
BareKey::Delete => {
self.delete_word_forward();
return InputAction::Continue;
},
_ => {},
}
}
// Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z)
if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) {
match key.bare_key {
BareKey::Char('Z') => {
// Ctrl-Shift-Z: Redo (alternative)
self.redo();
return InputAction::Continue;
},
_ => {},
}
}
// Check for Alt modifiers
if key.has_modifiers(&[KeyModifier::Alt]) {
match key.bare_key {
BareKey::Left => {
self.move_word_left();
return InputAction::Continue;
},
BareKey::Right => {
self.move_word_right();
return InputAction::Continue;
},
BareKey::Backspace => {
self.delete_word_backward();
return InputAction::Continue;
},
BareKey::Delete => {
self.delete_word_forward();
return InputAction::Continue;
},
_ => {},
}
}
// Handle bare keys (no modifiers)
match key.bare_key {
BareKey::Enter => InputAction::Submit,
BareKey::Esc => InputAction::Cancel,
BareKey::Tab => InputAction::Complete,
BareKey::Backspace => {
self.backspace();
InputAction::Continue
},
BareKey::Delete => {
self.delete();
InputAction::Continue
},
BareKey::Left => {
self.move_left();
InputAction::Continue
},
BareKey::Right => {
self.move_right();
InputAction::Continue
},
BareKey::Home => {
self.move_to_start();
InputAction::Continue
},
BareKey::End => {
self.move_to_end();
InputAction::Continue
},
BareKey::Char(c) => {
self.insert_char(c);
InputAction::Continue
},
_ => InputAction::NoAction,
}
}
/// Helper: Convert character index to byte index
fn char_index_to_byte_index(&self, char_index: usize) -> usize {
self.buffer
.char_indices()
.nth(char_index)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or(self.buffer.len())
}
/// Save current state to undo stack before making changes
fn save_undo_state(&mut self) {
if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE {
self.undo_stack.remove(0);
}
self.undo_stack
.push((self.buffer.clone(), self.cursor_position));
self.redo_stack.clear();
}
/// Save state only if not coalescing with previous insert
fn save_undo_state_unless_coalescing(&mut self) {
if !self.last_edit_was_insert {
self.save_undo_state();
}
self.last_edit_was_insert = true;
}
/// Mark that a non-insert edit occurred (breaks coalescing)
fn break_coalescing(&mut self) {
self.last_edit_was_insert = false;
}
/// Undo last change
pub fn undo(&mut self) -> bool {
if let Some((buffer, cursor)) = self.undo_stack.pop() {
self.redo_stack
.push((self.buffer.clone(), self.cursor_position));
self.buffer = buffer;
self.cursor_position = cursor;
self.break_coalescing();
true
} else {
false
}
}
/// Redo last undone change
pub fn redo(&mut self) -> bool {
if let Some((buffer, cursor)) = self.redo_stack.pop() {
self.undo_stack
.push((self.buffer.clone(), self.cursor_position));
self.buffer = buffer;
self.cursor_position = cursor;
self.break_coalescing();
true
} else {
false
}
}
/// Check if undo is available
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
/// Check if redo is available
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn drain_text(&mut self) -> String {
self.cursor_position = 0;
self.buffer.drain(..).collect()
}
}
// run with:
// cargo test --lib --target x86_64-unknown-linux-gnu
//
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_and_empty() {
let input = TextInput::new("hello".to_string());
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
let empty = TextInput::empty();
assert_eq!(empty.get_text(), "");
assert_eq!(empty.get_cursor_position(), 0);
}
#[test]
fn test_insert_char() {
let mut input = TextInput::new("helo".to_string());
input.cursor_position = 3; // Position after "hel"
input.insert_char('l');
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 4);
}
#[test]
fn test_backspace() {
let mut input = TextInput::new("hello".to_string());
input.backspace();
assert_eq!(input.get_text(), "hell");
assert_eq!(input.get_cursor_position(), 4);
// Backspace at start does nothing
input.cursor_position = 0;
input.backspace();
assert_eq!(input.get_text(), "hell");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete() {
let mut input = TextInput::new("hello".to_string());
input.cursor_position = 0;
input.delete();
assert_eq!(input.get_text(), "ello");
assert_eq!(input.get_cursor_position(), 0);
// Delete at end does nothing
input.move_to_end();
input.delete();
assert_eq!(input.get_text(), "ello");
}
#[test]
fn test_cursor_movement() {
let mut input = TextInput::new("hello".to_string());
assert_eq!(input.get_cursor_position(), 5);
input.move_left();
assert_eq!(input.get_cursor_position(), 4);
input.move_right();
assert_eq!(input.get_cursor_position(), 5);
input.move_to_start();
assert_eq!(input.get_cursor_position(), 0);
input.move_to_end();
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_unicode_support() {
let mut input = TextInput::new("hello 🦀 world".to_string());
assert_eq!(input.get_cursor_position(), 13); // 13 characters
input.cursor_position = 6; // After "hello "
input.insert_char('🐱');
assert_eq!(input.get_text(), "hello 🐱🦀 world");
}
#[test]
fn test_word_jump_right() {
let mut input = TextInput::new("hello world foo bar".to_string());
input.cursor_position = 0;
// Jump from start to "world"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 6); // After "hello "
// Jump to "foo"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 12); // After "world "
// Jump to "bar"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 16); // After "foo "
// Jump to end
input.move_word_right();
assert_eq!(input.get_cursor_position(), 19); // At end
}
#[test]
fn test_word_jump_left() {
let mut input = TextInput::new("hello world foo bar".to_string());
input.move_to_end();
assert_eq!(input.get_cursor_position(), 19);
// Jump back to "bar"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 16); // Start of "bar"
// Jump back to "foo"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 12); // Start of "foo"
// Jump back to "world"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 6); // Start of "world"
// Jump back to "hello"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0); // Start of "hello"
}
#[test]
fn test_word_jump_with_multiple_spaces() {
let mut input = TextInput::new("hello world".to_string());
input.cursor_position = 0;
// Jump over multiple spaces
input.move_word_right();
assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world"
// Jump back should skip spaces
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello"
}
#[test]
fn test_word_jump_boundaries() {
let mut input = TextInput::new("test".to_string());
// At start - word left does nothing
input.cursor_position = 0;
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0);
// At end - word right does nothing
input.move_to_end();
let end_pos = input.get_cursor_position();
input.move_word_right();
assert_eq!(input.get_cursor_position(), end_pos);
}
#[test]
fn test_up_down_arrows() {
let mut input = TextInput::new("hello world".to_string());
// Start in the middle
input.cursor_position = 5;
assert_eq!(input.get_cursor_position(), 5);
// Up arrow should go to start
input.move_to_start();
assert_eq!(input.get_cursor_position(), 0);
// Move back to middle
input.cursor_position = 5;
// Down arrow should go to end
input.move_to_end();
assert_eq!(input.get_cursor_position(), 11);
}
#[test]
fn test_delete_word_backward() {
let mut input = TextInput::new("hello world foo".to_string());
// Delete "foo" from end
input.move_to_end();
input.delete_word_backward();
assert_eq!(input.get_text(), "hello world ");
assert_eq!(input.get_cursor_position(), 12);
// Delete "world "
input.delete_word_backward();
assert_eq!(input.get_text(), "hello ");
assert_eq!(input.get_cursor_position(), 6);
// Delete "hello "
input.delete_word_backward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Delete on empty buffer does nothing
input.delete_word_backward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete_word_backward_middle() {
let mut input = TextInput::new("hello world foo".to_string());
// Position in middle of "world"
input.cursor_position = 8; // After "hello wo"
input.delete_word_backward();
assert_eq!(input.get_text(), "hello rld foo");
assert_eq!(input.get_cursor_position(), 6); // After "hello "
}
#[test]
fn test_delete_word_forward() {
let mut input = TextInput::new("hello world foo".to_string());
// Delete "hello " from start
input.cursor_position = 0;
input.delete_word_forward();
assert_eq!(input.get_text(), "world foo");
assert_eq!(input.get_cursor_position(), 0);
// Delete "world "
input.delete_word_forward();
assert_eq!(input.get_text(), "foo");
assert_eq!(input.get_cursor_position(), 0);
// Delete "foo"
input.delete_word_forward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Delete on empty buffer does nothing
input.delete_word_forward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete_word_forward_middle() {
let mut input = TextInput::new("hello world foo".to_string());
// Position in middle of "world"
input.cursor_position = 8; // After "hello wo"
input.delete_word_forward();
assert_eq!(input.get_text(), "hello wofoo");
assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward
}
#[test]
fn test_delete_word_with_multiple_spaces() {
let mut input = TextInput::new("hello world".to_string());
// Delete forward includes trailing spaces
input.cursor_position = 0;
input.delete_word_forward();
assert_eq!(input.get_text(), "world");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_undo_redo_basic() {
let mut input = TextInput::empty();
// Type "hello"
input.insert_char('h');
input.insert_char('e');
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
assert_eq!(input.get_text(), "hello");
// Undo should remove all characters (coalesced into one undo entry)
assert!(input.can_undo());
assert!(input.undo());
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Redo should restore "hello"
assert!(input.can_redo());
assert!(input.redo());
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_undo_coalescing_breaks_on_cursor_move() {
let mut input = TextInput::empty();
// Type "he"
input.insert_char('h');
input.insert_char('e');
// Move cursor to start (breaks coalescing)
input.move_to_start();
// Type "llo"
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
assert_eq!(input.get_text(), "llohe");
// First undo removes "llo" (second coalesced group)
input.undo();
assert_eq!(input.get_text(), "he");
// Second undo removes "he" (first coalesced group)
input.undo();
assert_eq!(input.get_text(), "");
}
#[test]
fn test_undo_backspace() {
let mut input = TextInput::new("hello".to_string());
// Backspace once
input.backspace();
assert_eq!(input.get_text(), "hell");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_undo_delete() {
let mut input = TextInput::new("hello".to_string());
input.cursor_position = 0;
// Delete first character
input.delete();
assert_eq!(input.get_text(), "ello");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_undo_word_delete() {
let mut input = TextInput::new("hello world".to_string());
// Delete "world" backward
input.delete_word_backward();
assert_eq!(input.get_text(), "hello ");
// Undo should restore "hello world"
input.undo();
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 11);
}
#[test]
fn test_undo_clear() {
let mut input = TextInput::new("hello world".to_string());
// Clear the buffer
input.clear();
assert_eq!(input.get_text(), "");
// Undo should restore the text
input.undo();
assert_eq!(input.get_text(), "hello world");
}
#[test]
fn test_undo_set_text() {
let mut input = TextInput::new("hello".to_string());
// Replace with new text
input.set_text("goodbye".to_string());
assert_eq!(input.get_text(), "goodbye");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
}
#[test]
fn test_redo_clears_on_new_edit() {
let mut input = TextInput::empty();
// Type "hello"
input.insert_char('h');
input.insert_char('e');
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
// Undo
input.undo();
assert_eq!(input.get_text(), "");
assert!(input.can_redo());
// Make a new edit (should clear redo stack)
input.insert_char('x');
assert!(!input.can_redo());
}
#[test]
fn test_multiple_undo_redo() {
let mut input = TextInput::empty();
// First edit: type "hello"
for c in "hello".chars() {
input.insert_char(c);
}
// Break coalescing
input.move_left();
// Second edit: type "world"
for c in "world".chars() {
input.insert_char(c);
}
assert_eq!(input.get_text(), "hellworldo");
// Undo "world"
input.undo();
assert_eq!(input.get_text(), "hello");
// Undo "hello"
input.undo();
assert_eq!(input.get_text(), "");
// Redo "hello"
input.redo();
assert_eq!(input.get_text(), "hello");
// Redo "world"
input.redo();
assert_eq!(input.get_text(), "hellworldo");
}
#[test]
fn test_undo_stack_limit() {
let mut input = TextInput::empty();
// Perform 102 separate edits (breaking coalescing each time)
for _i in 0..102 {
input.backspace(); // Break coalescing
input.insert_char('x');
}
// Should have at most 100 undo entries
let mut undo_count = 0;
while input.undo() {
undo_count += 1;
}
// We should have 100 undo entries (the stack limit)
// Plus the final state change from the last coalescing break
assert!(
undo_count <= 100,
"Undo count should be at most 100, got {}",
undo_count
);
}
#[test]
fn test_undo_redo_empty_stack() {
let mut input = TextInput::empty();
// Undo on empty stack should return false
assert!(!input.can_undo());
assert!(!input.undo());
// Redo on empty stack should return false
assert!(!input.can_redo());
assert!(!input.redo());
}
#[test]
fn test_undo_restores_cursor_position() {
let mut input = TextInput::new("hello world".to_string());
// Move cursor to position 5 (before "world")
input.cursor_position = 5;
// Insert a space
input.insert_char(' ');
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 6);
// Undo should restore both text and cursor position
input.undo();
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_coalescing_consecutive_inserts() {
let mut input = TextInput::empty();
// Type several characters
input.insert_char('a');
input.insert_char('b');
input.insert_char('c');
assert_eq!(input.get_text(), "abc");
// Single undo should remove all three (they were coalesced)
input.undo();
assert_eq!(input.get_text(), "");
// No more undo available
assert!(!input.can_undo());
}
#[test]
fn test_backspace_breaks_coalescing() {
let mut input = TextInput::empty();
// Type "ab"
input.insert_char('a');
input.insert_char('b');
// Backspace
input.backspace();
assert_eq!(input.get_text(), "a");
// Type "c"
input.insert_char('c');
assert_eq!(input.get_text(), "ac");
// Undo should remove just "c"
input.undo();
assert_eq!(input.get_text(), "a");
// Undo should remove backspace operation
input.undo();
assert_eq!(input.get_text(), "ab");
// Undo should remove "ab"
input.undo();
assert_eq!(input.get_text(), "");
}
}
+27
View File
@@ -210,6 +210,33 @@ fn main() {
commands::send_action_to_session(command_cli_action, opts.session, config);
std::process::exit(0);
}
if let Some(Command::Sessions(Sessions::Sequence { payload, blocking })) = opts.command {
let args = if blocking {
let mut args: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
args.insert("blocking".to_string(), "true".to_string());
Some(zellij_utils::input::layout::PluginUserConfiguration::new(
args,
))
} else {
None
};
let command_cli_action = CliAction::Pipe {
name: None,
payload,
args,
plugin: Some("zellij:sequence".to_string()),
plugin_configuration: None,
force_launch_plugin: true,
skip_plugin_cache: false,
floating_plugin: None,
in_place_plugin: None,
plugin_cwd: None,
plugin_title: None,
};
commands::send_action_to_session(command_cli_action, opts.session, config);
std::process::exit(0);
}
}
if let Some(Command::Sessions(Sessions::ListSessions {
+1
View File
@@ -1746,6 +1746,7 @@ impl Grid {
pub fn reset_terminal_state(&mut self) {
self.lines_above = VecDeque::new();
self.lines_below = vec![];
self.is_scrolled = false;
self.viewport = vec![Row::new().canonical()];
self.alternate_screen_state = None;
self.cursor_key_mode = false;
+411 -40
View File
@@ -28,13 +28,15 @@ use zellij_utils::data::{
KeyWithModifier, LayoutInfo, LayoutMetadata, LayoutParsingError, MessageToPlugin,
NewPanePlacement, NewTabResponse, NewTabsResponse, OpenCommandPaneBackgroundResponse,
OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
OpenCommandPaneInPlaceOfPluginResponse, OpenCommandPaneInPlaceResponse,
OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse, OpenFileFloatingNearPluginResponse,
OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdResponse, OpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse, OpenFileInPlaceOfPluginResponse, OpenFileInPlaceResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenTerminalFloatingNearPluginResponse,
OpenTerminalFloatingResponse, OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalResponse, OriginatingPlugin,
PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission,
OpenFileNearPluginResponse, OpenFileResponse, OpenPaneInNewTabResponse,
OpenTerminalFloatingNearPluginResponse, OpenTerminalFloatingResponse,
OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse,
OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission,
RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
};
use zellij_utils::home::default_layout_dir;
@@ -53,10 +55,10 @@ use kdl::KdlDocument;
use prost::Message;
use zellij_utils::{
consts::{VERSION, ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR},
consts::{VERSION, ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR, ZELLIJ_TMP_DIR},
data::{
CommandToRun, Direction, EventType, FileToOpen, InputMode, PluginCommand, PluginIds,
PluginMessage, Resize, ResizeStrategy,
CommandOrPlugin, CommandToRun, Direction, EventType, FileToOpen, InputMode, PluginCommand,
PluginIds, PluginMessage, Resize, ResizeStrategy,
},
errors::prelude::*,
input::{
@@ -84,14 +86,17 @@ use zellij_utils::{
ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse,
ProtobufOpenCommandPaneFloatingResponse,
ProtobufOpenCommandPaneInPlaceOfPaneIdResponse,
ProtobufOpenCommandPaneInPlaceOfPluginResponse, ProtobufOpenCommandPaneInPlaceResponse,
ProtobufOpenCommandPaneNearPluginResponse, ProtobufOpenCommandPaneResponse,
ProtobufOpenEditPaneInPlaceOfPaneIdResponse,
ProtobufOpenFileFloatingNearPluginResponse, ProtobufOpenFileFloatingResponse,
ProtobufOpenFileInPlaceOfPluginResponse, ProtobufOpenFileInPlaceResponse,
ProtobufOpenFileNearPluginResponse, ProtobufOpenFileResponse,
ProtobufOpenTerminalFloatingNearPluginResponse, ProtobufOpenTerminalFloatingResponse,
ProtobufOpenTerminalInPlaceOfPluginResponse, ProtobufOpenTerminalInPlaceResponse,
ProtobufOpenTerminalNearPluginResponse, ProtobufOpenTerminalResponse,
ProtobufOpenPaneInNewTabResponse, ProtobufOpenTerminalFloatingNearPluginResponse,
ProtobufOpenTerminalFloatingResponse, ProtobufOpenTerminalInPlaceOfPluginResponse,
ProtobufOpenTerminalInPlaceResponse, ProtobufOpenTerminalNearPluginResponse,
ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, ProtobufOpenTerminalResponse,
ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse,
ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse,
},
@@ -131,6 +136,23 @@ macro_rules! apply_action {
};
}
fn translate_plugin_path(env: &PluginEnv, path: PathBuf) -> PathBuf {
if let Ok(stripped) = path.strip_prefix("/host") {
env.plugin_cwd.join(stripped)
} else if let Ok(stripped) = path.strip_prefix("/data") {
env.plugin_own_data_dir.join(stripped)
} else if let Ok(stripped) = path.strip_prefix("/cache") {
env.plugin_own_cache_dir.join(stripped)
} else if let Ok(stripped) = path.strip_prefix("/tmp") {
ZELLIJ_TMP_DIR.join(stripped)
} else if path.is_relative() {
env.plugin_cwd.join(path)
} else {
// Absolute path not in any plugin special folder — pass through
path
}
}
pub fn zellij_exports(linker: &mut Linker<PluginEnv>) {
linker
.func_wrap("zellij", "host_run_plugin_command", host_run_plugin_command)
@@ -663,6 +685,53 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::GetSessionEnvironmentVariables => {
get_session_environment_variables(env);
},
PluginCommand::OpenCommandPaneInNewTab(command_to_run, context) => {
open_command_pane_in_new_tab(env, command_to_run, context);
},
PluginCommand::OpenPluginPaneInNewTab {
plugin_url,
configuration,
context,
} => {
open_plugin_pane_in_new_tab(env, plugin_url, configuration, context);
},
PluginCommand::OpenEditorPaneInNewTab(file_to_open, context) => {
open_editor_pane_in_new_tab(env, file_to_open, context);
},
PluginCommand::OpenCommandPaneInPlaceOfPaneId(
pane_id,
command_to_run,
close_replaced_pane,
context,
) => open_command_pane_in_place_of_pane_id(
env,
pane_id,
command_to_run,
close_replaced_pane,
context,
),
PluginCommand::OpenTerminalPaneInPlaceOfPaneId(
pane_id,
cwd,
close_replaced_pane,
) => open_terminal_pane_in_place_of_pane_id(
env,
pane_id,
cwd,
close_replaced_pane,
),
PluginCommand::OpenEditPaneInPlaceOfPaneId(
pane_id,
file_to_open,
close_replaced_pane,
context,
) => open_edit_pane_in_place_of_pane_id(
env,
pane_id,
file_to_open,
close_replaced_pane,
context,
),
},
(PermissionStatus::Denied, permission) => {
log::error!(
@@ -914,6 +983,147 @@ fn get_session_environment_variables(env: &PluginEnv) {
.non_fatal();
}
fn open_command_pane_in_new_tab(
env: &PluginEnv,
command_to_run: CommandToRun,
context: BTreeMap<String, String>,
) {
let command = command_to_run.path;
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let args = command_to_run.args;
let hold_on_close = true;
let hold_on_start = false;
let run_command_action = RunCommandAction {
command,
args,
cwd,
direction: None,
hold_on_close,
hold_on_start,
originating_plugin: Some(OriginatingPlugin::new(
env.plugin_id,
env.client_id,
context,
)),
use_terminal_title: false,
};
let initial_panes = Some(vec![CommandOrPlugin::Command(run_command_action)]);
let action = Action::NewTab {
tiled_layout: None,
floating_layouts: vec![],
swap_tiled_layouts: None,
swap_floating_layouts: None,
tab_name: None,
should_change_focus_to_new_tab: true,
cwd: None,
initial_panes,
first_pane_unblock_condition: None,
};
let error_msg = || format!("Failed to open command pane in new tab");
let result = apply_action!(action, error_msg, env);
let tab_id = result.as_ref().and_then(|r| r.affected_tab_id);
let pane_id = result
.as_ref()
.and_then(|r| r.affected_pane_id)
.map(|p| p.into());
let response =
ProtobufOpenPaneInNewTabResponse::from(OpenPaneInNewTabResponse { tab_id, pane_id });
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_command_pane_in_new_tab response"))
.non_fatal();
}
fn open_plugin_pane_in_new_tab(
env: &PluginEnv,
plugin_url: String,
configuration: BTreeMap<String, String>,
context: BTreeMap<String, String>,
) {
let run_plugin_or_alias = RunPluginOrAlias::from_url(
&plugin_url,
&Some(configuration),
None,
Some(env.plugin_cwd.clone()),
);
let run_plugin_or_alias = match run_plugin_or_alias {
Ok(r) => r,
Err(e) => {
log::error!("Failed to parse plugin url '{}': {}", plugin_url, e);
let response =
ProtobufOpenPaneInNewTabResponse::from(OpenPaneInNewTabResponse::default());
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| {
format!("failed to write open_plugin_pane_in_new_tab error response")
})
.non_fatal();
return;
},
};
let _ = context; // context is not currently used for plugin panes
let initial_panes = Some(vec![CommandOrPlugin::Plugin(run_plugin_or_alias)]);
let action = Action::NewTab {
tiled_layout: None,
floating_layouts: vec![],
swap_tiled_layouts: None,
swap_floating_layouts: None,
tab_name: None,
should_change_focus_to_new_tab: true,
cwd: None,
initial_panes,
first_pane_unblock_condition: None,
};
let error_msg = || format!("Failed to open plugin pane in new tab");
let result = apply_action!(action, error_msg, env);
let tab_id = result.as_ref().and_then(|r| r.affected_tab_id);
let pane_id = result
.as_ref()
.and_then(|r| r.affected_pane_id)
.map(|p| p.into());
let response =
ProtobufOpenPaneInNewTabResponse::from(OpenPaneInNewTabResponse { tab_id, pane_id });
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_plugin_pane_in_new_tab response"))
.non_fatal();
}
fn open_editor_pane_in_new_tab(
env: &PluginEnv,
file_to_open: FileToOpen,
context: BTreeMap<String, String>,
) {
let _ = context; // context is not currently used for editor panes in new tab
let initial_panes = Some(vec![CommandOrPlugin::File(file_to_open)]);
let action = Action::NewTab {
tiled_layout: None,
floating_layouts: vec![],
swap_tiled_layouts: None,
swap_floating_layouts: None,
tab_name: None,
should_change_focus_to_new_tab: true,
cwd: None,
initial_panes,
first_pane_unblock_condition: None,
};
let error_msg = || format!("Failed to open editor pane in new tab");
let result = apply_action!(action, error_msg, env);
let tab_id = result.as_ref().and_then(|r| r.affected_tab_id);
let pane_id = result
.as_ref()
.and_then(|r| r.affected_pane_id)
.map(|p| p.into());
let response =
ProtobufOpenPaneInNewTabResponse::from(OpenPaneInNewTabResponse { tab_id, pane_id });
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_editor_pane_in_new_tab response"))
.non_fatal();
}
fn get_focused_pane_info(env: &PluginEnv) {
use crossbeam::channel::RecvTimeoutError;
use std::time::Duration;
@@ -1082,10 +1292,10 @@ fn open_file(env: &PluginEnv, file_to_open: FileToOpen, context: BTreeMap<String
let floating = false;
let in_place = false;
let start_suppressed = false;
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let action = Action::EditFile {
payload: OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
@@ -1183,10 +1393,10 @@ fn open_file_floating(
let floating = true;
let in_place = false;
let start_suppressed = false;
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let action = Action::EditFile {
payload: OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
@@ -1221,10 +1431,10 @@ fn open_file_in_place(
let floating = false;
let in_place = true;
let start_suppressed = false;
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let action = Action::EditFile {
@@ -1258,9 +1468,9 @@ fn open_file_near_plugin(
) {
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let open_file_payload =
OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
OriginatingPlugin::new(env.plugin_id, env.client_id, context),
@@ -1301,9 +1511,9 @@ fn open_file_floating_near_plugin(
) {
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let open_file_payload =
OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
OriginatingPlugin::new(env.plugin_id, env.client_id, context),
@@ -1344,9 +1554,9 @@ fn open_file_in_place_of_plugin(
) {
let cwd = file_to_open
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let path = env.plugin_cwd.join(file_to_open.path);
let path = translate_plugin_path(env, file_to_open.path);
let open_file_payload =
OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
OriginatingPlugin::new(env.plugin_id, env.client_id, context),
@@ -1378,7 +1588,7 @@ fn open_file_in_place_of_plugin(
fn open_terminal(env: &PluginEnv, cwd: PathBuf) {
let error_msg = || format!("failed to open file in plugin {}", env.name());
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1411,7 +1621,7 @@ fn open_terminal(env: &PluginEnv, cwd: PathBuf) {
}
fn open_terminal_near_plugin(env: &PluginEnv, cwd: PathBuf) {
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1454,7 +1664,7 @@ fn open_terminal_floating(
floating_pane_coordinates: Option<FloatingPaneCoordinates>,
) {
let error_msg = || format!("failed to open file in plugin {}", env.name());
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1491,7 +1701,7 @@ fn open_terminal_floating_near_plugin(
cwd: PathBuf,
floating_pane_coordinates: Option<FloatingPaneCoordinates>,
) {
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1528,7 +1738,7 @@ fn open_terminal_floating_near_plugin(
fn open_terminal_in_place(env: &PluginEnv, cwd: PathBuf) {
let error_msg = || format!("failed to open file in plugin {}", env.name());
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1566,7 +1776,7 @@ fn open_terminal_in_place_of_plugin(
cwd: PathBuf,
close_plugin_after_replace: bool,
) {
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
@@ -1608,7 +1818,9 @@ fn open_command_pane_in_place_of_plugin(
context: BTreeMap<String, String>,
) {
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1655,6 +1867,144 @@ fn open_command_pane_in_place_of_plugin(
.non_fatal();
}
fn open_terminal_pane_in_place_of_pane_id(
env: &PluginEnv,
pane_id_to_replace: zellij_utils::data::PaneId,
cwd: FileToOpen,
close_replaced_pane: bool,
) {
let cwd_path = translate_plugin_path(env, cwd.path);
let mut default_shell = env.default_shell.clone().unwrap_or_else(|| {
TerminalAction::RunCommand(RunCommand {
command: env.path_to_default_shell.clone(),
use_terminal_title: true,
..Default::default()
})
});
default_shell.change_cwd(cwd_path);
let name = None;
let (completion_tx, completion_rx) = oneshot::channel();
let _ = env
.senders
.send_to_pty(PtyInstruction::SpawnInPlaceTerminal(
Some(default_shell),
name,
close_replaced_pane,
ClientTabIndexOrPaneId::PaneId(pane_id_to_replace.into()),
Some(NotificationEnd::new(completion_tx)),
));
let result = wait_for_action_completion(
completion_rx,
"open_terminal_pane_in_place_of_pane_id",
false,
);
let pane_id: OpenTerminalPaneInPlaceOfPaneIdResponse =
result.affected_pane_id.map(|p| p.into());
let response = ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse::from(pane_id);
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_terminal_pane_in_place_of_pane_id response"))
.non_fatal();
}
fn open_command_pane_in_place_of_pane_id(
env: &PluginEnv,
pane_id_to_replace: zellij_utils::data::PaneId,
command_to_run: CommandToRun,
close_replaced_pane: bool,
context: BTreeMap<String, String>,
) {
let command = command_to_run.path;
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
let hold_on_start = false;
let name = None;
let use_terminal_title = false;
let run_command_action = RunCommandAction {
command,
args,
cwd,
direction,
hold_on_close,
hold_on_start,
originating_plugin: Some(OriginatingPlugin::new(
env.plugin_id,
env.client_id,
context,
)),
use_terminal_title,
};
let run_cmd = TerminalAction::RunCommand(run_command_action.into());
let (completion_tx, completion_rx) = oneshot::channel();
let _ = env
.senders
.send_to_pty(PtyInstruction::SpawnInPlaceTerminal(
Some(run_cmd),
name,
close_replaced_pane,
ClientTabIndexOrPaneId::PaneId(pane_id_to_replace.into()),
Some(NotificationEnd::new(completion_tx)),
));
let result = wait_for_action_completion(
completion_rx,
"open_command_pane_in_place_of_pane_id",
false,
);
let pane_id: OpenCommandPaneInPlaceOfPaneIdResponse = result.affected_pane_id.map(|p| p.into());
let response = ProtobufOpenCommandPaneInPlaceOfPaneIdResponse::from(pane_id);
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_command_pane_in_place_of_pane_id response"))
.non_fatal();
}
fn open_edit_pane_in_place_of_pane_id(
env: &PluginEnv,
pane_id_to_replace: zellij_utils::data::PaneId,
file_to_open: FileToOpen,
close_replaced_pane: bool,
context: BTreeMap<String, String>,
) {
let cwd = file_to_open
.cwd
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let path = translate_plugin_path(env, file_to_open.path);
let open_file_payload =
OpenFilePayload::new(path, file_to_open.line_number, cwd).with_originating_plugin(
OriginatingPlugin::new(env.plugin_id, env.client_id, context),
);
let title = format!("Editing: {}", open_file_payload.path.display());
let open_file = TerminalAction::OpenFile(open_file_payload);
let (completion_tx, completion_rx) = oneshot::channel();
let pty_instr = PtyInstruction::SpawnInPlaceTerminal(
Some(open_file),
Some(title),
close_replaced_pane,
ClientTabIndexOrPaneId::PaneId(pane_id_to_replace.into()),
Some(NotificationEnd::new(completion_tx)),
);
let _ = env.senders.send_to_pty(pty_instr);
let result =
wait_for_action_completion(completion_rx, "open_edit_pane_in_place_of_pane_id", false);
let pane_id: OpenEditPaneInPlaceOfPaneIdResponse = result.affected_pane_id.map(|p| p.into());
let response = ProtobufOpenEditPaneInPlaceOfPaneIdResponse::from(pane_id);
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to write open_edit_pane_in_place_of_pane_id response"))
.non_fatal();
}
fn open_command_pane(
env: &PluginEnv,
command_to_run: CommandToRun,
@@ -1662,7 +2012,9 @@ fn open_command_pane(
) {
let error_msg = || format!("failed to open command in plugin {}", env.name());
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1709,7 +2061,9 @@ fn open_command_pane_near_plugin(
context: BTreeMap<String, String>,
) {
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1766,7 +2120,9 @@ fn open_command_pane_floating(
) {
let error_msg = || format!("failed to open command in plugin {}", env.name());
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1813,7 +2169,9 @@ fn open_command_pane_floating_near_plugin(
context: BTreeMap<String, String>,
) {
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1871,7 +2229,9 @@ fn open_command_pane_in_place(
) {
let error_msg = || format!("failed to open command in plugin {}", env.name());
let command = command_to_run.path;
let cwd = command_to_run.cwd.map(|cwd| env.plugin_cwd.join(cwd));
let cwd = command_to_run
.cwd
.map(|cwd| translate_plugin_path(env, cwd));
let args = command_to_run.args;
let direction = None;
let hold_on_close = true;
@@ -1920,7 +2280,7 @@ fn open_command_pane_background(
let command = command_to_run.path;
let cwd = command_to_run
.cwd
.map(|cwd| env.plugin_cwd.join(cwd))
.map(|cwd| translate_plugin_path(env, cwd))
.or_else(|| Some(env.plugin_cwd.clone()));
let args = command_to_run.args;
let direction = None;
@@ -2060,7 +2420,7 @@ fn run_command(
log::error!("Command cannot be empty");
} else {
let command = command_line.remove(0);
let cwd = env.plugin_cwd.join(cwd);
let cwd = translate_plugin_path(env, cwd);
let _ = env
.senders
.send_to_background_jobs(BackgroundJob::RunCommand(
@@ -4365,13 +4725,16 @@ fn replace_pane_with_existing_pane(
existing_pane: PaneId,
suppress_replaced_pane: bool,
) {
let (completion_tx, completion_rx) = oneshot::channel();
let _ = env
.senders
.send_to_screen(ScreenInstruction::ReplacePaneWithExistingPane(
pane_to_replace,
existing_pane,
suppress_replaced_pane,
Some(NotificationEnd::new(completion_tx)),
));
let _ = wait_for_action_completion(completion_rx, "replace_pane_with_existing_pane", false);
}
fn override_layout(
@@ -4490,14 +4853,18 @@ fn check_command_permission(
| PluginCommand::OpenFileNearPlugin(..)
| PluginCommand::OpenFileFloatingNearPlugin(..)
| PluginCommand::OpenFileInPlaceOfPlugin(..)
| PluginCommand::OpenFileInPlace(..) => PermissionType::OpenFiles,
| PluginCommand::OpenFileInPlace(..)
| PluginCommand::OpenEditPaneInPlaceOfPaneId(..) => PermissionType::OpenFiles,
PluginCommand::OpenTerminal(..)
| PluginCommand::OpenTerminalNearPlugin(..)
| PluginCommand::StartOrReloadPlugin(..)
| PluginCommand::OpenTerminalFloating(..)
| PluginCommand::OpenTerminalFloatingNearPlugin(..)
| PluginCommand::OpenTerminalInPlace(..)
| PluginCommand::OpenTerminalInPlaceOfPlugin(..) => PermissionType::OpenTerminalsOrPlugins,
| PluginCommand::OpenTerminalInPlaceOfPlugin(..)
| PluginCommand::OpenTerminalPaneInPlaceOfPaneId(..) => {
PermissionType::OpenTerminalsOrPlugins
},
PluginCommand::OpenCommandPane(..)
| PluginCommand::OpenCommandPaneNearPlugin(..)
| PluginCommand::OpenCommandPaneFloating(..)
@@ -4505,6 +4872,7 @@ fn check_command_permission(
| PluginCommand::OpenCommandPaneInPlace(..)
| PluginCommand::OpenCommandPaneInPlaceOfPlugin(..)
| PluginCommand::OpenCommandPaneBackground(..)
| PluginCommand::OpenCommandPaneInPlaceOfPaneId(..)
| PluginCommand::RunCommand(..)
| PluginCommand::ExecCmd(..) => PermissionType::RunCommands,
PluginCommand::WebRequest(..) => PermissionType::WebAccess,
@@ -4648,6 +5016,9 @@ fn check_command_permission(
PluginCommand::GetSessionEnvironmentVariables => {
PermissionType::ReadSessionEnvironmentVariables
},
PluginCommand::OpenCommandPaneInNewTab(..)
| PluginCommand::OpenPluginPaneInNewTab { .. }
| PluginCommand::OpenEditorPaneInNewTab(..) => PermissionType::ChangeApplicationState,
_ => return (PermissionStatus::Granted, None),
};
+33
View File
@@ -1164,6 +1164,15 @@ impl Pty {
log::warn!("More initial_panes provided than empty slots available");
break;
}
} else if let CommandOrPlugin::File(file_to_open) = initial_pane {
if !layout.replace_next_empty_slot_with_run(Run::EditFile(
file_to_open.path.clone(),
file_to_open.line_number,
file_to_open.cwd.clone(),
)) {
log::warn!("More initial_panes provided than empty slots available");
break;
}
}
// Skip CommandOrPlugin::Plugin entries (already handled by plugin thread)
}
@@ -1556,6 +1565,15 @@ impl Pty {
));
}
});
let originating_plugin = run_instruction.as_ref().and_then(|r| {
if let Run::Command(run_command) = r {
run_command.originating_plugin.clone()
} else {
None
}
});
match run_instruction {
Some(Run::Command(mut command)) => {
let starts_held = command.hold_on_start;
@@ -1563,6 +1581,21 @@ impl Pty {
let quit_cb = Box::new({
let senders = self.bus.senders.clone();
move |pane_id, exit_status, command| {
if let PaneId::Terminal(terminal_pane_id) = pane_id {
if let Some(originating_plugin) = originating_plugin.as_ref() {
let update_event = Event::CommandPaneExited(
terminal_pane_id,
exit_status,
originating_plugin.context.clone(),
);
let _ = senders.send_to_plugin(PluginInstruction::Update(vec![(
Some(originating_plugin.plugin_id),
Some(originating_plugin.client_id),
update_event,
)]));
}
}
if hold_on_close {
let _ = senders.send_to_screen(ScreenInstruction::HoldPane(
pane_id,
+18 -3
View File
@@ -640,7 +640,7 @@ pub enum ScreenInstruction {
SetMouseSelectionSupport(PaneId, bool),
InterceptKeyPresses(PluginId, ClientId),
ClearKeyPressesIntercepts(ClientId),
ReplacePaneWithExistingPane(PaneId, PaneId, bool), // bool -> suppress_replaced_pane
ReplacePaneWithExistingPane(PaneId, PaneId, bool, Option<NotificationEnd>), // bool -> suppress_replaced_pane
AddWatcherClient(ClientId, Size),
RemoveWatcherClient(ClientId),
SetFollowedClient(ClientId),
@@ -3294,6 +3294,7 @@ impl Screen {
pane_id_to_replace: PaneId,
pane_id_of_existing_pane: PaneId,
suppress_replaced_pane: bool,
_completion_tx: Option<NotificationEnd>, // ends here
) {
let Some(tab_index_of_pane_id_to_replace) = self
.tabs
@@ -3301,7 +3302,10 @@ impl Screen {
.find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_to_replace))
.map(|(_tab_index, tab)| tab.position)
else {
log::error!("Could not find tab");
log::error!(
"Could not find tab with pane_id: {:?} to replace",
pane_id_to_replace
);
return;
};
let Some(tab_index_of_existing_pane) = self
@@ -3310,7 +3314,10 @@ impl Screen {
.find(|(_tab_index, tab)| tab.has_pane_with_pid(&pane_id_of_existing_pane))
.map(|(_tab_index, tab)| tab.position)
else {
log::error!("Could not find tab");
log::error!(
"Could not find tab with pane_id: {:?} to be replaced by",
pane_id_of_existing_pane
);
return;
};
let Some(extracted_pane_from_other_tab) = self
@@ -5220,6 +5227,12 @@ pub(crate) fn screen_thread_main(
completion_tx
.as_mut()
.map(|c| c.set_affected_pane_id(PaneId::Terminal(first_terminal_pane.0)));
} else if let Some(plugin_id) =
new_plugin_ids.values().next().and_then(|v| v.first())
{
completion_tx
.as_mut()
.map(|c| c.set_affected_pane_id(PaneId::Plugin(*plugin_id)));
}
// Set the affected tab ID for plugin API return value
completion_tx
@@ -7286,10 +7299,12 @@ pub(crate) fn screen_thread_main(
old_pane_id,
new_pane_id,
suppress_replaced_pane,
completion_tx,
) => screen.replace_pane_with_existing_pane(
old_pane_id,
new_pane_id,
suppress_replaced_pane,
completion_tx,
),
ScreenInstruction::AddWatcherClient(client_id, size) => {
screen
+198 -13
View File
@@ -14,26 +14,27 @@ use zellij_utils::plugin_api::plugin_command::{
dump_layout_response, dump_session_layout_response, get_focused_pane_info_response,
get_pane_cwd_response, get_pane_running_command_response, parse_layout_response,
CreateTokenResponse, ListTokensResponse, ProtobufBreakPanesToNewTabResponse,
ProtobufBreakPanesToTabWithIndexResponse, ProtobufCurrentSessionLastSavedTimeResponse,
ProtobufDeleteLayoutResponse, ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse,
ProtobufEditLayoutResponse, ProtobufFocusOrCreateTabResponse,
ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse,
ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse, ProtobufGetPaneInfoResponse,
ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufBreakPanesToTabWithIdResponse, ProtobufBreakPanesToTabWithIndexResponse,
ProtobufCurrentSessionLastSavedTimeResponse, ProtobufDeleteLayoutResponse,
ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse, ProtobufEditLayoutResponse,
ProtobufFocusOrCreateTabResponse, ProtobufGenerateRandomNameResponse,
ProtobufGetFocusedPaneInfoResponse, ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse,
ProtobufGetPaneInfoResponse, ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetTabInfoResponse,
ProtobufNewTabResponse, ProtobufNewTabsResponse, ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse, ProtobufOpenCommandPaneFloatingResponse,
ProtobufOpenCommandPaneInPlaceOfPluginResponse, ProtobufOpenCommandPaneInPlaceResponse,
ProtobufOpenCommandPaneNearPluginResponse, ProtobufOpenCommandPaneResponse,
ProtobufOpenCommandPaneInPlaceOfPaneIdResponse, ProtobufOpenCommandPaneInPlaceOfPluginResponse,
ProtobufOpenCommandPaneInPlaceResponse, ProtobufOpenCommandPaneNearPluginResponse,
ProtobufOpenCommandPaneResponse, ProtobufOpenEditPaneInPlaceOfPaneIdResponse,
ProtobufOpenFileFloatingNearPluginResponse, ProtobufOpenFileFloatingResponse,
ProtobufOpenFileInPlaceOfPluginResponse, ProtobufOpenFileInPlaceResponse,
ProtobufOpenFileNearPluginResponse, ProtobufOpenFileResponse,
ProtobufOpenFileNearPluginResponse, ProtobufOpenFileResponse, ProtobufOpenPaneInNewTabResponse,
ProtobufOpenTerminalFloatingNearPluginResponse, ProtobufOpenTerminalFloatingResponse,
ProtobufOpenTerminalInPlaceOfPluginResponse, ProtobufOpenTerminalInPlaceResponse,
ProtobufOpenTerminalNearPluginResponse, ProtobufOpenTerminalResponse,
ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse,
ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse, RenameWebTokenResponse,
RevokeAllWebTokensResponse, RevokeTokenResponse,
ProtobufOpenTerminalNearPluginResponse, ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse,
ProtobufOpenTerminalResponse, ProtobufParseLayoutResponse, ProtobufPluginCommand,
ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse,
RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse,
};
use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion};
@@ -692,6 +693,89 @@ pub fn open_command_pane_in_place_of_plugin(
OpenCommandPaneInPlaceOfPluginResponse::try_from(response).unwrap()
}
/// Opens a command pane in place of the pane identified by `pane_id`.
/// Unlike `open_command_pane_in_place`, this targets an arbitrary pane by ID rather than the
/// focused pane, and does not change focus. If `close_replaced_pane` is false, the replaced
/// pane is suppressed and restored when the new pane closes; if true, it is permanently closed.
/// Returns the `PaneId` of the newly opened pane, or `None` if the operation failed.
pub fn open_command_pane_in_place_of_pane_id(
pane_id: PaneId,
command_to_run: CommandToRun,
close_replaced_pane: bool,
context: BTreeMap<String, String>,
) -> Option<PaneId> {
let plugin_command = PluginCommand::OpenCommandPaneInPlaceOfPaneId(
pane_id,
command_to_run,
close_replaced_pane,
context,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response = ProtobufOpenCommandPaneInPlaceOfPaneIdResponse::decode(
bytes_from_stdin().unwrap().as_slice(),
)
.unwrap();
OpenCommandPaneInPlaceOfPaneIdResponse::try_from(response).unwrap()
}
/// Opens a terminal pane in place of the pane identified by `pane_id`.
/// Unlike `open_terminal_in_place`, this targets an arbitrary pane by ID rather than the
/// focused pane, and does not change focus. If `close_replaced_pane` is false, the replaced
/// pane is suppressed and restored when the new pane closes; if true, it is permanently closed.
/// `cwd` sets the working directory for the new terminal. Returns the `PaneId` of the newly
/// opened pane, or `None` if the operation failed.
pub fn open_terminal_pane_in_place_of_pane_id<P: AsRef<Path>>(
pane_id: PaneId,
cwd: P,
close_replaced_pane: bool,
) -> Option<PaneId> {
let file_to_open = FileToOpen {
path: cwd.as_ref().to_path_buf(),
..Default::default()
};
let plugin_command =
PluginCommand::OpenTerminalPaneInPlaceOfPaneId(pane_id, file_to_open, close_replaced_pane);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response = ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse::decode(
bytes_from_stdin().unwrap().as_slice(),
)
.unwrap();
OpenTerminalPaneInPlaceOfPaneIdResponse::try_from(response).unwrap()
}
/// Opens an editor pane in place of the pane identified by `pane_id`.
/// Unlike `open_file_in_place`, this targets an arbitrary pane by ID rather than the
/// focused pane, and does not change focus. If `close_replaced_pane` is false, the replaced
/// pane is suppressed and restored when the new pane closes; if true, it is permanently closed.
/// Returns the `PaneId` of the newly opened pane, or `None` if the operation failed.
pub fn open_edit_pane_in_place_of_pane_id(
pane_id: PaneId,
file_to_open: FileToOpen,
close_replaced_pane: bool,
context: BTreeMap<String, String>,
) -> Option<PaneId> {
let plugin_command = PluginCommand::OpenEditPaneInPlaceOfPaneId(
pane_id,
file_to_open,
close_replaced_pane,
context,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufOpenEditPaneInPlaceOfPaneIdResponse::decode(bytes_from_stdin().unwrap().as_slice())
.unwrap();
OpenEditPaneInPlaceOfPaneIdResponse::try_from(response).unwrap()
}
/// Open a new hidden (background) command pane with the specified command and args (this sort of pane allows the user to control the command, re-run it and see its exit status through the Zellij UI).
pub fn open_command_pane_background(
command_to_run: CommandToRun,
@@ -871,6 +955,63 @@ where
NewTabResponse::try_from(response).unwrap()
}
/// Opens a new tab with a command pane running `command_to_run`.
/// Returns `(tab_id, pane_id)` of the created tab and pane, or `None` if unavailable.
pub fn open_command_pane_in_new_tab(
command_to_run: CommandToRun,
context: BTreeMap<String, String>,
) -> (Option<usize>, Option<PaneId>) {
let plugin_command = PluginCommand::OpenCommandPaneInNewTab(command_to_run, context);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufOpenPaneInNewTabResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
let result = OpenPaneInNewTabResponse::try_from(response).unwrap();
(result.tab_id, result.pane_id)
}
/// Opens a new tab with a plugin pane loaded from `plugin_url`.
/// `plugin_url` can be a path (`file:/path/to/plugin.wasm`) or a named alias.
/// Returns `(tab_id, pane_id)` of the created tab and pane.
pub fn open_plugin_pane_in_new_tab(
plugin_url: impl ToString,
configuration: BTreeMap<String, String>,
context: BTreeMap<String, String>,
) -> (Option<usize>, Option<PaneId>) {
let plugin_command = PluginCommand::OpenPluginPaneInNewTab {
plugin_url: plugin_url.to_string(),
configuration,
context,
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufOpenPaneInNewTabResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
let result = OpenPaneInNewTabResponse::try_from(response).unwrap();
(result.tab_id, result.pane_id)
}
/// Opens a new tab with an editor pane for `file_to_open`.
/// Returns `(tab_id, pane_id)` of the created tab and pane.
pub fn open_editor_pane_in_new_tab(
file_to_open: FileToOpen,
context: BTreeMap<String, String>,
) -> (Option<usize>, Option<PaneId>) {
let plugin_command = PluginCommand::OpenEditorPaneInNewTab(file_to_open, context);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufOpenPaneInNewTabResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
let result = OpenPaneInNewTabResponse::try_from(response).unwrap();
(result.tab_id, result.pane_id)
}
/// Change focus to the next tab or loop back to the first
pub fn go_to_next_tab() {
let plugin_command = PluginCommand::GoToNextTab;
@@ -1277,6 +1418,17 @@ where
unsafe { host_run_plugin_command() };
}
/// Changes the name (the title that appears in the UI) of the tab with the specified id.
pub fn rename_tab_with_id<S: AsRef<str>>(tab_id: u64, new_name: S)
where
S: ToString,
{
let plugin_command = PluginCommand::RenameTabWithId(tab_id, new_name.to_string());
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Switch to a session with the given name, create one if no name is given
pub fn switch_session(name: Option<&str>) {
let plugin_command = PluginCommand::SwitchSession(ConnectToSession {
@@ -2048,6 +2200,18 @@ pub fn close_tab_with_index(tab_index: usize) {
unsafe { host_run_plugin_command() };
}
/// Close the tab with the given stable ID.
///
/// Unlike `close_tab_with_index`, this function identifies the tab by its stable
/// `tab_id` rather than its display position, which may change as tabs are moved
/// or closed. The tab_id can be obtained from `TabInfo.tab_id`.
pub fn close_tab_with_id(tab_id: u64) {
let plugin_command = PluginCommand::CloseTabWithId(tab_id);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Rename the specified pane
pub fn rename_pane_with_id<S: AsRef<str>>(pane_id: PaneId, new_name: S)
where
@@ -2107,6 +2271,27 @@ pub fn break_panes_to_tab_with_index(
BreakPanesToTabWithIndexResponse::try_from(response).unwrap()
}
/// Move the pane ids to the tab with the specified id
pub fn break_panes_to_tab_with_id(
pane_ids: &[PaneId],
tab_id: usize,
should_change_focus_to_target_tab: bool,
) -> Option<usize> {
let plugin_command = PluginCommand::BreakPanesToTabWithId(
pane_ids.to_vec(),
tab_id as u64,
should_change_focus_to_target_tab,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufBreakPanesToTabWithIdResponse::decode(bytes_from_stdin().unwrap().as_slice())
.unwrap();
BreakPanesToTabWithIdResponse::try_from(response).unwrap()
}
/// Reload an already-running in this session, optionally skipping the cache
pub fn reload_plugin_with_id(plugin_id: u32) {
let plugin_command = PluginCommand::ReloadPlugin(plugin_id);
+13 -1
View File
@@ -220,8 +220,18 @@ pub struct LayoutConstraintFloatingPair {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CommandOrPluginFile {
#[prost(string, tag="1")]
pub path: ::prost::alloc::string::String,
#[prost(int32, optional, tag="2")]
pub line_number: ::core::option::Option<i32>,
#[prost(string, optional, tag="3")]
pub cwd: ::core::option::Option<::prost::alloc::string::String>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CommandOrPlugin {
#[prost(oneof="command_or_plugin::CommandOrPluginType", tags="1, 2")]
#[prost(oneof="command_or_plugin::CommandOrPluginType", tags="1, 2, 3")]
pub command_or_plugin_type: ::core::option::Option<command_or_plugin::CommandOrPluginType>,
}
/// Nested message and enum types in `CommandOrPlugin`.
@@ -233,6 +243,8 @@ pub mod command_or_plugin {
Command(super::RunCommandAction),
#[prost(message, tag="2")]
Plugin(super::RunPluginOrAlias),
#[prost(message, tag="3")]
File(super::CommandOrPluginFile),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
+101 -1
View File
@@ -3,7 +3,7 @@
pub struct PluginCommand {
#[prost(enumeration="CommandName", tag="1")]
pub name: i32,
#[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148")]
#[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154")]
pub payload: ::core::option::Option<plugin_command::Payload>,
}
/// Nested message and enum types in `PluginCommand`.
@@ -279,6 +279,18 @@ pub mod plugin_command {
BreakPanesToTabWithIdPayload(super::BreakPanesToTabWithIdPayload),
#[prost(message, tag="148")]
GetSessionEnvironmentVariablesPayload(super::GetSessionEnvironmentVariablesPayload),
#[prost(message, tag="149")]
OpenCommandPaneInNewTabPayload(super::OpenCommandPanePayload),
#[prost(message, tag="150")]
OpenPluginPaneInNewTabPayload(super::OpenPluginPaneInNewTabPayload),
#[prost(message, tag="151")]
OpenEditorPaneInNewTabPayload(super::OpenFilePayload),
#[prost(message, tag="152")]
OpenCommandPaneInPlaceOfPaneIdPayload(super::OpenCommandPaneInPlaceOfPaneIdPayload),
#[prost(message, tag="153")]
OpenTerminalPaneInPlaceOfPaneIdPayload(super::OpenTerminalPaneInPlaceOfPaneIdPayload),
#[prost(message, tag="154")]
OpenEditPaneInPlaceOfPaneIdPayload(super::OpenEditPaneInPlaceOfPaneIdPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1636,6 +1648,76 @@ pub struct GetSessionEnvironmentVariablesResponse {
#[prost(message, repeated, tag="1")]
pub env_vars: ::prost::alloc::vec::Vec<EnvVariable>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenPluginPaneInNewTabPayload {
#[prost(string, tag="1")]
pub plugin_url: ::prost::alloc::string::String,
#[prost(map="string, string", tag="2")]
pub configuration: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>,
#[prost(message, repeated, tag="3")]
pub context: ::prost::alloc::vec::Vec<ContextItem>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenPaneInNewTabResponse {
#[prost(uint64, optional, tag="1")]
pub tab_id: ::core::option::Option<u64>,
#[prost(message, optional, tag="2")]
pub pane_id: ::core::option::Option<PaneId>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenCommandPaneInPlaceOfPaneIdPayload {
#[prost(message, optional, tag="1")]
pub pane_id_to_replace: ::core::option::Option<PaneId>,
#[prost(message, optional, tag="2")]
pub command_to_run: ::core::option::Option<super::command::Command>,
#[prost(bool, tag="3")]
pub close_replaced_pane: bool,
#[prost(message, repeated, tag="4")]
pub context: ::prost::alloc::vec::Vec<ContextItem>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenTerminalPaneInPlaceOfPaneIdPayload {
#[prost(message, optional, tag="1")]
pub pane_id_to_replace: ::core::option::Option<PaneId>,
#[prost(message, optional, tag="2")]
pub cwd: ::core::option::Option<super::file::File>,
#[prost(bool, tag="3")]
pub close_replaced_pane: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenEditPaneInPlaceOfPaneIdPayload {
#[prost(message, optional, tag="1")]
pub pane_id_to_replace: ::core::option::Option<PaneId>,
#[prost(message, optional, tag="2")]
pub file_to_open: ::core::option::Option<super::file::File>,
#[prost(bool, tag="3")]
pub close_replaced_pane: bool,
#[prost(message, repeated, tag="4")]
pub context: ::prost::alloc::vec::Vec<ContextItem>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenCommandPaneInPlaceOfPaneIdResponse {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenTerminalPaneInPlaceOfPaneIdResponse {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct OpenEditPaneInPlaceOfPaneIdResponse {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum CommandName {
@@ -1816,6 +1898,12 @@ pub enum CommandName {
RenameTabWithId = 195,
BreakPanesToTabWithId = 196,
GetSessionEnvironmentVariables = 197,
OpenCommandPaneInNewTab = 198,
OpenPluginPaneInNewTab = 199,
OpenEditorPaneInNewTab = 200,
OpenCommandPaneInPlaceOfPaneId = 201,
OpenTerminalPaneInPlaceOfPaneId = 202,
OpenEditPaneInPlaceOfPaneId = 203,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2001,6 +2089,12 @@ impl CommandName {
CommandName::RenameTabWithId => "RenameTabWithId",
CommandName::BreakPanesToTabWithId => "BreakPanesToTabWithId",
CommandName::GetSessionEnvironmentVariables => "GetSessionEnvironmentVariables",
CommandName::OpenCommandPaneInNewTab => "OpenCommandPaneInNewTab",
CommandName::OpenPluginPaneInNewTab => "OpenPluginPaneInNewTab",
CommandName::OpenEditorPaneInNewTab => "OpenEditorPaneInNewTab",
CommandName::OpenCommandPaneInPlaceOfPaneId => "OpenCommandPaneInPlaceOfPaneId",
CommandName::OpenTerminalPaneInPlaceOfPaneId => "OpenTerminalPaneInPlaceOfPaneId",
CommandName::OpenEditPaneInPlaceOfPaneId => "OpenEditPaneInPlaceOfPaneId",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -2183,6 +2277,12 @@ impl CommandName {
"RenameTabWithId" => Some(Self::RenameTabWithId),
"BreakPanesToTabWithId" => Some(Self::BreakPanesToTabWithId),
"GetSessionEnvironmentVariables" => Some(Self::GetSessionEnvironmentVariables),
"OpenCommandPaneInNewTab" => Some(Self::OpenCommandPaneInNewTab),
"OpenPluginPaneInNewTab" => Some(Self::OpenPluginPaneInNewTab),
"OpenEditorPaneInNewTab" => Some(Self::OpenEditorPaneInNewTab),
"OpenCommandPaneInPlaceOfPaneId" => Some(Self::OpenCommandPaneInPlaceOfPaneId),
"OpenTerminalPaneInPlaceOfPaneId" => Some(Self::OpenTerminalPaneInPlaceOfPaneId),
"OpenEditPaneInPlaceOfPaneId" => Some(Self::OpenEditPaneInPlaceOfPaneId),
_ => None,
}
}
@@ -1492,8 +1492,18 @@ pub struct PluginAlias {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CommandOrPluginFile {
#[prost(string, tag="1")]
pub path: ::prost::alloc::string::String,
#[prost(int32, optional, tag="2")]
pub line_number: ::core::option::Option<i32>,
#[prost(string, optional, tag="3")]
pub cwd: ::core::option::Option<::prost::alloc::string::String>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CommandOrPlugin {
#[prost(oneof="command_or_plugin::CommandOrPluginType", tags="1, 2")]
#[prost(oneof="command_or_plugin::CommandOrPluginType", tags="1, 2, 3")]
pub command_or_plugin_type: ::core::option::Option<command_or_plugin::CommandOrPluginType>,
}
/// Nested message and enum types in `CommandOrPlugin`.
@@ -1505,6 +1515,8 @@ pub mod command_or_plugin {
Command(super::RunCommandAction),
#[prost(message, tag="2")]
Plugin(super::RunPluginOrAlias),
#[prost(message, tag="3")]
File(super::CommandOrPluginFile),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
+28
View File
@@ -596,6 +596,34 @@ tail -f /tmp/my-live-logfile | zellij pipe --name logs --plugin https://example.
#[clap(short('c'), long, value_parser, display_order(4))]
plugin_configuration: Option<PluginUserConfiguration>,
},
/// Send commands to the sequence plugin (sugar for: zellij pipe --plugin zellij:sequence)
#[clap(
visible_alias = "seq",
override_usage(
r#"
zellij sequence [OPTIONS] [--] <COMMANDS>
* Run a sequence of commands:
zellij sequence -- 'echo hello && echo world'
* Pipe commands from STDIN:
echo 'echo hello && echo world' | zellij sequence
* Wait for the sequence to complete before returning:
zellij sequence --blocking -- 'echo hello && echo world'
"#
)
)]
Sequence {
/// The commands to run (if blank, will listen to STDIN)
payload: Option<String>,
/// Block until the sequence finishes before exiting
#[clap(short, long, value_parser, takes_value(false), default_value("false"))]
blocking: bool,
},
}
#[derive(Debug, Subcommand, Clone, Serialize, Deserialize)]
@@ -903,10 +903,17 @@ message PluginAlias {
optional RunPlugin run_plugin = 4;
}
message CommandOrPluginFile {
string path = 1;
optional int32 line_number = 2;
optional string cwd = 3;
}
message CommandOrPlugin {
oneof command_or_plugin_type {
RunCommandAction command = 1;
RunPluginOrAlias plugin = 2;
CommandOrPluginFile file = 3;
}
}
+22 -1
View File
@@ -54,6 +54,7 @@ impl UnblockCondition {
pub enum CommandOrPlugin {
Command(RunCommandAction),
Plugin(RunPluginOrAlias),
File(FileToOpen), // open file in configured editor
}
impl CommandOrPlugin {
@@ -2622,7 +2623,7 @@ pub enum PermissionStatus {
Denied,
}
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileToOpen {
pub path: PathBuf,
pub line_number: Option<usize>,
@@ -3450,6 +3451,23 @@ pub enum PluginCommand {
GetPaneInfo(PaneId),
GetTabInfo(usize), // tab_id
GetSessionEnvironmentVariables,
OpenCommandPaneInNewTab(CommandToRun, Context),
OpenPluginPaneInNewTab {
plugin_url: String,
configuration: BTreeMap<String, String>,
context: Context,
},
OpenEditorPaneInNewTab(FileToOpen, Context),
OpenCommandPaneInPlaceOfPaneId(PaneId, CommandToRun, bool, Context), // bool = close_replaced_pane
OpenTerminalPaneInPlaceOfPaneId(PaneId, FileToOpen, bool),
OpenEditPaneInPlaceOfPaneId(PaneId, FileToOpen, bool, Context),
}
// Response type for plugin API methods that open a pane in a new tab
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenPaneInNewTabResponse {
pub tab_id: Option<usize>,
pub pane_id: Option<PaneId>,
}
// Response types for plugin API methods that create tabs
@@ -3482,3 +3500,6 @@ pub type OpenCommandPaneNearPluginResponse = Option<PaneId>;
pub type OpenCommandPaneFloatingNearPluginResponse = Option<PaneId>;
pub type OpenCommandPaneInPlaceOfPluginResponse = Option<PaneId>;
pub type OpenCommandPaneBackgroundResponse = Option<PaneId>;
pub type OpenCommandPaneInPlaceOfPaneIdResponse = Option<PaneId>;
pub type OpenTerminalPaneInPlaceOfPaneIdResponse = Option<PaneId>;
pub type OpenEditPaneInPlaceOfPaneIdResponse = Option<PaneId>;
@@ -3276,6 +3276,7 @@ impl From<crate::data::CommandOrPlugin>
{
fn from(cmd_or_plugin: crate::data::CommandOrPlugin) -> Self {
use crate::client_server_contract::client_server_contract::command_or_plugin::CommandOrPluginType;
use crate::client_server_contract::client_server_contract::CommandOrPluginFile;
match cmd_or_plugin {
crate::data::CommandOrPlugin::Command(cmd) => Self {
command_or_plugin_type: Some(CommandOrPluginType::Command(cmd.into())),
@@ -3283,6 +3284,13 @@ impl From<crate::data::CommandOrPlugin>
crate::data::CommandOrPlugin::Plugin(plugin) => Self {
command_or_plugin_type: Some(CommandOrPluginType::Plugin(plugin.into())),
},
crate::data::CommandOrPlugin::File(f) => Self {
command_or_plugin_type: Some(CommandOrPluginType::File(CommandOrPluginFile {
path: f.path.display().to_string(),
line_number: f.line_number.map(|n| n as i32),
cwd: f.cwd.map(|c| c.display().to_string()),
})),
},
}
}
}
@@ -3307,6 +3315,13 @@ impl TryFrom<crate::client_server_contract::client_server_contract::CommandOrPlu
CommandOrPluginType::Plugin(plugin) => {
Ok(crate::data::CommandOrPlugin::Plugin(plugin.try_into()?))
},
CommandOrPluginType::File(f) => Ok(crate::data::CommandOrPlugin::File(
crate::data::FileToOpen {
path: std::path::PathBuf::from(&f.path),
line_number: f.line_number.map(|n| n as usize),
cwd: f.cwd.map(std::path::PathBuf::from),
},
)),
}
}
}
+7
View File
@@ -144,10 +144,17 @@ message LayoutConstraintFloatingPair {
repeated FloatingPaneLayout layouts = 2;
}
message CommandOrPluginFile {
string path = 1;
optional int32 line_number = 2;
optional string cwd = 3;
}
message CommandOrPlugin {
oneof command_or_plugin_type {
RunCommandAction command = 1;
RunPluginOrAlias plugin = 2;
CommandOrPluginFile file = 3;
}
}
+13
View File
@@ -2724,6 +2724,13 @@ impl TryFrom<ProtobufCommandOrPlugin> for CommandOrPlugin {
Some(CommandOrPluginType::Plugin(plugin)) => {
Ok(CommandOrPlugin::Plugin(plugin.try_into()?))
},
Some(CommandOrPluginType::File(f)) => {
Ok(CommandOrPlugin::File(crate::data::FileToOpen {
path: std::path::PathBuf::from(&f.path),
line_number: f.line_number.map(|n| n as usize),
cwd: f.cwd.map(std::path::PathBuf::from),
}))
},
None => Err("CommandOrPlugin must have command_or_plugin_type"),
}
}
@@ -2733,11 +2740,17 @@ impl TryFrom<CommandOrPlugin> for ProtobufCommandOrPlugin {
type Error = &'static str;
fn try_from(internal: CommandOrPlugin) -> Result<Self, Self::Error> {
use super::generated_api::api::action::command_or_plugin::CommandOrPluginType;
use super::generated_api::api::action::CommandOrPluginFile;
let command_or_plugin_type = match internal {
CommandOrPlugin::Command(cmd) => Some(CommandOrPluginType::Command(cmd.try_into()?)),
CommandOrPlugin::Plugin(plugin) => {
Some(CommandOrPluginType::Plugin(plugin.try_into()?))
},
CommandOrPlugin::File(f) => Some(CommandOrPluginType::File(CommandOrPluginFile {
path: f.path.display().to_string(),
line_number: f.line_number.map(|n| n as i32),
cwd: f.cwd.map(|c| c.display().to_string()),
})),
};
Ok(ProtobufCommandOrPlugin {
command_or_plugin_type,
@@ -190,6 +190,12 @@ enum CommandName {
RenameTabWithId = 195;
BreakPanesToTabWithId = 196;
GetSessionEnvironmentVariables = 197;
OpenCommandPaneInNewTab = 198;
OpenPluginPaneInNewTab = 199;
OpenEditorPaneInNewTab = 200;
OpenCommandPaneInPlaceOfPaneId = 201;
OpenTerminalPaneInPlaceOfPaneId = 202;
OpenEditPaneInPlaceOfPaneId = 203;
}
message PluginCommand {
@@ -329,6 +335,12 @@ message PluginCommand {
RenameTabWithIdPayload rename_tab_with_id_payload = 146;
BreakPanesToTabWithIdPayload break_panes_to_tab_with_id_payload = 147;
GetSessionEnvironmentVariablesPayload get_session_environment_variables_payload = 148;
OpenCommandPanePayload open_command_pane_in_new_tab_payload = 149;
OpenPluginPaneInNewTabPayload open_plugin_pane_in_new_tab_payload = 150;
OpenFilePayload open_editor_pane_in_new_tab_payload = 151;
OpenCommandPaneInPlaceOfPaneIdPayload open_command_pane_in_place_of_pane_id_payload = 152;
OpenTerminalPaneInPlaceOfPaneIdPayload open_terminal_pane_in_place_of_pane_id_payload = 153;
OpenEditPaneInPlaceOfPaneIdPayload open_edit_pane_in_place_of_pane_id_payload = 154;
}
}
@@ -1131,3 +1143,46 @@ message GetSessionEnvironmentVariablesPayload {}
message GetSessionEnvironmentVariablesResponse {
repeated EnvVariable env_vars = 1;
}
message OpenPluginPaneInNewTabPayload {
string plugin_url = 1;
map<string, string> configuration = 2;
repeated ContextItem context = 3;
}
message OpenPaneInNewTabResponse {
optional uint64 tab_id = 1;
optional PaneId pane_id = 2;
}
message OpenCommandPaneInPlaceOfPaneIdPayload {
PaneId pane_id_to_replace = 1;
command.Command command_to_run = 2;
bool close_replaced_pane = 3;
repeated ContextItem context = 4;
}
message OpenTerminalPaneInPlaceOfPaneIdPayload {
PaneId pane_id_to_replace = 1;
optional file.File cwd = 2;
bool close_replaced_pane = 3;
}
message OpenEditPaneInPlaceOfPaneIdPayload {
PaneId pane_id_to_replace = 1;
file.File file_to_open = 2;
bool close_replaced_pane = 3;
repeated ContextItem context = 4;
}
message OpenCommandPaneInPlaceOfPaneIdResponse {
optional PaneId pane_id = 1;
}
message OpenTerminalPaneInPlaceOfPaneIdResponse {
optional PaneId pane_id = 1;
}
message OpenEditPaneInPlaceOfPaneIdResponse {
optional PaneId pane_id = 1;
}
+347 -6
View File
@@ -56,12 +56,16 @@ pub use super::generated_api::api::{
OpenCommandPaneFloatingNearPluginPayload,
OpenCommandPaneFloatingNearPluginResponse as ProtobufOpenCommandPaneFloatingNearPluginResponse,
OpenCommandPaneFloatingResponse as ProtobufOpenCommandPaneFloatingResponse,
OpenCommandPaneInPlaceOfPaneIdPayload,
OpenCommandPaneInPlaceOfPaneIdResponse as ProtobufOpenCommandPaneInPlaceOfPaneIdResponse,
OpenCommandPaneInPlaceOfPluginPayload,
OpenCommandPaneInPlaceOfPluginResponse as ProtobufOpenCommandPaneInPlaceOfPluginResponse,
OpenCommandPaneInPlaceResponse as ProtobufOpenCommandPaneInPlaceResponse,
OpenCommandPaneNearPluginPayload,
OpenCommandPaneNearPluginResponse as ProtobufOpenCommandPaneNearPluginResponse,
OpenCommandPanePayload, OpenCommandPaneResponse as ProtobufOpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdPayload,
OpenEditPaneInPlaceOfPaneIdResponse as ProtobufOpenEditPaneInPlaceOfPaneIdResponse,
OpenFileFloatingNearPluginPayload,
OpenFileFloatingNearPluginResponse as ProtobufOpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse as ProtobufOpenFileFloatingResponse,
@@ -69,7 +73,10 @@ pub use super::generated_api::api::{
OpenFileInPlaceOfPluginResponse as ProtobufOpenFileInPlaceOfPluginResponse,
OpenFileInPlaceResponse as ProtobufOpenFileInPlaceResponse, OpenFileNearPluginPayload,
OpenFileNearPluginResponse as ProtobufOpenFileNearPluginResponse, OpenFilePayload,
OpenFileResponse as ProtobufOpenFileResponse, OpenTerminalFloatingNearPluginPayload,
OpenFileResponse as ProtobufOpenFileResponse,
OpenPaneInNewTabResponse as ProtobufOpenPaneInNewTabResponse,
OpenPluginPaneInNewTabPayload as ProtobufOpenPluginPaneInNewTabPayload,
OpenTerminalFloatingNearPluginPayload,
OpenTerminalFloatingNearPluginResponse as ProtobufOpenTerminalFloatingNearPluginResponse,
OpenTerminalFloatingResponse as ProtobufOpenTerminalFloatingResponse,
OpenTerminalInPlaceOfPluginPayload,
@@ -77,6 +84,8 @@ pub use super::generated_api::api::{
OpenTerminalInPlaceResponse as ProtobufOpenTerminalInPlaceResponse,
OpenTerminalNearPluginPayload,
OpenTerminalNearPluginResponse as ProtobufOpenTerminalNearPluginResponse,
OpenTerminalPaneInPlaceOfPaneIdPayload,
OpenTerminalPaneInPlaceOfPaneIdResponse as ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse,
OpenTerminalResponse as ProtobufOpenTerminalResponse, OverrideLayoutPayload,
PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId,
PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType, ParseLayoutPayload,
@@ -2269,6 +2278,129 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
_ => Err("Mismatched payload for GetSessionEnvironmentVariables"),
}
},
Some(CommandName::OpenCommandPaneInNewTab) => match protobuf_plugin_command.payload {
Some(Payload::OpenCommandPaneInNewTabPayload(payload)) => {
match payload.command_to_run {
Some(command_to_run) => {
let context: BTreeMap<String, String> = payload
.context
.into_iter()
.map(|e| (e.name, e.value))
.collect();
Ok(PluginCommand::OpenCommandPaneInNewTab(
command_to_run.try_into()?,
context,
))
},
None => Err("Malformed open_command_pane_in_new_tab payload"),
}
},
_ => Err("Mismatched payload for OpenCommandPaneInNewTab"),
},
Some(CommandName::OpenPluginPaneInNewTab) => match protobuf_plugin_command.payload {
Some(Payload::OpenPluginPaneInNewTabPayload(payload)) => {
let configuration: BTreeMap<String, String> =
payload.configuration.into_iter().collect();
let context: BTreeMap<String, String> = payload
.context
.into_iter()
.map(|e| (e.name, e.value))
.collect();
Ok(PluginCommand::OpenPluginPaneInNewTab {
plugin_url: payload.plugin_url,
configuration,
context,
})
},
_ => Err("Mismatched payload for OpenPluginPaneInNewTab"),
},
Some(CommandName::OpenEditorPaneInNewTab) => match protobuf_plugin_command.payload {
Some(Payload::OpenEditorPaneInNewTabPayload(payload)) => {
match payload.file_to_open {
Some(file) => {
let context: BTreeMap<String, String> = payload
.context
.into_iter()
.map(|e| (e.name, e.value))
.collect();
Ok(PluginCommand::OpenEditorPaneInNewTab(
file.try_into()?,
context,
))
},
None => Err("Malformed open_editor_pane_in_new_tab payload"),
}
},
_ => Err("Mismatched payload for OpenEditorPaneInNewTab"),
},
Some(CommandName::OpenCommandPaneInPlaceOfPaneId) => {
match protobuf_plugin_command.payload {
Some(Payload::OpenCommandPaneInPlaceOfPaneIdPayload(payload)) => {
match (payload.pane_id_to_replace, payload.command_to_run) {
(Some(pane_id), Some(command_to_run)) => {
let context: BTreeMap<String, String> = payload
.context
.into_iter()
.map(|e| (e.name, e.value))
.collect();
Ok(PluginCommand::OpenCommandPaneInPlaceOfPaneId(
pane_id.try_into()?,
command_to_run.try_into()?,
payload.close_replaced_pane,
context,
))
},
_ => Err("Malformed open_command_pane_in_place_of_pane_id payload"),
}
},
_ => Err("Mismatched payload for OpenCommandPaneInPlaceOfPaneId"),
}
},
Some(CommandName::OpenTerminalPaneInPlaceOfPaneId) => {
match protobuf_plugin_command.payload {
Some(Payload::OpenTerminalPaneInPlaceOfPaneIdPayload(payload)) => {
match payload.pane_id_to_replace {
Some(pane_id) => {
let cwd = payload.cwd.map(|f| f.try_into()).transpose()?;
let cwd = cwd.unwrap_or_else(|| crate::data::FileToOpen {
path: std::path::PathBuf::from("."),
..Default::default()
});
Ok(PluginCommand::OpenTerminalPaneInPlaceOfPaneId(
pane_id.try_into()?,
cwd,
payload.close_replaced_pane,
))
},
None => Err("Malformed open_terminal_pane_in_place_of_pane_id payload"),
}
},
_ => Err("Mismatched payload for OpenTerminalPaneInPlaceOfPaneId"),
}
},
Some(CommandName::OpenEditPaneInPlaceOfPaneId) => {
match protobuf_plugin_command.payload {
Some(Payload::OpenEditPaneInPlaceOfPaneIdPayload(payload)) => {
match (payload.pane_id_to_replace, payload.file_to_open) {
(Some(pane_id), Some(file_to_open)) => {
let context: BTreeMap<String, String> = payload
.context
.into_iter()
.map(|e| (e.name, e.value))
.collect();
Ok(PluginCommand::OpenEditPaneInPlaceOfPaneId(
pane_id.try_into()?,
file_to_open.try_into()?,
payload.close_replaced_pane,
context,
))
},
_ => Err("Malformed open_edit_pane_in_place_of_pane_id payload"),
}
},
_ => Err("Mismatched payload for OpenEditPaneInPlaceOfPaneId"),
}
},
None => Err("Unrecognized plugin command"),
}
}
@@ -3697,6 +3829,114 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
payload: Some(Payload::GetSessionEnvironmentVariablesPayload(payload)),
})
},
PluginCommand::OpenCommandPaneInNewTab(command_to_run, context) => {
let context: Vec<_> = context
.into_iter()
.map(|(name, value)| ContextItem { name, value })
.collect();
Ok(ProtobufPluginCommand {
name: CommandName::OpenCommandPaneInNewTab as i32,
payload: Some(Payload::OpenCommandPaneInNewTabPayload(
OpenCommandPanePayload {
command_to_run: Some(command_to_run.try_into()?),
floating_pane_coordinates: None,
context,
},
)),
})
},
PluginCommand::OpenPluginPaneInNewTab {
plugin_url,
configuration,
context,
} => {
let context: Vec<_> = context
.into_iter()
.map(|(name, value)| ContextItem { name, value })
.collect();
let configuration: std::collections::HashMap<String, String> =
configuration.into_iter().collect();
Ok(ProtobufPluginCommand {
name: CommandName::OpenPluginPaneInNewTab as i32,
payload: Some(Payload::OpenPluginPaneInNewTabPayload(
ProtobufOpenPluginPaneInNewTabPayload {
plugin_url,
configuration,
context,
},
)),
})
},
PluginCommand::OpenEditorPaneInNewTab(file_to_open, context) => {
let context: Vec<_> = context
.into_iter()
.map(|(name, value)| ContextItem { name, value })
.collect();
Ok(ProtobufPluginCommand {
name: CommandName::OpenEditorPaneInNewTab as i32,
payload: Some(Payload::OpenEditorPaneInNewTabPayload(OpenFilePayload {
file_to_open: Some(file_to_open.try_into()?),
floating_pane_coordinates: None,
context,
})),
})
},
PluginCommand::OpenCommandPaneInPlaceOfPaneId(
pane_id,
command_to_run,
close_replaced_pane,
context,
) => {
let context: Vec<_> = context
.into_iter()
.map(|(name, value)| ContextItem { name, value })
.collect();
Ok(ProtobufPluginCommand {
name: CommandName::OpenCommandPaneInPlaceOfPaneId as i32,
payload: Some(Payload::OpenCommandPaneInPlaceOfPaneIdPayload(
OpenCommandPaneInPlaceOfPaneIdPayload {
pane_id_to_replace: Some(pane_id.try_into()?),
command_to_run: Some(command_to_run.try_into()?),
close_replaced_pane,
context,
},
)),
})
},
PluginCommand::OpenTerminalPaneInPlaceOfPaneId(pane_id, cwd, close_replaced_pane) => {
Ok(ProtobufPluginCommand {
name: CommandName::OpenTerminalPaneInPlaceOfPaneId as i32,
payload: Some(Payload::OpenTerminalPaneInPlaceOfPaneIdPayload(
OpenTerminalPaneInPlaceOfPaneIdPayload {
pane_id_to_replace: Some(pane_id.try_into()?),
cwd: Some(cwd.try_into()?),
close_replaced_pane,
},
)),
})
},
PluginCommand::OpenEditPaneInPlaceOfPaneId(
pane_id,
file_to_open,
close_replaced_pane,
context,
) => {
let context: Vec<_> = context
.into_iter()
.map(|(name, value)| ContextItem { name, value })
.collect();
Ok(ProtobufPluginCommand {
name: CommandName::OpenEditPaneInPlaceOfPaneId as i32,
payload: Some(Payload::OpenEditPaneInPlaceOfPaneIdPayload(
OpenEditPaneInPlaceOfPaneIdPayload {
pane_id_to_replace: Some(pane_id.try_into()?),
file_to_open: Some(file_to_open.try_into()?),
close_replaced_pane,
context,
},
)),
})
},
}
}
}
@@ -3706,12 +3946,14 @@ use crate::data::{
BreakPanesToNewTabResponse, BreakPanesToTabWithIdResponse, BreakPanesToTabWithIndexResponse,
FocusOrCreateTabResponse, NewTabResponse, NewTabsResponse, OpenCommandPaneBackgroundResponse,
OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
OpenCommandPaneInPlaceOfPluginResponse, OpenCommandPaneInPlaceResponse,
OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse, OpenFileFloatingNearPluginResponse,
OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdResponse, OpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse, OpenFileInPlaceOfPluginResponse, OpenFileInPlaceResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenTerminalFloatingNearPluginResponse,
OpenTerminalFloatingResponse, OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenPaneInNewTabResponse,
OpenTerminalFloatingNearPluginResponse, OpenTerminalFloatingResponse,
OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse,
};
impl TryFrom<ProtobufNewTabResponse> for NewTabResponse {
@@ -3827,6 +4069,16 @@ impl From<BreakPanesToTabWithIndexResponse> for ProtobufBreakPanesToTabWithIndex
}
}
impl TryFrom<ProtobufBreakPanesToTabWithIdResponse> for BreakPanesToTabWithIdResponse {
type Error = &'static str;
fn try_from(protobuf: ProtobufBreakPanesToTabWithIdResponse) -> Result<Self, Self::Error> {
match protobuf.result {
Some(break_panes_to_tab_with_id_response::Result::TabId(id)) => Ok(Some(id as usize)),
Some(break_panes_to_tab_with_id_response::Result::None(_)) | None => Ok(None),
}
}
}
impl From<BreakPanesToTabWithIdResponse> for ProtobufBreakPanesToTabWithIdResponse {
fn from(response: BreakPanesToTabWithIdResponse) -> Self {
match response {
@@ -4205,3 +4457,92 @@ impl From<OpenCommandPaneBackgroundResponse> for ProtobufOpenCommandPaneBackgrou
}
}
}
impl TryFrom<ProtobufOpenPaneInNewTabResponse> for OpenPaneInNewTabResponse {
type Error = &'static str;
fn try_from(protobuf: ProtobufOpenPaneInNewTabResponse) -> Result<Self, Self::Error> {
let tab_id = protobuf.tab_id.map(|id| id as usize);
let pane_id = match protobuf.pane_id {
Some(pane_id) => Some(pane_id.try_into()?),
None => None,
};
Ok(OpenPaneInNewTabResponse { tab_id, pane_id })
}
}
impl From<OpenPaneInNewTabResponse> for ProtobufOpenPaneInNewTabResponse {
fn from(response: OpenPaneInNewTabResponse) -> Self {
ProtobufOpenPaneInNewTabResponse {
tab_id: response.tab_id.map(|id| id as u64),
pane_id: response.pane_id.map(|p| p.try_into().unwrap()),
}
}
}
impl TryFrom<ProtobufOpenCommandPaneInPlaceOfPaneIdResponse>
for OpenCommandPaneInPlaceOfPaneIdResponse
{
type Error = &'static str;
fn try_from(
protobuf: ProtobufOpenCommandPaneInPlaceOfPaneIdResponse,
) -> Result<Self, Self::Error> {
match protobuf.pane_id {
Some(pane_id) => Ok(Some(pane_id.try_into()?)),
None => Ok(None),
}
}
}
impl From<OpenCommandPaneInPlaceOfPaneIdResponse>
for ProtobufOpenCommandPaneInPlaceOfPaneIdResponse
{
fn from(response: OpenCommandPaneInPlaceOfPaneIdResponse) -> Self {
ProtobufOpenCommandPaneInPlaceOfPaneIdResponse {
pane_id: response.map(|p| p.try_into().unwrap()),
}
}
}
impl TryFrom<ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse>
for OpenTerminalPaneInPlaceOfPaneIdResponse
{
type Error = &'static str;
fn try_from(
protobuf: ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse,
) -> Result<Self, Self::Error> {
match protobuf.pane_id {
Some(pane_id) => Ok(Some(pane_id.try_into()?)),
None => Ok(None),
}
}
}
impl From<OpenTerminalPaneInPlaceOfPaneIdResponse>
for ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse
{
fn from(response: OpenTerminalPaneInPlaceOfPaneIdResponse) -> Self {
ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse {
pane_id: response.map(|p| p.try_into().unwrap()),
}
}
}
impl TryFrom<ProtobufOpenEditPaneInPlaceOfPaneIdResponse> for OpenEditPaneInPlaceOfPaneIdResponse {
type Error = &'static str;
fn try_from(
protobuf: ProtobufOpenEditPaneInPlaceOfPaneIdResponse,
) -> Result<Self, Self::Error> {
match protobuf.pane_id {
Some(pane_id) => Ok(Some(pane_id.try_into()?)),
None => Ok(None),
}
}
}
impl From<OpenEditPaneInPlaceOfPaneIdResponse> for ProtobufOpenEditPaneInPlaceOfPaneIdResponse {
fn from(response: OpenEditPaneInPlaceOfPaneIdResponse) -> Self {
ProtobufOpenEditPaneInPlaceOfPaneIdResponse {
pane_id: response.map(|p| p.try_into().unwrap()),
}
}
}