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