fresh/app/
shell_command.rs1use 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 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 pub fn execute_shell_command(&mut self, command: &str) -> Result<String, String> {
31 let input = self.get_shell_input();
33
34 let shell = detect_shell();
36
37 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 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 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 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 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 fn get_shell_input(&mut self) -> String {
80 let selection_range = { self.active_cursors().primary().selection_range() };
82
83 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 self.active_state().buffer.to_string().unwrap_or_default()
91 }
92 }
93
94 pub fn handle_shell_command(&mut self, command: &str, replace: bool) {
98 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 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 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 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 if let Some((start, end, deleted_text)) = selection_info {
148 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 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 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
173 let buffer_len = buffer_content.len();
174
175 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 let new_buffer_len = output.len();
190 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
191
192 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, 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 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 fn create_shell_output_buffer(&mut self, command: &str, output: &str) {
221 let buffer_name = format!("*Shell: {}*", truncate_command(command, 30));
223 let buffer_id = self.new_buffer();
224
225 self.switch_buffer(buffer_id);
227
228 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 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 #[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 #[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 #[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 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
288fn detect_shell() -> String {
290 if let Ok(shell) = std::env::var("SHELL") {
292 if !shell.is_empty() {
293 return shell;
294 }
295 }
296
297 #[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 "sh".to_string()
318}
319
320fn 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}