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 = { self.active_cursors().primary().selection_range() };
82
83        // Check if there's a selection
84        if let Some(selection) = selection_range {
85            let start = selection.start.min(selection.end);
86            let end = selection.start.max(selection.end);
87            self.active_state_mut().get_text_range(start, end)
88        } else {
89            // Use entire buffer
90            self.active_state().buffer.to_string().unwrap_or_default()
91        }
92    }
93
94    /// Handle shell command execution after prompt confirmation.
95    /// If `replace` is true, replaces the selection/buffer with output.
96    /// If `replace` is false, creates a new buffer with the output.
97    pub fn handle_shell_command(&mut self, command: &str, replace: bool) {
98        // Capture selection range first
99        let selection_range = {
100            let primary = self.active_cursors().primary();
101            primary.selection_range().map(|sel| {
102                let start = sel.start.min(sel.end);
103                let end = sel.start.max(sel.end);
104                (start, end)
105            })
106        };
107
108        // Now get the deleted text if there's a selection
109        let selection_info = if let Some((start, end)) = selection_range {
110            let deleted_text = self.active_state_mut().get_text_range(start, end);
111            Some((start, end, deleted_text))
112        } else {
113            None
114        };
115        let has_selection = selection_info.is_some();
116
117        match self.execute_shell_command(command) {
118            Ok(output) => {
119                if replace {
120                    self.replace_with_shell_output(&output, has_selection, selection_info);
121                } else {
122                    self.create_shell_output_buffer(command, &output);
123                }
124            }
125            Err(err) => {
126                self.set_status_message(err);
127            }
128        }
129    }
130
131    /// Replace the current selection or buffer with shell output.
132    fn replace_with_shell_output(
133        &mut self,
134        output: &str,
135        has_selection: bool,
136        selection_info: Option<(usize, usize, String)>,
137    ) {
138        let cursor_id = self.active_cursors().primary_id();
139
140        // Capture cursor position and selection state before replacement
141        let old_cursor_pos = self.active_cursors().primary().position;
142        let old_anchor = self.active_cursors().primary().anchor;
143        let old_sticky_column = self.active_cursors().primary().sticky_column;
144
145        if has_selection {
146            // Replace selection with output
147            if let Some((start, end, deleted_text)) = selection_info {
148                // Create delete and insert events
149                let delete_event = Event::Delete {
150                    range: start..end,
151                    deleted_text,
152                    cursor_id,
153                };
154                let insert_event = Event::Insert {
155                    position: start,
156                    text: output.to_string(),
157                    cursor_id,
158                };
159
160                // After insert, cursor will be at start + output.len()
161                // For selection replacement, keep cursor at end of insertion (default behavior)
162                // Apply as a batch for atomic undo
163                let batch = Event::Batch {
164                    events: vec![delete_event, insert_event],
165                    description: "Shell command replace".to_string(),
166                };
167                self.active_event_log_mut().append(batch.clone());
168                self.apply_event_to_active_buffer(&batch);
169            }
170        } else {
171            // Replace entire buffer
172            let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
173            let buffer_len = buffer_content.len();
174
175            // Delete all content and insert new
176            let delete_event = Event::Delete {
177                range: 0..buffer_len,
178                deleted_text: buffer_content,
179                cursor_id,
180            };
181            let insert_event = Event::Insert {
182                position: 0,
183                text: output.to_string(),
184                cursor_id,
185            };
186
187            // After delete+insert, cursor will be at output.len()
188            // Restore cursor to original position (or clamp to new buffer length)
189            let new_buffer_len = output.len();
190            let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
191
192            // Only add MoveCursor event if position actually changes
193            let mut events = vec![delete_event, insert_event];
194            if new_cursor_pos != new_buffer_len {
195                let move_cursor_event = Event::MoveCursor {
196                    cursor_id,
197                    old_position: new_buffer_len, // Where cursor is after insert
198                    new_position: new_cursor_pos,
199                    old_anchor: None,
200                    new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
201                    old_sticky_column: 0,
202                    new_sticky_column: old_sticky_column,
203                };
204                events.push(move_cursor_event);
205            }
206
207            // Apply as a batch for atomic undo
208            let batch = Event::Batch {
209                events,
210                description: "Shell command replace buffer".to_string(),
211            };
212            self.active_event_log_mut().append(batch.clone());
213            self.apply_event_to_active_buffer(&batch);
214        }
215
216        self.set_status_message(t!("status.shell_command_completed").to_string());
217    }
218
219    /// Create a new buffer with the shell command output.
220    fn create_shell_output_buffer(&mut self, command: &str, output: &str) {
221        // Create a new buffer for the output
222        let buffer_name = format!("*Shell: {}*", truncate_command(command, 30));
223        let buffer_id = self.new_buffer();
224
225        // Switch to the new buffer first
226        self.switch_buffer(buffer_id);
227
228        // Insert the output content
229        let cursor_id = self.active_cursors().primary_id();
230        let insert_event = Event::Insert {
231            position: 0,
232            text: output.to_string(),
233            cursor_id,
234        };
235        self.apply_event_to_active_buffer(&insert_event);
236
237        // Update metadata with a virtual name
238        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
239            metadata.display_name = buffer_name.clone();
240        }
241
242        self.set_status_message(t!("shell.output_in", buffer = buffer_name).to_string());
243    }
244
245    /// Execute a shell command blocking the UI.
246    /// This is used for commands like `sudo` where we might need to wait for completion.
247    #[allow(dead_code)]
248    pub(crate) fn run_shell_command_blocking(&mut self, command: &str) -> anyhow::Result<()> {
249        use crossterm::terminal::{
250            disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
251        };
252        use crossterm::ExecutableCommand;
253        use std::io::stdout;
254
255        // Suspend TUI — best-effort, nothing useful to do on failure.
256        #[allow(clippy::let_underscore_must_use)]
257        let _ = disable_raw_mode();
258        #[allow(clippy::let_underscore_must_use)]
259        let _ = stdout().execute(LeaveAlternateScreen);
260
261        let shell = detect_shell();
262        let mut child = Command::new(&shell)
263            .args(["-c", command])
264            .spawn()
265            .map_err(|e| anyhow::anyhow!("Failed to spawn shell: {}", e))?;
266
267        let status = child
268            .wait()
269            .map_err(|e| anyhow::anyhow!("Failed to wait for command: {}", e))?;
270
271        // Resume TUI — best-effort, nothing useful to do on failure.
272        #[allow(clippy::let_underscore_must_use)]
273        let _ = stdout().execute(EnterAlternateScreen);
274        #[allow(clippy::let_underscore_must_use)]
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}