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 = {
82 let state = self.active_state();
83 state.cursors.primary().selection_range()
84 };
85
86 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 self.active_state().buffer.to_string().unwrap_or_default()
94 }
95 }
96
97 pub fn handle_shell_command(&mut self, command: &str, replace: bool) {
101 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 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 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 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 if let Some((start, end, deleted_text)) = selection_info {
152 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 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 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
177 let buffer_len = buffer_content.len();
178
179 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 let new_buffer_len = output.len();
194 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
195
196 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, 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 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 fn create_shell_output_buffer(&mut self, command: &str, output: &str) {
225 let buffer_name = format!("*Shell: {}*", truncate_command(command, 30));
227 let buffer_id = self.new_buffer();
228
229 self.switch_buffer(buffer_id);
231
232 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 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 #[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 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 let _ = stdout().execute(EnterAlternateScreen);
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}