fresh/app/
shell_command.rs1use 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 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 pub fn execute_shell_command(&mut self, command: &str) -> Result<String, String> {
32 let input = self.get_shell_input();
34
35 let shell = detect_shell();
37
38 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 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 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 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 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 fn get_shell_input(&mut self) -> String {
82 let selection_range = { self.active_cursors().primary().selection_range() };
84
85 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 self.active_state().buffer.to_string().unwrap_or_default()
93 }
94 }
95
96 pub fn handle_shell_command(&mut self, command: &str, replace: bool) {
100 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 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 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 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 if let Some((start, end, deleted_text)) = selection_info {
150 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 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 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
175 let buffer_len = buffer_content.len();
176
177 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 let new_buffer_len = output.len();
192 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
193
194 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, 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 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 fn create_shell_output_buffer(&mut self, command: &str, output: &str) {
223 let buffer_name = format!("*Shell: {}*", truncate_command(command, 30));
225 let buffer_id = self.new_buffer();
226
227 self.switch_buffer(buffer_id);
229
230 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 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 #[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 #[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 #[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 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
291fn detect_shell() -> String {
293 if let Ok(shell) = std::env::var("SHELL") {
295 if !shell.is_empty() {
296 return shell;
297 }
298 }
299
300 #[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 "sh".to_string()
321}
322
323fn 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 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 let cmd = "echo 😀😀😀😀😀😀";
369 let out = truncate_command(cmd, 8);
370 assert_eq!(out, "echo ...");
371 }
372}