Skip to main content

fresh/app/
shell_command.rs

1//! Shell command execution on buffer/region content.
2//!
3//! This module provides functionality to:
4//! - Run shell commands with buffer or selection content as stdin
5//! - Output results to a new buffer or replace the input content
6
7use std::io::Write;
8use std::process::{Command, Stdio};
9
10use super::Editor;
11use crate::model::event::Event;
12use crate::view::prompt::PromptType;
13use rust_i18n::t;
14
15impl Editor {
16    /// Start a shell command prompt.
17    /// If `replace` is true, the output will replace the buffer/selection.
18    /// If `replace` is false, the output goes to a new buffer.
19    pub fn start_shell_command_prompt(&mut self, replace: bool) {
20        let prompt_msg = if replace {
21            t!("shell.command_replace_prompt").to_string()
22        } else {
23            t!("shell.command_prompt").to_string()
24        };
25        self.start_prompt(prompt_msg, PromptType::ShellCommand { replace });
26    }
27
28    /// Execute a shell command with the current buffer/selection as stdin.
29    /// Returns Ok(output) on success, Err(error_message) on failure.
30    pub fn execute_shell_command(&mut self, command: &str) -> Result<String, String> {
31        // Get the input text (selection or entire buffer)
32        let input = self.get_shell_input();
33
34        // Detect the shell to use
35        let shell = detect_shell();
36
37        // Execute the command
38        let mut child = Command::new(&shell)
39            .args(["-c", command])
40            .stdin(Stdio::piped())
41            .stdout(Stdio::piped())
42            .stderr(Stdio::piped())
43            .spawn()
44            .map_err(|e| format!("Failed to spawn shell: {}", e))?;
45
46        // Write input to stdin
47        if let Some(mut stdin) = child.stdin.take() {
48            stdin
49                .write_all(input.as_bytes())
50                .map_err(|e| format!("Failed to write to stdin: {}", e))?;
51        }
52
53        // Wait for the command to complete
54        let output = child
55            .wait_with_output()
56            .map_err(|e| format!("Failed to wait for command: {}", e))?;
57
58        if output.status.success() {
59            String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 in output: {}", e))
60        } else {
61            // Include stderr in error message
62            let stderr = String::from_utf8_lossy(&output.stderr);
63            let stdout = String::from_utf8_lossy(&output.stdout);
64            if !stderr.is_empty() {
65                Err(format!("Command failed: {}", stderr.trim()))
66            } else if !stdout.is_empty() {
67                // Some commands output errors to stdout
68                Err(format!("Command failed: {}", stdout.trim()))
69            } else {
70                Err(format!(
71                    "Command failed with exit code: {:?}",
72                    output.status.code()
73                ))
74            }
75        }
76    }
77
78    /// Get the input for shell command (selection or entire buffer).
79    fn get_shell_input(&mut self) -> String {
80        // First get selection range
81        let selection_range = {
82            let state = self.active_state();
83            state.cursors.primary().selection_range()
84        };
85
86        // Check if there's a selection
87        if let Some(selection) = selection_range {
88            let start = selection.start.min(selection.end);
89            let end = selection.start.max(selection.end);
90            self.active_state_mut().get_text_range(start, end)
91        } else {
92            // Use entire buffer
93            self.active_state().buffer.to_string().unwrap_or_default()
94        }
95    }
96
97    /// Handle shell command execution after prompt confirmation.
98    /// If `replace` is true, replaces the selection/buffer with output.
99    /// If `replace` is false, creates a new buffer with the output.
100    pub fn handle_shell_command(&mut self, command: &str, replace: bool) {
101        // Capture selection range first
102        let selection_range = {
103            let state = self.active_state();
104            let primary = state.cursors.primary();
105            primary.selection_range().map(|sel| {
106                let start = sel.start.min(sel.end);
107                let end = sel.start.max(sel.end);
108                (start, end)
109            })
110        };
111
112        // Now get the deleted text if there's a selection
113        let selection_info = if let Some((start, end)) = selection_range {
114            let deleted_text = self.active_state_mut().get_text_range(start, end);
115            Some((start, end, deleted_text))
116        } else {
117            None
118        };
119        let has_selection = selection_info.is_some();
120
121        match self.execute_shell_command(command) {
122            Ok(output) => {
123                if replace {
124                    self.replace_with_shell_output(&output, has_selection, selection_info);
125                } else {
126                    self.create_shell_output_buffer(command, &output);
127                }
128            }
129            Err(err) => {
130                self.set_status_message(err);
131            }
132        }
133    }
134
135    /// Replace the current selection or buffer with shell output.
136    fn replace_with_shell_output(
137        &mut self,
138        output: &str,
139        has_selection: bool,
140        selection_info: Option<(usize, usize, String)>,
141    ) {
142        let cursor_id = self.active_state().cursors.primary_id();
143
144        // Capture cursor position and selection state before replacement
145        let old_cursor_pos = self.active_state().cursors.primary().position;
146        let old_anchor = self.active_state().cursors.primary().anchor;
147        let old_sticky_column = self.active_state().cursors.primary().sticky_column;
148
149        if has_selection {
150            // Replace selection with output
151            if let Some((start, end, deleted_text)) = selection_info {
152                // Create delete and insert events
153                let delete_event = Event::Delete {
154                    range: start..end,
155                    deleted_text,
156                    cursor_id,
157                };
158                let insert_event = Event::Insert {
159                    position: start,
160                    text: output.to_string(),
161                    cursor_id,
162                };
163
164                // After insert, cursor will be at start + output.len()
165                // For selection replacement, keep cursor at end of insertion (default behavior)
166                // Apply as a batch for atomic undo
167                let batch = Event::Batch {
168                    events: vec![delete_event, insert_event],
169                    description: "Shell command replace".to_string(),
170                };
171                self.active_event_log_mut().append(batch.clone());
172                self.apply_event_to_active_buffer(&batch);
173            }
174        } else {
175            // Replace entire buffer
176            let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
177            let buffer_len = buffer_content.len();
178
179            // Delete all content and insert new
180            let delete_event = Event::Delete {
181                range: 0..buffer_len,
182                deleted_text: buffer_content,
183                cursor_id,
184            };
185            let insert_event = Event::Insert {
186                position: 0,
187                text: output.to_string(),
188                cursor_id,
189            };
190
191            // After delete+insert, cursor will be at output.len()
192            // Restore cursor to original position (or clamp to new buffer length)
193            let new_buffer_len = output.len();
194            let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
195
196            // Only add MoveCursor event if position actually changes
197            let mut events = vec![delete_event, insert_event];
198            if new_cursor_pos != new_buffer_len {
199                let move_cursor_event = Event::MoveCursor {
200                    cursor_id,
201                    old_position: new_buffer_len, // Where cursor is after insert
202                    new_position: new_cursor_pos,
203                    old_anchor: None,
204                    new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
205                    old_sticky_column: 0,
206                    new_sticky_column: old_sticky_column,
207                };
208                events.push(move_cursor_event);
209            }
210
211            // Apply as a batch for atomic undo
212            let batch = Event::Batch {
213                events,
214                description: "Shell command replace buffer".to_string(),
215            };
216            self.active_event_log_mut().append(batch.clone());
217            self.apply_event_to_active_buffer(&batch);
218        }
219
220        self.set_status_message(t!("status.shell_command_completed").to_string());
221    }
222
223    /// Create a new buffer with the shell command output.
224    fn create_shell_output_buffer(&mut self, command: &str, output: &str) {
225        // Create a new buffer for the output
226        let buffer_name = format!("*Shell: {}*", truncate_command(command, 30));
227        let buffer_id = self.new_buffer();
228
229        // Switch to the new buffer first
230        self.switch_buffer(buffer_id);
231
232        // Insert the output content
233        let cursor_id = self.active_state().cursors.primary_id();
234        let insert_event = Event::Insert {
235            position: 0,
236            text: output.to_string(),
237            cursor_id,
238        };
239        self.apply_event_to_active_buffer(&insert_event);
240
241        // Update metadata with a virtual name
242        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
243            metadata.display_name = buffer_name.clone();
244        }
245
246        self.set_status_message(t!("shell.output_in", buffer = buffer_name).to_string());
247    }
248
249    /// Execute a shell command blocking the UI.
250    /// This is used for commands like `sudo` where we might need to wait for completion.
251    #[allow(dead_code)]
252    pub(crate) fn run_shell_command_blocking(&mut self, command: &str) -> anyhow::Result<()> {
253        use crossterm::terminal::{
254            disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
255        };
256        use crossterm::ExecutableCommand;
257        use std::io::stdout;
258
259        // Suspend TUI
260        let _ = disable_raw_mode();
261        let _ = stdout().execute(LeaveAlternateScreen);
262
263        let shell = detect_shell();
264        let mut child = Command::new(&shell)
265            .args(["-c", command])
266            .spawn()
267            .map_err(|e| anyhow::anyhow!("Failed to spawn shell: {}", e))?;
268
269        let status = child
270            .wait()
271            .map_err(|e| anyhow::anyhow!("Failed to wait for command: {}", e))?;
272
273        // Resume TUI
274        let _ = stdout().execute(EnterAlternateScreen);
275        let _ = enable_raw_mode();
276
277        // Request a full hard redraw to clear any ghost text from the external command
278        self.request_full_redraw();
279
280        if status.success() {
281            Ok(())
282        } else {
283            anyhow::bail!("Command failed with exit code: {:?}", status.code())
284        }
285    }
286}
287
288/// Detect the shell to use for executing commands.
289fn detect_shell() -> String {
290    // Try SHELL environment variable first
291    if let Ok(shell) = std::env::var("SHELL") {
292        if !shell.is_empty() {
293            return shell;
294        }
295    }
296
297    // Fall back to common shells
298    #[cfg(unix)]
299    {
300        if std::path::Path::new("/bin/bash").exists() {
301            return "/bin/bash".to_string();
302        }
303        if std::path::Path::new("/bin/sh").exists() {
304            return "/bin/sh".to_string();
305        }
306    }
307
308    #[cfg(windows)]
309    {
310        if let Ok(comspec) = std::env::var("COMSPEC") {
311            return comspec;
312        }
313        return "cmd.exe".to_string();
314    }
315
316    // Last resort
317    "sh".to_string()
318}
319
320/// Truncate a command string for display purposes.
321fn truncate_command(command: &str, max_len: usize) -> String {
322    let trimmed = command.trim();
323    if trimmed.len() <= max_len {
324        trimmed.to_string()
325    } else {
326        format!("{}...", &trimmed[..max_len - 3])
327    }
328}