feat: run shell commands asynchronously from command mode
This commit is contained in:
+2
-1
@@ -130,10 +130,11 @@ impl ChromeLine {
|
||||
Self {
|
||||
left: vec!["tabs".into()],
|
||||
center: Vec::new(),
|
||||
right: vec!["battery".into()],
|
||||
right: vec!["battery".into(), "shell".into()],
|
||||
segments: BTreeMap::from([
|
||||
("tabs".into(), default_segment("tabs")),
|
||||
("battery".into(), default_segment("battery")),
|
||||
("shell".into(), default_segment("shell")),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -137,6 +137,11 @@ pub struct StartupDiagnostic {
|
||||
max_column: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct ShellOutput {
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: StartupDiagnostic,
|
||||
}
|
||||
|
||||
pub struct Hover {
|
||||
text: String,
|
||||
line_starts: Vec<usize>,
|
||||
@@ -2223,6 +2228,8 @@ pub struct Editor {
|
||||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
battery_charge: Option<u8>,
|
||||
battery_label: String,
|
||||
shell_label: String,
|
||||
command_output: Option<ShellOutput>,
|
||||
git_blame: bool,
|
||||
gutter_style: crate::gutter::GutterStyle,
|
||||
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
|
||||
@@ -2339,6 +2346,8 @@ impl Editor {
|
||||
language_tab_spaces: BTreeMap::new(),
|
||||
battery_charge: None,
|
||||
battery_label: String::new(),
|
||||
shell_label: String::new(),
|
||||
command_output: None,
|
||||
git_blame: true,
|
||||
gutter_style: crate::gutter::GutterStyle::default(),
|
||||
line_blame: None,
|
||||
@@ -4698,6 +4707,7 @@ impl Editor {
|
||||
&& self.document_actions_available()
|
||||
&& self.startup_diagnostic.is_none()
|
||||
&& self.hover.is_none()
|
||||
&& self.command_output.is_none()
|
||||
&& self.palette.is_none()
|
||||
&& self.completion.is_none()
|
||||
&& !self.file_picker().is_some_and(|picker| picker.focused)
|
||||
@@ -6914,6 +6924,107 @@ impl Editor {
|
||||
pub fn battery_label(&self) -> &str {
|
||||
&self.battery_label
|
||||
}
|
||||
pub(crate) fn shell_label(&self) -> &str {
|
||||
&self.shell_label
|
||||
}
|
||||
|
||||
pub(crate) fn set_shell_label(&mut self, label: String) -> bool {
|
||||
if self.shell_label == label {
|
||||
return false;
|
||||
}
|
||||
self.shell_label = label;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn command_output(&self) -> Option<&ShellOutput> {
|
||||
self.command_output.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn show_command_output(
|
||||
&mut self,
|
||||
command: String,
|
||||
result: Result<crate::shell::Output, String>,
|
||||
) {
|
||||
let (status, text) = match result {
|
||||
Ok(output) => {
|
||||
let status = output.code.map_or_else(
|
||||
|| "terminated by signal".into(),
|
||||
|code| format!("exit {code}"),
|
||||
);
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if !output.stderr.is_empty() {
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str("[stderr]\n");
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
if text.is_empty() {
|
||||
text.push_str("(no output)");
|
||||
}
|
||||
(status, text)
|
||||
}
|
||||
Err(error) => ("failed".into(), error),
|
||||
};
|
||||
// Captured bytes are text, never terminal escape sequences.
|
||||
let lines: Vec<String> = normalize_newlines(&text)
|
||||
.split('\n')
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_control() && ch != '\t' {
|
||||
'�'
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let max_column = lines
|
||||
.iter()
|
||||
.map(|line| grapheme_count(line).saturating_sub(1))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let command: String = command
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch })
|
||||
.collect();
|
||||
self.command_output = Some(ShellOutput {
|
||||
title: format!("{status} · !{command}"),
|
||||
content: StartupDiagnostic {
|
||||
lines,
|
||||
top: 0,
|
||||
left: 0,
|
||||
max_column,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_command_output(&mut self, input: &Input) -> bool {
|
||||
let Some(output) = &mut self.command_output else {
|
||||
return false;
|
||||
};
|
||||
let content = &mut output.content;
|
||||
match input {
|
||||
Input::Escape | Input::Enter => self.command_output = None,
|
||||
Input::Up | Input::Ctrl('k') => content.top = content.top.saturating_sub(1),
|
||||
Input::Down | Input::Ctrl('j') => {
|
||||
content.top = content.top.saturating_add(1).min(content.lines.len() - 1);
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => content.left = content.left.saturating_sub(1),
|
||||
Input::Right | Input::Ctrl('l') => {
|
||||
content.left = content.left.saturating_add(1).min(content.max_column);
|
||||
}
|
||||
Input::Home => {
|
||||
content.top = 0;
|
||||
content.left = 0;
|
||||
}
|
||||
Input::End => content.top = content.lines.len() - 1,
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle {
|
||||
self.gutter_style
|
||||
}
|
||||
@@ -7975,6 +8086,9 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.handle_command_output(&input) {
|
||||
return None;
|
||||
}
|
||||
if self.handle_review_overview(&input) {
|
||||
return None;
|
||||
}
|
||||
@@ -9544,6 +9658,20 @@ impl Editor {
|
||||
|
||||
fn execute_command(&mut self, command: &str) -> Option<Invocation> {
|
||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||
if let Some(command) = command.strip_prefix('!') {
|
||||
self.prepare_command();
|
||||
self.active.view.command_line.clear();
|
||||
self.finish_command_mode();
|
||||
if command.trim().is_empty() {
|
||||
self.set_message("Shell command is empty");
|
||||
return None;
|
||||
}
|
||||
return Some(Invocation {
|
||||
plugin: "core".into(),
|
||||
command: "shell".into(),
|
||||
args: command.into(),
|
||||
});
|
||||
}
|
||||
let (name, args) = command
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((command, ""));
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod resolve;
|
||||
pub mod review;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod shell;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
mod terminal_colors;
|
||||
|
||||
@@ -61,6 +61,8 @@ Command: Tab/Shift+Tab completes command names and cycles forward/backward with
|
||||
Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
||||
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Shell: :!COMMAND runs asynchronously in the project directory using $SHELL (or /bin/sh)
|
||||
Pending command/spinner in top bar; output/exit status in floating window; Enter/Esc closes
|
||||
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
|
||||
:wq=save_and_quit, :wq!=force_save_and_quit
|
||||
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
|
||||
|
||||
@@ -468,6 +468,7 @@ pub struct EditorSession {
|
||||
picker_source: Option<PathBuf>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
shell: Option<(String, crate::shell::Worker, Instant)>,
|
||||
last_file_refresh: Instant,
|
||||
last_diff_refresh: Instant,
|
||||
pub(crate) diff_follow_working_copy: bool,
|
||||
@@ -595,6 +596,7 @@ impl EditorSession {
|
||||
picker_source: None,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
shell: None,
|
||||
last_file_refresh: Instant::now(),
|
||||
last_diff_refresh: Instant::now(),
|
||||
diff_follow_working_copy: config.diff_follow_working_copy,
|
||||
@@ -827,6 +829,25 @@ impl EditorSession {
|
||||
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
if invocation.plugin == "core" {
|
||||
if invocation.command == "shell" {
|
||||
if self.shell.is_some() {
|
||||
self.editor
|
||||
.set_message("A shell command is already running");
|
||||
return;
|
||||
}
|
||||
let command = invocation.args;
|
||||
match crate::shell::Worker::start(
|
||||
command.clone(),
|
||||
self.editor.project_root().to_path_buf(),
|
||||
) {
|
||||
Ok(worker) => {
|
||||
self.shell = Some((command, worker, Instant::now()));
|
||||
self.poll_shell_at(Instant::now());
|
||||
}
|
||||
Err(error) => self.editor.show_command_output(command, Err(error)),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.editor.document_actions_available()
|
||||
|| (invocation.command == "lsp.completion" && self.editor.mutation_blocked())
|
||||
{
|
||||
@@ -1750,6 +1771,34 @@ impl EditorSession {
|
||||
changed
|
||||
}
|
||||
|
||||
fn poll_shell_at(&mut self, now: Instant) -> bool {
|
||||
let Some((command, worker, started)) = &mut self.shell else {
|
||||
return false;
|
||||
};
|
||||
if let Some(result) = worker.poll() {
|
||||
let command = command.clone();
|
||||
self.shell = None;
|
||||
self.editor.set_shell_label(String::new());
|
||||
self.editor.show_command_output(command, result);
|
||||
return true;
|
||||
}
|
||||
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
|
||||
let frame =
|
||||
FRAMES[((now.saturating_duration_since(*started).as_millis() / 100) % 4) as usize];
|
||||
if self.editor.shell_label().ends_with(frame) {
|
||||
return false;
|
||||
}
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let mut graphemes = command.trim().graphemes(true);
|
||||
let mut label: String = graphemes.by_ref().take(3).collect();
|
||||
if graphemes.next().is_some() {
|
||||
label.push_str("...");
|
||||
}
|
||||
label.push(' ');
|
||||
label.push(frame);
|
||||
self.editor.set_shell_label(label)
|
||||
}
|
||||
|
||||
fn refresh_files_at(&mut self, now: Instant) -> bool {
|
||||
if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) {
|
||||
return false;
|
||||
@@ -1777,6 +1826,7 @@ impl EditorSession {
|
||||
changed |= self.poll_gutter_at(now);
|
||||
changed |= self.synchronize_documents();
|
||||
changed |= self.poll_battery();
|
||||
changed |= self.poll_shell_at(now);
|
||||
self.refresh_checkout_diffs_at(now);
|
||||
changed |= self.poll_diff();
|
||||
changed |= self.poll_resolver();
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io::{self, Read},
|
||||
os::{
|
||||
fd::OwnedFd,
|
||||
unix::{net::UnixStream, process::CommandExt},
|
||||
},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::mpsc::{self, RecvTimeoutError},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, Sender, TryRecvError},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
const OUTPUT_LIMIT: usize = 4 * 1024 * 1024;
|
||||
#[cfg(unix)]
|
||||
const TRUNCATION_NOTE: &[u8] = b"\n[vamp: output truncated after 4 MiB]\n";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Output {
|
||||
pub(crate) code: Option<i32>,
|
||||
pub(crate) stdout: Vec<u8>,
|
||||
pub(crate) stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(crate) struct Worker {
|
||||
cancel: Sender<()>,
|
||||
results: Receiver<Result<Output, String>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub(crate) fn start(command: String, root: PathBuf) -> Result<Self, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let shell = std::env::var_os("SHELL")
|
||||
.filter(|shell| !shell.is_empty())
|
||||
.unwrap_or_else(|| "/bin/sh".into());
|
||||
Self::start_with_shell(command, root, shell)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (command, root);
|
||||
Err("Shell commands require Unix process-group cancellation on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start_with_shell(command: String, root: PathBuf, shell: OsString) -> Result<Self, String> {
|
||||
let (cancel, cancelled) = mpsc::channel();
|
||||
let (send, results) = mpsc::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-shell".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(run(command, root, shell, cancelled));
|
||||
})
|
||||
.map_err(|error| format!("Cannot start shell worker: {error}"))?;
|
||||
Ok(Self {
|
||||
cancel,
|
||||
results,
|
||||
thread: Some(thread),
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<Result<Output, String>> {
|
||||
if self.finished {
|
||||
return None;
|
||||
}
|
||||
let result = match self.results.try_recv() {
|
||||
Ok(output) => output,
|
||||
Err(TryRecvError::Empty) => return None,
|
||||
Err(TryRecvError::Disconnected) => Err("Shell worker stopped unexpectedly".into()),
|
||||
};
|
||||
self.finished = true;
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cancel.send(());
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct Process(Child);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for Process {
|
||||
fn drop(&mut self) {
|
||||
unsafe extern "C" {
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
// The shell and its descendants share a private process group; killing
|
||||
// only the leader would leave children holding the output descriptors.
|
||||
unsafe { kill(-(self.0.id() as i32), 9) };
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct Capture {
|
||||
reader: UnixStream,
|
||||
bytes: Vec<u8>,
|
||||
truncated: bool,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Capture {
|
||||
fn new() -> Result<(Self, Stdio), String> {
|
||||
let (reader, writer) = UnixStream::pair()
|
||||
.map_err(|error| format!("Cannot create shell output stream: {error}"))?;
|
||||
reader
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("Cannot configure shell output stream: {error}"))?;
|
||||
Ok((
|
||||
Self {
|
||||
reader,
|
||||
bytes: Vec::new(),
|
||||
truncated: false,
|
||||
eof: false,
|
||||
},
|
||||
Stdio::from(OwnedFd::from(writer)),
|
||||
))
|
||||
}
|
||||
|
||||
fn read(&mut self) -> io::Result<bool> {
|
||||
if self.eof {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut chunk = [0; 16 * 1024];
|
||||
match self.reader.read(&mut chunk) {
|
||||
Ok(0) => {
|
||||
self.eof = true;
|
||||
Ok(false)
|
||||
}
|
||||
Ok(count) => {
|
||||
let keep = count.min(OUTPUT_LIMIT.saturating_sub(self.bytes.len()));
|
||||
self.bytes.extend_from_slice(&chunk[..keep]);
|
||||
self.truncated |= keep != count;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false),
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.truncated {
|
||||
self.bytes.extend_from_slice(TRUNCATION_NOTE);
|
||||
}
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run(
|
||||
command: String,
|
||||
root: PathBuf,
|
||||
shell: OsString,
|
||||
cancelled: Receiver<()>,
|
||||
) -> Result<Output, String> {
|
||||
if cancelled.try_recv().is_ok() {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
let (mut stdout, stdout_writer) = Capture::new()?;
|
||||
let (mut stderr, stderr_writer) = Capture::new()?;
|
||||
let child = Command::new(shell)
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(root)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(stdout_writer)
|
||||
.stderr(stderr_writer)
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.map_err(|error| format!("Cannot start shell command: {error}"))?;
|
||||
let mut process = Some(Process(child));
|
||||
let mut status = None;
|
||||
loop {
|
||||
if cancelled.try_recv().is_ok() {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
// Read each stream once per turn so a busy writer cannot starve the
|
||||
// other stream or cancellation. Overflow is still drained, not retained.
|
||||
let out = stdout
|
||||
.read()
|
||||
.map_err(|error| format!("Cannot read shell stdout: {error}"))?;
|
||||
let err = stderr
|
||||
.read()
|
||||
.map_err(|error| format!("Cannot read shell stderr: {error}"))?;
|
||||
if let Some(child) = process.as_mut() {
|
||||
status = child
|
||||
.0
|
||||
.try_wait()
|
||||
.map_err(|error| format!("Cannot wait for shell command: {error}"))?;
|
||||
if status.is_some() {
|
||||
// Background jobs must not outlive the command. Killing the
|
||||
// remaining group also closes inherited output descriptors.
|
||||
drop(process.take());
|
||||
}
|
||||
}
|
||||
if let Some(status) = status
|
||||
&& stdout.eof
|
||||
&& stderr.eof
|
||||
{
|
||||
return Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: stdout.finish(),
|
||||
stderr: stderr.finish(),
|
||||
});
|
||||
}
|
||||
if !out && !err {
|
||||
match cancelled.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "../tests/unit/shell.rs"]
|
||||
mod tests;
|
||||
+42
-6
@@ -1142,7 +1142,10 @@ impl BufferViewports {
|
||||
let Some(kind) = wheel_kind(&mouse) else {
|
||||
return false;
|
||||
};
|
||||
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
|
||||
if editor.palette().is_some()
|
||||
|| editor.startup_diagnostic().is_some()
|
||||
|| editor.command_output().is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some((buffer_id, viewport)) = self.document.geometry else {
|
||||
@@ -1781,6 +1784,7 @@ fn chrome_items<'a>(
|
||||
String::new()
|
||||
}),
|
||||
"battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()),
|
||||
"shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()),
|
||||
_ => Cow::Borrowed(editor.label(name)),
|
||||
};
|
||||
// Empty values remain hidden even when a title or decoration is configured.
|
||||
@@ -2839,6 +2843,7 @@ fn draw_overlays(
|
||||
|| editor.palette().is_some()
|
||||
|| editor.hover().is_some()
|
||||
|| editor.startup_diagnostic().is_some()
|
||||
|| editor.command_output().is_some()
|
||||
|| editor
|
||||
.file_picker()
|
||||
.is_some_and(|picker| picker.presentation == FilePresentation::Floating);
|
||||
@@ -2852,6 +2857,7 @@ fn draw_overlays(
|
||||
}
|
||||
}
|
||||
if !matches!(editor.mode(), Mode::Command | Mode::Search)
|
||||
&& editor.command_output().is_none()
|
||||
&& !editor.file_picker().is_some_and(|picker| picker.focused)
|
||||
&& let Some((_, position)) = caret
|
||||
&& !panels
|
||||
@@ -2899,6 +2905,9 @@ fn draw_overlays(
|
||||
if let Some(diagnostic) = editor.startup_diagnostic() {
|
||||
draw_startup_diagnostic(frame, diagnostic, area);
|
||||
}
|
||||
if let Some(output) = editor.command_output() {
|
||||
draw_diagnostic_panel(frame, &output.content, area, &output.title, false);
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffGeometry {
|
||||
@@ -4622,6 +4631,16 @@ fn floating_panel(area: Rect) -> Rect {
|
||||
}
|
||||
|
||||
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
|
||||
draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", true);
|
||||
}
|
||||
|
||||
fn draw_diagnostic_panel(
|
||||
frame: &mut Frame,
|
||||
diagnostic: &StartupDiagnostic,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
startup: bool,
|
||||
) {
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
@@ -4631,8 +4650,8 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(Color::Red))
|
||||
.title(" Configuration error ");
|
||||
.border_style(Style::default().fg(if startup { Color::Red } else { Color::DarkGray }))
|
||||
.title(format!(" {} ", display_text(title)));
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
@@ -4658,11 +4677,15 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), text_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
Paragraph::new(if startup {
|
||||
"Arrows scroll · Home reset · Enter/Esc continue"
|
||||
} else {
|
||||
"Arrows scroll · Home reset · Enter/Esc close"
|
||||
})
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
if text_area.width > 0 && text_area.height > 0 {
|
||||
if startup && text_area.width > 0 && text_area.height > 0 {
|
||||
frame.set_cursor_position((text_area.x, text_area.y));
|
||||
}
|
||||
}
|
||||
@@ -5546,6 +5569,19 @@ impl WheelPanels {
|
||||
let Some(kind) = wheel_kind(&mouse) else {
|
||||
return false;
|
||||
};
|
||||
if session.editor().command_output().is_some() {
|
||||
let input = match kind {
|
||||
MouseEventKind::ScrollUp => Input::Up,
|
||||
MouseEventKind::ScrollDown => Input::Down,
|
||||
MouseEventKind::ScrollLeft => Input::Left,
|
||||
MouseEventKind::ScrollRight => Input::Right,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
for _ in 0..3 {
|
||||
session.handle(input.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let horizontal = matches!(
|
||||
kind,
|
||||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
|
||||
|
||||
Reference in New Issue
Block a user