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