mod jobs;
mod process;
#[cfg(test)]
mod tests;
use strop_core::worker::{self, CancelReason, Outcome, Ticket};
use super::trace;
use super::{Document, Editor};
#[cfg(test)]
pub use jobs::ProcessOutput;
pub use jobs::{ShellIntent, ShellKey, ShellResult};
impl Editor {
pub(crate) fn shell_run(&mut self, cmd: &str) {
if self.remote_endpoint().is_some() {
self.message = "remote shell commands are not supported; no local fallback".into();
return;
}
let cmd = cmd.trim().to_string();
if cmd.is_empty() {
self.message = ":! needs a command".into();
return;
}
let request = match self.worker_ids.allocate() {
Ok(request) => request,
Err(error) => {
self.message = error.message;
return;
}
};
let intent = ShellIntent {
ticket: Ticket {
request,
key: ShellKey::Display {
origin: self.current(),
revision: self.buf().revision(),
focus: request,
},
},
command: cmd.clone(),
cwd: self.cwd.clone(),
original: None,
};
let class = crate::editor::privacy::classify(
&self.effect_policy(),
crate::editor::privacy::EffectFamily::Process,
crate::editor::privacy::EffectTarget::Local,
);
strop_trace::record_with(strop_trace::EventKind::JobStarted, || match class.capture {
strop_trace::ContentPolicy::Full => serde_json::json!({
"service":"shell","request":request.get(),
"command":cmd,"cwd":self.cwd.to_string_lossy(),
}),
strop_trace::ContentPolicy::Metadata => serde_json::json!({
"service":"shell","request":request.get(),
"command_bytes":cmd.len(),"cwd":self.cwd.to_string_lossy(),
}),
});
self.shell_focus = Some(request);
self.message = format!("sh: {cmd} …");
self.launch_shell(intent, None);
}
pub(crate) fn pipe_run(&mut self, start: usize, end: usize, cmd: &str) {
if self.remote_endpoint().is_some() {
self.message = "remote pipes are not supported; no local fallback".into();
return;
}
let cmd = cmd.trim().to_string();
if cmd.is_empty() {
self.message = "pipe: needs a command".into();
return;
}
let document = self.current();
let len = self.buf().len_bytes();
let raw_start = start.min(end).min(len);
let raw_end = end.max(start).min(len);
let buf = self.buf();
let s = buf.clamp_boundary(raw_start);
let e = buf.clamp_boundary(raw_end);
let original = buf.text().byte_slice(s..e).to_string();
let revision = buf.revision();
let request = match self.worker_ids.allocate() {
Ok(request) => request,
Err(error) => {
self.message = error.message;
return;
}
};
let intent = ShellIntent {
ticket: Ticket {
request,
key: ShellKey::Pipe {
document,
revision,
start: s,
end: e,
},
},
command: cmd.clone(),
cwd: self.cwd.clone(),
original: Some(original),
};
let class = crate::editor::privacy::classify(
&self.effect_policy(),
crate::editor::privacy::EffectFamily::Process,
crate::editor::privacy::EffectTarget::Local,
);
strop_trace::record_with(strop_trace::EventKind::JobStarted, || match class.capture {
strop_trace::ContentPolicy::Full => serde_json::json!({
"service":"pipe","request":request.get(),"command":cmd,
"start_byte":s,"end_byte":e,"revision":revision.get(),
}),
strop_trace::ContentPolicy::Metadata => serde_json::json!({
"service":"pipe","request":request.get(),"command_bytes":cmd.len(),
"start_byte":s,"end_byte":e,"revision":revision.get(),
}),
});
self.message = format!("| {cmd} …");
let input = intent.original.clone();
self.launch_shell(intent, input);
}
fn launch_shell(&mut self, intent: ShellIntent, input: Option<String>) {
let request = intent.ticket.request;
let command = intent.command.clone();
let cwd = intent.cwd.clone();
let ticket = intent.ticket.clone();
self.shell_requests.insert(request, intent);
let operation = match ticket.key {
ShellKey::Display { .. } => "shell.display",
ShellKey::Pipe { .. } => "shell.pipe",
};
match self.tape.request(operation, &self.shell_requests[&request]) {
Ok(false) => return,
Ok(true) => {}
Err(error) => {
self.handle_shell_result(ShellResult {
ticket,
outcome: Outcome::failed(
strop_core::worker::FailureKind::Protocol,
error.to_string(),
),
});
return;
}
}
let tx = self.shell_tx.clone();
let handle = worker::spawn(
"strop-shell",
move |outcome| {
let _ = tx.send(ShellResult { ticket, outcome });
},
move |token| {
if token.is_cancelled() {
return Outcome::Cancelled(CancelReason::OwnerClosed);
}
process::run_shell(&command, &cwd, input, &token)
},
);
self.worker_handles.insert(request, handle);
}
pub(crate) fn revoke_shell_focus(&mut self) {
self.shell_focus = None;
}
pub(crate) fn shell_document_closed(&mut self, document: strop_core::id::DocumentId) {
if let Some(focus) = self.shell_focus {
let origin_lost = self
.shell_requests
.get(&focus)
.is_some_and(|intent| {
matches!(&intent.ticket.key, ShellKey::Display { origin, .. } if *origin == document)
});
if origin_lost {
self.shell_focus = None;
}
}
let stale: Vec<_> = self
.shell_requests
.values()
.filter(|intent| {
matches!(&intent.ticket.key, ShellKey::Pipe { document: d, .. } if *d == document)
})
.map(|intent| intent.ticket.request)
.collect();
for request in stale {
self.shell_requests.remove(&request);
if let Some(handle) = self.worker_handles.remove(&request) {
handle.cancel(CancelReason::OwnerClosed);
}
}
}
pub(crate) fn handle_shell_result(&mut self, result: ShellResult) {
trace::services::shell(&result);
let request = result.ticket.request;
if !self
.shell_requests
.get(&request)
.is_some_and(|intent| intent.ticket == result.ticket)
{
trace::services::rejected("shell", "request already settled or cancelled");
return;
}
let Some(intent) = self.shell_requests.remove(&request) else {
return;
};
self.worker_handles.remove(&request);
if self.docs.is_empty() {
return;
}
match intent.ticket.key {
ShellKey::Display {
origin,
revision,
focus,
} => {
let may_focus = self.shell_focus == Some(focus)
&& self.current() == origin
&& self
.docs
.get(origin)
.is_some_and(|d| d.buf.revision() == revision);
if self.shell_focus == Some(focus) {
self.shell_focus = None;
}
let (output, status) = match result.outcome {
Outcome::Success(output) => (output, "completed (exit 0)".to_string()),
Outcome::Failed { failure, partial } => (
partial.unwrap_or_default(),
format!("failed: {}", failure.message),
),
Outcome::Cancelled(_) => return,
};
let command = strop_core::layout::printable_text(intent.command.as_str());
let cwd = strop_core::layout::printable_text(intent.cwd.to_string_lossy());
let status = strop_core::layout::printable_text(status);
let mut text = format!(
"shell — {status}\ncommand: {command}\ncwd: {cwd}\n\n--- stdout ---\n{}",
output.stdout,
);
if !output.stderr.is_empty() {
text.push_str("\n--- stderr ---\n");
text.push_str(&output.stderr);
}
let mut buffer = strop_core::Buffer::from_text(&text);
buffer.name = Some(format!("sh: {}", intent.command));
if may_focus {
let _ = self.open_temporary_output(buffer);
} else {
match self.docs.try_insert(Document::output(buffer)) {
Ok(doc) => self.mru.push(doc),
Err(_) => self.message = "document identity space exhausted".into(),
}
}
self.generation += 1;
if may_focus {
self.message = format!("sh: {status} — q closes");
}
}
ShellKey::Pipe {
document,
revision,
start,
end,
} => {
let Some(doc) = self.docs.get(document) else {
trace::services::rejected("shell", "pipe document closed");
return;
};
if doc.buf.revision() != revision {
if self.current() == document {
self.message = "pipe: text changed under the job — skipped".into();
}
trace::services::rejected("shell", "pipe revision changed");
return;
}
if doc.buf.readonly {
if self.current() == document {
self.message = "pipe: readonly buffer".into();
}
trace::services::rejected("shell", "pipe readonly buffer");
return;
}
let output = match result.outcome {
Outcome::Success(output) => output.stdout,
Outcome::Failed { failure, partial } => {
if self.current() == document {
let detail = partial
.as_ref()
.map(|partial| partial.stderr.trim())
.filter(|stderr| !stderr.is_empty())
.unwrap_or(&failure.message);
self.message = format!("pipe failed: {detail}");
}
trace::services::rejected("shell", "pipe command failed");
return;
}
Outcome::Cancelled(_) => return,
};
let Some(original) = intent.original else {
trace::services::rejected("shell", "pipe intent lost its captured text");
return;
};
let buf = &doc.buf;
if end > buf.len_bytes()
|| !buf.is_boundary(start)
|| !buf.is_boundary(end)
|| buf.text().byte_slice(start..end) != original.as_str()
{
if self.current() == document {
self.message = "pipe: text changed under the job — skipped".into();
}
trace::services::rejected("shell", "pipe range changed");
return;
}
let replacement = if original.ends_with('\n') {
output
} else {
output.strip_suffix('\n').unwrap_or(&output).to_owned()
};
let changes = super::transact::ChangeSet {
edits: vec![strop_core::Replacement::new(
strop_core::Range::charwise(start, end),
replacement,
)],
undo_open: false,
};
if let Err(error) = self.apply(document, revision, changes) {
if self.current() == document {
self.message = format!("pipe: {error}");
}
trace::services::rejected("shell", "pipe replacement rejected");
return;
}
if document == self.current() {
self.set_head(self.buf().clamp_boundary(start));
self.clamp_cursor();
self.flash(strop_core::Range::charwise(self.head(), self.head()));
self.message = "piped".into();
}
}
}
}
}