Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/application.rs')
| -rw-r--r-- | helix-term/src/application.rs | 1621 |
1 files changed, 588 insertions, 1033 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db649..df7ce871 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,326 +1,208 @@ -use arc_swap::{access::Map, ArcSwap}; -use futures_util::Stream; -use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Range, Selection}; -use helix_lsp::{ - lsp::{self, notification::Notification}, - util::lsp_range_to_range, - LanguageServerId, LspProgressMap, -}; -use helix_stdx::path::get_relative_path; -use helix_view::{ - align_view, - document::{DocumentOpenError, DocumentSavedEventResult}, - editor::{ConfigEvent, EditorEvent}, - graphics::Rect, - theme, - tree::Layout, - Align, Editor, +use helix_core::{ + config::{default_syntax_loader, user_syntax_loader}, + pos_at_coords, syntax, Selection, }; +use helix_dap::{self as dap, Payload, Request}; +use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; +use helix_view::{editor::Breakpoint, theme, Editor}; use serde_json::json; -use tui::backend::Backend; use crate::{ args::Args, - compositor::{Compositor, Event}, + commands::{align_view, apply_workspace_edit, fetch_stack_trace, Align}, + compositor::Compositor, config::Config, - handlers, job::Jobs, - keymap::Keymaps, - ui::{self, overlay::overlaid}, + ui::{self, overlay::overlayed}, }; -use log::{debug, error, info, warn}; +use log::{error, warn}; use std::{ - io::{stdin, IsTerminal}, - path::Path, + io::{stdin, stdout, Write}, sync::Arc, + time::{Duration, Instant}, }; -#[cfg_attr(windows, allow(unused_imports))] -use anyhow::{Context, Error}; +use anyhow::Error; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, + execute, terminal, + tty::IsTty, +}; #[cfg(not(windows))] -use {signal_hook::consts::signal, signal_hook_tokio::Signals}; +use { + signal_hook::{consts::signal, low_level}, + signal_hook_tokio::Signals, +}; #[cfg(windows)] type Signals = futures_util::stream::Empty<()>; -#[cfg(all(not(windows), not(feature = "integration")))] -use tui::backend::TerminaBackend; - -#[cfg(all(windows, not(feature = "integration")))] -use tui::backend::CrosstermBackend; - -#[cfg(feature = "integration")] -use tui::backend::TestBackend; - -#[cfg(all(not(windows), not(feature = "integration")))] -type TerminalBackend = TerminaBackend; -#[cfg(all(windows, not(feature = "integration")))] -type TerminalBackend = CrosstermBackend<std::io::Stdout>; -#[cfg(feature = "integration")] -type TerminalBackend = TestBackend; - -#[cfg(not(windows))] -type TerminalEvent = termina::Event; -#[cfg(windows)] -type TerminalEvent = crossterm::event::Event; - -type Terminal = tui::terminal::Terminal<TerminalBackend>; - pub struct Application { compositor: Compositor, - terminal: Terminal, - pub editor: Editor, + editor: Editor, - config: Arc<ArcSwap<Config>>, + // TODO: share an ArcSwap with Editor? + config: Config, + + #[allow(dead_code)] + theme_loader: Arc<theme::Loader>, + #[allow(dead_code)] + syn_loader: Arc<syntax::Loader>, signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, - - theme_mode: Option<theme::Mode>, -} - -#[cfg(feature = "integration")] -fn setup_integration_logging() { - let level = std::env::var("HELIX_LOG_LEVEL") - .map(|lvl| lvl.parse().unwrap()) - .unwrap_or(log::LevelFilter::Info); - - // Separate file config so we can include year, month and day in file logs - let _ = fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{} {} [{}] {}", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), - record.target(), - record.level(), - message - )) - }) - .level(level) - .chain(std::io::stdout()) - .apply(); } impl Application { - pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Self, Error> { - #[cfg(feature = "integration")] - setup_integration_logging(); - + pub fn new(args: Args, mut config: Config) -> Result<Self, Error> { use helix_view::editor::Action; + let mut compositor = Compositor::new()?; + let size = compositor.size(); + + let conf_dir = helix_loader::config_dir(); + + let theme_loader = + std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_loader::runtime_dir())); + + let true_color = config.editor.true_color || crate::true_color(); + let theme = config + .theme + .as_ref() + .and_then(|theme| { + theme_loader + .load(theme) + .map_err(|e| { + log::warn!("failed to load theme `{}` - {}", theme, e); + e + }) + .ok() + .filter(|theme| (true_color || theme.is_16_color())) + }) + .unwrap_or_else(|| { + if true_color { + theme_loader.default() + } else { + theme_loader.base16_default() + } + }); + + let syn_loader_conf = user_syntax_loader().unwrap_or_else(|err| { + eprintln!("Bad language config: {}", err); + eprintln!("Press <ENTER> to continue with default language config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + default_syntax_loader() + }); + let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); - let mut theme_parent_dirs = vec![helix_loader::config_dir()]; - theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned()); - let theme_loader = theme::Loader::new(&theme_parent_dirs); - - #[cfg(all(not(windows), not(feature = "integration")))] - let backend = TerminaBackend::new((&config.editor).into()) - .context("failed to create terminal backend")?; - #[cfg(all(windows, not(feature = "integration")))] - let backend = CrosstermBackend::new(std::io::stdout(), (&config.editor).into()); - - #[cfg(feature = "integration")] - let backend = TestBackend::new(120, 150); - - let theme_mode = backend.get_theme_mode(); - let terminal = Terminal::new(backend)?; - let area = terminal.size(); - let mut compositor = Compositor::new(area); - let config = Arc::new(ArcSwap::from_pointee(config)); - let handlers = handlers::setup(config.clone()); let mut editor = Editor::new( - area, - Arc::new(theme_loader), - Arc::new(ArcSwap::from_pointee(lang_loader)), - Arc::new(Map::new(Arc::clone(&config), |config: &Config| { - &config.editor - })), - handlers, - ); - Self::load_configured_theme( - &mut editor, - &config.load(), - terminal.backend().supports_true_color(), - theme_mode, + size, + theme_loader.clone(), + syn_loader.clone(), + config.editor.clone(), ); - let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { - &config.keys - })); - let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys))); + let editor_view = Box::new(ui::EditorView::new(std::mem::take(&mut config.keys))); compositor.push(editor_view); if args.load_tutor { - let path = helix_loader::runtime_file(Path::new("tutor")); - editor.open(&path, Action::VerticalSplit)?; + let path = helix_loader::runtime_dir().join("tutor.txt"); + editor.open(path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. - doc_mut!(editor).set_path(None); + doc_mut!(editor).set_path(None)?; + } else if args.edit_config { + let path = conf_dir.join("config.toml"); + editor.open(path, Action::VerticalSplit)?; } else if !args.files.is_empty() { - let mut files_it = args.files.into_iter().peekable(); - - // If the first file is a directory, skip it and open a picker - if let Some((first, _)) = files_it.next_if(|(p, _)| p.is_dir()) { - let picker = ui::file_picker(&editor, first); - compositor.push(Box::new(overlaid(picker))); - } - - // If there are any more files specified, open them - if files_it.peek().is_some() { - let mut nr_of_files = 0; - for (file, pos) in files_it { - nr_of_files += 1; + let first = &args.files[0].0; // we know it's not empty + if first.is_dir() { + std::env::set_current_dir(&first)?; + editor.new_file(Action::VerticalSplit); + let picker = ui::file_picker(".".into(), &config.editor); + compositor.push(Box::new(overlayed(picker))); + } else { + let nr_of_files = args.files.len(); + editor.open(first.to_path_buf(), Action::VerticalSplit)?; + for (file, pos) in args.files { if file.is_dir() { return Err(anyhow::anyhow!( - "expected a path to file, but found a directory: {file:?}. (to open a directory pass it as first argument)" + "expected a path to file, found a directory. (to open a directory pass it as first argument)" )); } else { - // If the user passes in either `--vsplit` or - // `--hsplit` as a command line argument, all the given - // files will be opened according to the selected - // option. If neither of those two arguments are passed - // in, just load the files normally. - let action = match args.split { - _ if nr_of_files == 1 => Action::VerticalSplit, - Some(Layout::Vertical) => Action::VerticalSplit, - Some(Layout::Horizontal) => Action::HorizontalSplit, - None => Action::Load, - }; - let old_id = editor.document_id_by_path(&file); - let doc_id = match editor.open(&file, action) { - // Ignore irregular files during application init. - Err(DocumentOpenError::IrregularFile) => { - nr_of_files -= 1; - continue; - } - Err(err) => return Err(anyhow::anyhow!(err)), - // We can't open more than 1 buffer for 1 file, in this case we already have opened this file previously - Ok(doc_id) if old_id == Some(doc_id) => { - nr_of_files -= 1; - doc_id - } - Ok(doc_id) => doc_id, - }; + let doc_id = editor.open(file, Action::Load)?; // with Action::Load all documents have the same view - // NOTE: this isn't necessarily true anymore. If - // `--vsplit` or `--hsplit` are used, the file which is - // opened last is focused on. let view_id = editor.tree.focus; - let doc = doc_mut!(editor, &doc_id); - let selection = pos - .into_iter() - .map(|coords| { - Range::point(pos_at_coords(doc.text().slice(..), coords, true)) - }) - .collect(); - doc.set_selection(view_id, selection); + let doc = editor.document_mut(doc_id).unwrap(); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view_id, pos); } } - - // if all files were invalid, replace with empty buffer - if nr_of_files == 0 { - editor.new_file(Action::VerticalSplit); - } else { - editor.set_status(format!( - "Loaded {} file{}.", - nr_of_files, - if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo - )); - // align the view to center after all files are loaded, - // does not affect views without pos since it is at the top - let (view, doc) = current!(editor); - align_view(doc, view, Align::Center); - } - } else { - editor.new_file(Action::VerticalSplit); + editor.set_status(format!("Loaded {} files.", nr_of_files)); + // align the view to center after all files are loaded, + // does not affect views without pos since it is at the top + let (view, doc) = current!(editor); + align_view(doc, view, Align::Center); } - } else if stdin().is_terminal() || cfg!(feature = "integration") { + } else if stdin().is_tty() { editor.new_file(Action::VerticalSplit); + } else if cfg!(target_os = "macos") { + // On Linux and Windows, we allow the output of a command to be piped into the new buffer. + // This doesn't currently work on macOS because of the following issue: + // https://github.com/crossterm-rs/crossterm/issues/500 + anyhow::bail!("Piping into helix-term is currently not supported on macOS"); } else { editor .new_file_from_stdin(Action::VerticalSplit) .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); } + editor.set_theme(theme); + #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] - let signals = Signals::new([ - signal::SIGTSTP, - signal::SIGCONT, - signal::SIGUSR1, - signal::SIGTERM, - signal::SIGINT, - ]) - .context("build signal handler")?; + let signals = Signals::new(&[signal::SIGTSTP, signal::SIGCONT])?; let app = Self { compositor, - terminal, editor, + config, + + theme_loader, + syn_loader, + signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), - theme_mode, }; Ok(app) } - async fn render(&mut self) { - if self.compositor.full_redraw { - self.terminal.clear().expect("Cannot clear the terminal"); - self.compositor.full_redraw = false; - } - + fn render(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - helix_event::start_frame(); - cx.editor.needs_redraw = false; - - let area = self - .terminal - .autoresize() - .expect("Unable to determine terminal size"); - - // TODO: need to recalculate view tree if necessary - - let surface = self.terminal.current_buffer_mut(); - - self.compositor.render(area, surface, &mut cx); - let (pos, kind) = self.compositor.cursor(area, &self.editor); - // reset cursor cache - self.editor.cursor_cache.reset(); - - let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); - self.terminal.draw(pos, kind).unwrap(); + self.compositor.render(&mut cx); } - pub async fn event_loop<S>(&mut self, input_stream: &mut S) - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { - self.render().await; + pub async fn event_loop(&mut self) { + let mut reader = EventStream::new(); + let mut last_render = Instant::now(); + let deadline = Duration::from_secs(1) / 60; - loop { - if !self.event_loop_until_idle(input_stream).await { - break; - } - } - } + self.render(); - pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { loop { if self.editor.should_close() { - return false; + break; } use futures_util::StreamExt; @@ -328,514 +210,413 @@ impl Application { tokio::select! { biased; + event = reader.next() => { + self.handle_terminal_events(event) + } Some(signal) = self.signals.next() => { - if !self.handle_signals(signal).await { - return false; - }; + self.handle_signals(signal).await; } - Some(event) = input_stream.next() => { - self.handle_terminal_events(event).await; + Some((id, call)) = self.editor.language_servers.incoming.next() => { + self.handle_language_server_message(call, id).await; + // limit render calls for fast language server messages + let last = self.editor.language_servers.incoming.is_empty(); + if last || last_render.elapsed() > deadline { + self.render(); + last_render = Instant::now(); + } } - Some(callback) = self.jobs.callbacks.recv() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))); - self.render().await; + Some(payload) = self.editor.debugger_events.next() => { + self.handle_debugger_message(payload).await; } - Some(msg) = self.jobs.status_messages.recv() => { - let severity = match msg.severity{ - helix_event::status::Severity::Hint => Severity::Hint, - helix_event::status::Severity::Info => Severity::Info, - helix_event::status::Severity::Warning => Severity::Warning, - helix_event::status::Severity::Error => Severity::Error, - }; - // TODO: show multiple status messages at once to avoid clobbering - self.editor.status_msg = Some((msg.message, severity)); - helix_event::request_redraw(); + Some(callback) = self.jobs.futures.next() => { + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.render(); } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); - self.render().await; + self.render(); } - event = self.editor.wait_event() => { - let _idle_handled = self.handle_editor_event(event).await; - - #[cfg(feature = "integration")] - { - if _idle_handled { - return true; - } - } + _ = &mut self.editor.idle_timer => { + // idle timeout + self.editor.clear_idle_timer(); + self.handle_idle_timeout(); } } - - // for integration tests only, reset the idle timer after every - // event to signal when test events are done processing - #[cfg(feature = "integration")] - { - self.editor.reset_idle_timer(); - } - } - } - - pub fn handle_config_events(&mut self, config_event: ConfigEvent) { - let old_editor_config = self.editor.config(); - - match config_event { - ConfigEvent::Refresh => self.refresh_config(), - - // Since only the Application can make changes to Editor's config, - // the Editor must send up a new copy of a modified config so that - // the Application can apply it. - ConfigEvent::Update(editor_config) => { - let mut app_config = (*self.config.load().clone()).clone(); - app_config.editor = *editor_config; - if let Err(err) = self.terminal.reconfigure((&app_config.editor).into()) { - self.editor.set_error(err.to_string()); - }; - self.config.store(Arc::new(app_config)); - } - } - - // Update all the relevant members in the editor after updating - // the configuration. - self.editor.refresh_config(&old_editor_config); - - // reset view position in case softwrap was enabled/disabled - let scrolloff = self.editor.config().scrolloff; - for (view, _) in self.editor.tree.views() { - let doc = doc_mut!(self.editor, &view.doc); - view.ensure_cursor_in_view(doc, scrolloff); - } - } - - fn refresh_config(&mut self) { - let mut refresh_config = || -> Result<(), Error> { - let default_config = Config::load_default() - .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; - - // Update the syntax language loader before setting the theme. Setting the theme will - // call `Loader::set_scopes` which must be done before the documents are re-parsed for - // the sake of locals highlighting. - let lang_loader = helix_core::config::user_lang_loader()?; - self.editor.syn_loader.store(Arc::new(lang_loader)); - Self::load_configured_theme( - &mut self.editor, - &default_config, - self.terminal.backend().supports_true_color(), - self.theme_mode, - ); - - // Re-parse any open documents with the new language config. - let lang_loader = self.editor.syn_loader.load(); - for document in self.editor.documents.values_mut() { - // Re-detect .editorconfig - document.detect_editor_config(); - document.detect_language(&lang_loader); - let diagnostics = Editor::doc_diagnostics( - &self.editor.language_servers, - &self.editor.diagnostics, - document, - ); - document.replace_diagnostics(diagnostics, &[], None); - } - - self.terminal.reconfigure((&default_config.editor).into())?; - // Store new config - self.config.store(Arc::new(default_config)); - Ok(()) - }; - - match refresh_config() { - Ok(_) => { - self.editor.set_status("Config refreshed"); - } - Err(err) => { - self.editor.set_error(err.to_string()); - } } } - /// Load the theme set in configuration - fn load_configured_theme( - editor: &mut Editor, - config: &Config, - terminal_true_color: bool, - mode: Option<theme::Mode>, - ) { - let true_color = terminal_true_color || config.editor.true_color || crate::true_color(); - let theme = config - .theme - .as_ref() - .and_then(|theme_config| { - let theme = theme_config.choose(mode); - editor - .theme_loader - .load(theme) - .map_err(|e| { - log::warn!("failed to load theme `{}` - {}", theme, e); - e - }) - .ok() - .filter(|theme| { - let colors_ok = true_color || theme.is_16_color(); - if !colors_ok { - log::warn!( - "loaded theme `{}` but cannot use it because true color \ - support is not enabled", - theme.name() - ); - } - colors_ok - }) - }) - .unwrap_or_else(|| editor.theme_loader.default_theme(true_color)); - editor.set_theme(theme); - } - #[cfg(windows)] // no signal handling available on windows - pub async fn handle_signals(&mut self, _signal: ()) -> bool { - true - } + pub async fn handle_signals(&mut self, _signal: ()) {} #[cfg(not(windows))] - pub async fn handle_signals(&mut self, signal: i32) -> bool { + pub async fn handle_signals(&mut self, signal: i32) { + use helix_view::graphics::Rect; match signal { signal::SIGTSTP => { + self.compositor.restore_cursor(); self.restore_term().unwrap(); - - // SAFETY: - // - // - helix must have permissions to send signals to all processes in its signal - // group, either by already having the requisite permission, or by having the - // user's UID / EUID / SUID match that of the receiving process(es). - let res = unsafe { - // A pid of 0 sends the signal to the entire process group, allowing the user to - // regain control of their terminal if the editor was spawned under another process - // (e.g. when running `git commit`). - // - // We have to send SIGSTOP (not SIGTSTP) to the entire process group, because, - // as mentioned above, the terminal will get stuck if `helix` was spawned from - // an external process and that process waits for `helix` to complete. This may - // be an issue with signal-hook-tokio, but the author of signal-hook believes it - // could be a tokio issue instead: - // https://github.com/vorner/signal-hook/issues/132 - libc::kill(0, signal::SIGSTOP) - }; - - if res != 0 { - let err = std::io::Error::last_os_error(); - eprintln!("{}", err); - let res = err.raw_os_error().unwrap_or(1); - std::process::exit(res); - } + low_level::emulate_default_handler(signal::SIGTSTP).unwrap(); } signal::SIGCONT => { - // Copy/Paste from same issue from neovim: - // https://github.com/neovim/neovim/issues/12322 - // https://github.com/neovim/neovim/pull/13084 - for retries in 1..=10 { - match self.terminal.claim() { - Ok(()) => break, - Err(err) if retries == 10 => panic!("Failed to claim terminal: {}", err), - Err(_) => continue, - } - } - + self.claim_term().await.unwrap(); // redraw the terminal - let area = self.terminal.size(); - self.compositor.resize(area); - self.terminal.clear().expect("couldn't clear terminal"); - - self.render().await; - } - signal::SIGUSR1 => { - self.refresh_config(); - self.render().await; - } - signal::SIGTERM | signal::SIGINT => { - self.restore_term().unwrap(); - return false; + let Rect { width, height, .. } = self.compositor.size(); + self.compositor.resize(width, height); + self.compositor.load_cursor(); + self.render(); } _ => unreachable!(), } - - true } - pub async fn handle_idle_timeout(&mut self) { + pub fn handle_idle_timeout(&mut self) { + use crate::compositor::EventResult; + let editor_view = self + .compositor + .find::<ui::EditorView>() + .expect("expected at least one EditorView"); + let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); - if should_render || self.editor.needs_redraw { - self.render().await; + if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { + self.render(); } } - pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { - let doc_save_event = match doc_save_event { - Ok(event) => event, - Err(err) => { - self.editor.set_error(err.to_string()); - return; - } - }; - - let doc = match self.editor.document_mut(doc_save_event.doc_id) { - None => { - warn!( - "received document saved event for non-existent doc id: {}", - doc_save_event.doc_id - ); - - return; - } - Some(doc) => doc, + pub fn handle_terminal_events(&mut self, event: Option<Result<Event, crossterm::ErrorKind>>) { + let mut cx = crate::compositor::Context { + editor: &mut self.editor, + jobs: &mut self.jobs, + scroll: None, }; + // Handle key events + let should_redraw = match event { + Some(Ok(Event::Resize(width, height))) => { + self.compositor.resize(width, height); - debug!( - "document {:?} saved with revision {}", - doc.path(), - doc_save_event.revision - ); - - doc.set_last_saved_revision(doc_save_event.revision, doc_save_event.save_time); - - let lines = doc_save_event.text.len_lines(); - let size = doc_save_event.text.len_bytes(); - - enum Size { - Bytes(u16), - HumanReadable(f32, &'static str), - } - - impl std::fmt::Display for Size { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bytes(bytes) => write!(f, "{bytes}B"), - Self::HumanReadable(size, suffix) => write!(f, "{size:.1}{suffix}"), - } - } - } - - let size = if size < 1024 { - Size::Bytes(size as u16) - } else { - const SUFFIX: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; - let mut size = size as f32; - let mut i = 0; - while i < SUFFIX.len() - 1 && size >= 1024.0 { - size /= 1024.0; - i += 1; + self.compositor + .handle_event(Event::Resize(width, height), &mut cx) } - Size::HumanReadable(size, SUFFIX[i]) + Some(Ok(event)) => self.compositor.handle_event(event, &mut cx), + Some(Err(x)) => panic!("{}", x), + None => panic!(), }; - self.editor - .set_doc_path(doc_save_event.doc_id, &doc_save_event.path); - // TODO: fix being overwritten by lsp - self.editor.set_status(format!( - "'{}' written, {lines}L {size}", - get_relative_path(&doc_save_event.path).to_string_lossy(), - )); - } - - #[inline(always)] - pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool { - log::debug!("received editor event: {:?}", event); - - match event { - EditorEvent::DocumentSaved(event) => { - self.handle_document_write(event); - self.render().await; - } - EditorEvent::ConfigEvent(event) => { - self.handle_config_events(event); - self.render().await; - } - EditorEvent::LanguageServerMessage((id, call)) => { - self.handle_language_server_message(call, id).await; - // limit render calls for fast language server messages - helix_event::request_redraw(); - } - EditorEvent::DebuggerEvent((id, payload)) => { - let needs_render = self.editor.handle_debugger_message(id, payload).await; - if needs_render { - self.render().await; - } - } - EditorEvent::Redraw => { - self.render().await; - } - EditorEvent::IdleTimer => { - self.editor.clear_idle_timer(); - self.handle_idle_timeout().await; - - #[cfg(feature = "integration")] - { - return true; - } - } + if should_redraw && !self.editor.should_close() { + self.render(); } - - false } - pub async fn handle_terminal_events(&mut self, event: std::io::Result<TerminalEvent>) { - #[cfg(not(windows))] - use termina::escape::csi; + pub async fn handle_debugger_message(&mut self, payload: helix_dap::Payload) { + use crate::commands::dap::{breakpoints_changed, select_thread_id}; + use dap::requests::RunInTerminal; + use helix_dap::{events, Event}; - let mut cx = crate::compositor::Context { - editor: &mut self.editor, - jobs: &mut self.jobs, - scroll: None, + let debugger = match self.editor.debugger.as_mut() { + Some(debugger) => debugger, + None => return, }; - // Handle key events - let should_redraw = match event.unwrap() { - #[cfg(not(windows))] - termina::Event::WindowResized(termina::WindowSize { rows, cols, .. }) => { - self.terminal - .resize(Rect::new(0, 0, cols, rows)) - .expect("Unable to resize terminal"); - - let area = self.terminal.size(); + match payload { + Payload::Event(ev) => match *ev { + Event::Stopped(events::Stopped { + thread_id, + description, + text, + reason, + all_threads_stopped, + .. + }) => { + let all_threads_stopped = all_threads_stopped.unwrap_or_default(); + + if all_threads_stopped { + if let Ok(response) = debugger.request::<dap::requests::Threads>(()).await { + for thread in response.threads { + fetch_stack_trace(debugger, thread.id).await; + } + select_thread_id( + &mut self.editor, + thread_id.unwrap_or_default(), + false, + ) + .await; + } + } else if let Some(thread_id) = thread_id { + debugger.thread_states.insert(thread_id, reason.clone()); // TODO: dap uses "type" || "reason" here - self.compositor.resize(area); + // whichever thread stops is made "current" (if no previously selected thread). + select_thread_id(&mut self.editor, thread_id, false).await; + } - self.compositor - .handle_event(&Event::Resize(cols, rows), &mut cx) - } - #[cfg(not(windows))] - // Ignore keyboard release events. - termina::Event::Key(termina::event::KeyEvent { - kind: termina::event::KeyEventKind::Release, - .. - }) => false, - #[cfg(not(windows))] - termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(mode))) => { - Self::load_configured_theme( - &mut self.editor, - &self.config.load(), - self.terminal.backend().supports_true_color(), - Some(mode.into()), - ); - true - } - #[cfg(windows)] - TerminalEvent::Resize(width, height) => { - self.terminal - .resize(Rect::new(0, 0, width, height)) - .expect("Unable to resize terminal"); + let scope = match thread_id { + Some(id) => format!("Thread {}", id), + None => "Target".to_owned(), + }; - let area = self.terminal.size(); + let mut status = format!("{} stopped because of {}", scope, reason); + if let Some(desc) = description { + status.push_str(&format!(" {}", desc)); + } + if let Some(text) = text { + status.push_str(&format!(" {}", text)); + } + if all_threads_stopped { + status.push_str(" (all threads stopped)"); + } - self.compositor.resize(area); + self.editor.set_status(status); + } + Event::Continued(events::Continued { thread_id, .. }) => { + debugger + .thread_states + .insert(thread_id, "running".to_owned()); + if debugger.thread_id == Some(thread_id) { + debugger.resume_application(); + } + } + Event::Thread(_) => { + // TODO: update thread_states, make threads request + } + Event::Breakpoint(events::Breakpoint { reason, breakpoint }) => { + match &reason[..] { + "new" => { + if let Some(source) = breakpoint.source { + self.editor + .breakpoints + .entry(source.path.unwrap()) // TODO: no unwraps + .or_default() + .push(Breakpoint { + id: breakpoint.id, + verified: breakpoint.verified, + message: breakpoint.message, + line: breakpoint.line.unwrap().saturating_sub(1), // TODO: no unwrap + column: breakpoint.column, + ..Default::default() + }); + } + } + "changed" => { + for breakpoints in self.editor.breakpoints.values_mut() { + if let Some(i) = + breakpoints.iter().position(|b| b.id == breakpoint.id) + { + breakpoints[i].verified = breakpoint.verified; + breakpoints[i].message = breakpoint.message.clone(); + breakpoints[i].line = + breakpoint.line.unwrap().saturating_sub(1); // TODO: no unwrap + breakpoints[i].column = breakpoint.column; + } + } + } + "removed" => { + for breakpoints in self.editor.breakpoints.values_mut() { + if let Some(i) = + breakpoints.iter().position(|b| b.id == breakpoint.id) + { + breakpoints.remove(i); + } + } + } + reason => { + warn!("Unknown breakpoint event: {}", reason); + } + } + } + Event::Output(events::Output { + category, output, .. + }) => { + let prefix = match category { + Some(category) => { + if &category == "telemetry" { + return; + } + format!("Debug ({}):", category) + } + None => "Debug:".to_owned(), + }; - self.compositor - .handle_event(&Event::Resize(width, height), &mut cx) - } - #[cfg(windows)] - // Ignore keyboard release events. - crossterm::event::Event::Key(crossterm::event::KeyEvent { - kind: crossterm::event::KeyEventKind::Release, - .. - }) => false, - event => self.compositor.handle_event(&event.into(), &mut cx), - }; + log::info!("{}", output); + self.editor.set_status(format!("{} {}", prefix, output)); + } + Event::Initialized => { + // send existing breakpoints + for (path, breakpoints) in &mut self.editor.breakpoints { + // TODO: call futures in parallel, await all + let _ = breakpoints_changed(debugger, path.clone(), breakpoints); + } + // TODO: fetch breakpoints (in case we're attaching) - if should_redraw && !self.editor.should_close() { - self.render().await; + if debugger.configuration_done().await.is_ok() { + self.editor.set_status("Debugged application started"); + }; // TODO: do we need to handle error? + } + ev => { + log::warn!("Unhandled event {:?}", ev); + return; // return early to skip render + } + }, + Payload::Response(_) => unreachable!(), + Payload::Request(request) => match request.command.as_str() { + RunInTerminal::COMMAND => { + let arguments: dap::requests::RunInTerminalArguments = + serde_json::from_value(request.arguments.unwrap_or_default()).unwrap(); + // TODO: no unwrap + + let process = std::process::Command::new("tmux") + .arg("split-window") + .arg(arguments.args.join(" ")) + .spawn() + .unwrap(); + + let _ = debugger + .reply( + request.seq, + dap::requests::RunInTerminal::COMMAND, + serde_json::to_value(dap::requests::RunInTerminalResponse { + process_id: Some(process.id()), + shell_process_id: None, + }) + .map_err(|e| e.into()), + ) + .await; + } + _ => log::error!("DAP reverse request not implemented: {:?}", request), + }, } + self.render(); } pub async fn handle_language_server_message( &mut self, call: helix_lsp::Call, - server_id: LanguageServerId, + server_id: usize, ) { use helix_lsp::{Call, MethodCall, Notification}; - macro_rules! language_server { - () => { - match self.editor.language_server_by_id(server_id) { - Some(language_server) => language_server, - None => { - warn!("can't find language server with id `{}`", server_id); - return; - } - } - }; - } - match call { Call::Notification(helix_lsp::jsonrpc::Notification { method, params, .. }) => { let notification = match Notification::parse(&method, params) { - Ok(notification) => notification, - Err(helix_lsp::Error::Unhandled) => { - info!("Ignoring Unhandled notification from Language Server"); - return; - } - Err(err) => { - error!( - "Ignoring unknown notification from Language Server: {}", - err - ); - return; - } + Some(notification) => notification, + None => return, }; match notification { Notification::Initialized => { - let language_server = language_server!(); + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; // Trigger a workspace/didChangeConfiguration notification after initialization. // This might not be required by the spec but Neovim does this as well, so it's // probably a good idea for compatibility. if let Some(config) = language_server.config() { - language_server.did_change_configuration(config.clone()); + tokio::spawn(language_server.did_change_configuration(config.clone())); } - helix_event::dispatch(helix_view::events::LanguageServerInitialized { - editor: &mut self.editor, - server_id, + let docs = self.editor.documents().filter(|doc| { + doc.language_server().map(|server| server.id()) == Some(server_id) }); + + // trigger textDocument/didOpen for docs that are already open + for doc in docs { + let language_id = + doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); + + tokio::spawn(language_server.text_document_did_open( + doc.url().unwrap(), + doc.version(), + doc.text(), + language_id, + )); + } } Notification::PublishDiagnostics(params) => { - let uri = match helix_core::Uri::try_from(params.uri) { - Ok(uri) => uri, - Err(err) => { - log::error!("{err}"); - return; - } - }; - let language_server = language_server!(); - if !language_server.is_initialized() { - log::error!("Discarding publishDiagnostic notification sent by an uninitialized server: {}", language_server.name()); - return; + let path = params.uri.to_file_path().unwrap(); + let doc = self.editor.document_by_path_mut(&path); + + if let Some(doc) = doc { + let lang_conf = doc.language_config(); + let text = doc.text(); + + let diagnostics = params + .diagnostics + .into_iter() + .filter_map(|diagnostic| { + use helix_core::{ + diagnostic::{Range, Severity::*}, + Diagnostic, + }; + use lsp::DiagnosticSeverity; + + let language_server = doc.language_server().unwrap(); + + // TODO: convert inside server + let start = if let Some(start) = lsp_pos_to_pos( + text, + diagnostic.range.start, + language_server.offset_encoding(), + ) { + start + } else { + log::warn!("lsp position out of bounds - {:?}", diagnostic); + return None; + }; + + let end = if let Some(end) = lsp_pos_to_pos( + text, + diagnostic.range.end, + language_server.offset_encoding(), + ) { + end + } else { + log::warn!("lsp position out of bounds - {:?}", diagnostic); + return None; + }; + + let severity = + diagnostic.severity.map(|severity| match severity { + DiagnosticSeverity::ERROR => Error, + DiagnosticSeverity::WARNING => Warning, + DiagnosticSeverity::INFORMATION => Info, + DiagnosticSeverity::HINT => Hint, + severity => unreachable!( + "unrecognized diagnostic severity: {:?}", + severity + ), + }); + + if let Some(lang_conf) = lang_conf { + if let Some(severity) = severity { + if severity < lang_conf.diagnostic_severity { + return None; + } + } + }; + + Some(Diagnostic { + range: Range { start, end }, + line: diagnostic.range.start.line as usize, + message: diagnostic.message, + severity, + // code + // source + }) + }) + .collect(); + + doc.set_diagnostics(diagnostics); } - let provider = helix_core::diagnostic::DiagnosticProvider::Lsp { - server_id, - identifier: None, - }; - self.editor.handle_lsp_diagnostics( - &provider, - uri, - params.version, - params.diagnostics, - ); } Notification::ShowMessage(params) => { - if self.config.load().editor.lsp.display_messages { - match params.typ { - lsp::MessageType::ERROR => self.editor.set_error(params.message), - lsp::MessageType::WARNING => { - self.editor.set_warning(params.message) - } - _ => self.editor.set_status(params.message), - } - } + log::warn!("unhandled window/showMessage: {:?}", params); } Notification::LogMessage(params) => { log::info!("window/logMessage: {:?}", params); @@ -849,11 +630,10 @@ impl Application { .compositor .find::<ui::EditorView>() .expect("expected at least one EditorView"); - let lsp::ProgressParams { - token, - value: lsp::ProgressParamsValue::WorkDone(work), - } = params; - let (title, message, percentage) = match &work { + let lsp::ProgressParams { token, value } = params; + + let lsp::ProgressParamsValue::WorkDone(work) = value; + let parts = match &work { lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin { title, message, @@ -881,106 +661,67 @@ impl Application { } }; - if self.editor.config().lsp.display_progress_messages { - let title = - title.or_else(|| self.lsp_progress.title(server_id, &token)); - if title.is_some() || percentage.is_some() || message.is_some() { - use std::fmt::Write as _; - let mut status = format!("{}: ", language_server!().name()); - if let Some(percentage) = percentage { - write!(status, "{percentage:>2}% ").unwrap(); - } - if let Some(title) = title { - status.push_str(title); - } - if title.is_some() && message.is_some() { - status.push_str(" ⋅ "); - } - if let Some(message) = message { - status.push_str(message); - } - self.editor.set_status(status); - } - } + let token_d: &dyn std::fmt::Display = match &token { + lsp::NumberOrString::Number(n) => n, + lsp::NumberOrString::String(s) => s, + }; - match work { - lsp::WorkDoneProgress::Begin(begin_status) => { - self.lsp_progress - .begin(server_id, token.clone(), begin_status); + let status = match parts { + (Some(title), Some(message), Some(percentage)) => { + format!("[{}] {}% {} - {}", token_d, percentage, title, message) } - lsp::WorkDoneProgress::Report(report_status) => { - self.lsp_progress - .update(server_id, token.clone(), report_status); + (Some(title), None, Some(percentage)) => { + format!("[{}] {}% {}", token_d, percentage, title) } - lsp::WorkDoneProgress::End(_) => { - self.lsp_progress.end_progress(server_id, &token); - if !self.lsp_progress.is_progressing(server_id) { - editor_view.spinners_mut().get_or_create(server_id).stop(); - }; + (Some(title), Some(message), None) => { + format!("[{}] {} - {}", token_d, title, message) + } + (None, Some(message), Some(percentage)) => { + format!("[{}] {}% {}", token_d, percentage, message) + } + (Some(title), None, None) => { + format!("[{}] {}", token_d, title) } + (None, Some(message), None) => { + format!("[{}] {}", token_d, message) + } + (None, None, Some(percentage)) => { + format!("[{}] {}%", token_d, percentage) + } + (None, None, None) => format!("[{}]", token_d), + }; + + if let lsp::WorkDoneProgress::End(_) = work { + self.lsp_progress.end_progress(server_id, &token); + if !self.lsp_progress.is_progressing(server_id) { + editor_view.spinners_mut().get_or_create(server_id).stop(); + } + } else { + self.lsp_progress.update(server_id, token, work); + } + + if self.config.lsp.display_messages { + self.editor.set_status(status); } } Notification::ProgressMessage(_params) => { // do nothing } - Notification::Exit => { - self.editor.set_status("Language server exited"); - - // LSPs may produce diagnostics for files that haven't been opened in helix, - // we need to clear those and remove the entries from the list if this leads to - // an empty diagnostic list for said files - for diags in self.editor.diagnostics.values_mut() { - diags.retain(|(_, provider)| { - provider.language_server_id() != Some(server_id) - }); - } - - self.editor.diagnostics.retain(|_, diags| !diags.is_empty()); - - // Clear any diagnostics for documents with this server open. - for doc in self.editor.documents_mut() { - doc.clear_diagnostics_for_language_server(server_id); - } - - helix_event::dispatch(helix_view::events::LanguageServerExited { - editor: &mut self.editor, - server_id, - }); - - // Remove the language server from the registry. - self.editor.language_servers.remove_by_id(server_id); - } } } Call::MethodCall(helix_lsp::jsonrpc::MethodCall { method, params, id, .. }) => { - let reply = match MethodCall::parse(&method, params) { - Err(helix_lsp::Error::Unhandled) => { - error!( - "Language Server: Method {} not found in request {}", - method, id - ); - Err(helix_lsp::jsonrpc::Error { - code: helix_lsp::jsonrpc::ErrorCode::MethodNotFound, - message: format!("Method not found: {}", method), - data: None, - }) - } - Err(err) => { - log::error!( - "Language Server: Received malformed method call {} in request {}: {}", - method, - id, - err - ); - Err(helix_lsp::jsonrpc::Error { - code: helix_lsp::jsonrpc::ErrorCode::ParseError, - message: format!("Malformed method call: {}", method), - data: None, - }) + let call = match MethodCall::parse(&method, params) { + Some(call) => call, + None => { + error!("Method not found {}", method); + return; } - Ok(MethodCall::WorkDoneProgressCreate(params)) => { + }; + + match call { + MethodCall::WorkDoneProgressCreate(params) => { self.lsp_progress.create(server_id, params.token); let editor_view = self @@ -991,312 +732,126 @@ impl Application { if spinner.is_stopped() { spinner.start(); } + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; - Ok(serde_json::Value::Null) + tokio::spawn(language_server.reply(id, Ok(serde_json::Value::Null))); } - Ok(MethodCall::ApplyWorkspaceEdit(params)) => { - let language_server = language_server!(); - if language_server.is_initialized() { - let offset_encoding = language_server.offset_encoding(); - let res = self - .editor - .apply_workspace_edit(offset_encoding, ¶ms.edit); + MethodCall::ApplyWorkspaceEdit(params) => { + apply_workspace_edit( + &mut self.editor, + helix_lsp::OffsetEncoding::Utf8, + ¶ms.edit, + ); + + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; + tokio::spawn(language_server.reply( + id, Ok(json!(lsp::ApplyWorkspaceEditResponse { - applied: res.is_ok(), - failure_reason: res.as_ref().err().map(|err| err.kind.to_string()), - failed_change: res - .as_ref() - .err() - .map(|err| err.failed_change_idx as u32), - })) - } else { - Err(helix_lsp::jsonrpc::Error { - code: helix_lsp::jsonrpc::ErrorCode::InvalidRequest, - message: "Server must be initialized to request workspace edits" - .to_string(), - data: None, - }) - } - } - Ok(MethodCall::WorkspaceFolders) => { - Ok(json!(&*language_server!().workspace_folders().await)) + applied: true, + failure_reason: None, + failed_change: None, + })), + )); } - Ok(MethodCall::WorkspaceConfiguration(params)) => { - let language_server = language_server!(); + MethodCall::WorkspaceConfiguration(params) => { + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; let result: Vec<_> = params .items .iter() .map(|item| { - let mut config = language_server.config()?; + let mut config = match &item.scope_uri { + Some(scope) => { + let path = scope.to_file_path().ok()?; + let doc = self.editor.document_by_path(path)?; + doc.language_config()?.config.as_ref()? + } + None => language_server.config()?, + }; if let Some(section) = item.section.as_ref() { - // for some reason some lsps send an empty string (observed in 'vscode-eslint-language-server') - if !section.is_empty() { - for part in section.split('.') { - config = config.get(part)?; - } + for part in section.split('.') { + config = config.get(part)?; } } Some(config) }) .collect(); - Ok(json!(result)) + tokio::spawn(language_server.reply(id, Ok(json!(result)))); } - Ok(MethodCall::RegisterCapability(params)) => { - if let Some(client) = self.editor.language_servers.get_by_id(server_id) { - for reg in params.registrations { - match reg.method.as_str() { - lsp::notification::DidChangeWatchedFiles::METHOD => { - let Some(options) = reg.register_options else { - continue; - }; - let ops: lsp::DidChangeWatchedFilesRegistrationOptions = - match serde_json::from_value(options) { - Ok(ops) => ops, - Err(err) => { - log::warn!("Failed to deserialize DidChangeWatchedFilesRegistrationOptions: {err}"); - continue; - } - }; - self.editor.language_servers.file_event_handler.register( - client.id(), - Arc::downgrade(client), - reg.id, - ops, - ) - } - _ => { - // Language Servers based on the `vscode-languageserver-node` library often send - // client/registerCapability even though we do not enable dynamic registration - // for most capabilities. We should send a MethodNotFound JSONRPC error in this - // case but that rejects the registration promise in the server which causes an - // exit. So we work around this by ignoring the request and sending back an OK - // response. - log::warn!("Ignoring a client/registerCapability request because dynamic capability registration is not enabled. Please report this upstream to the language server"); - } - } - } - } - - Ok(serde_json::Value::Null) - } - Ok(MethodCall::UnregisterCapability(params)) => { - for unreg in params.unregisterations { - match unreg.method.as_str() { - lsp::notification::DidChangeWatchedFiles::METHOD => { - self.editor - .language_servers - .file_event_handler - .unregister(server_id, unreg.id); - } - _ => { - log::warn!("Received unregistration request for unsupported method: {}", unreg.method); - } - } - } - Ok(serde_json::Value::Null) - } - Ok(MethodCall::ShowDocument(params)) => { - let language_server = language_server!(); - let offset_encoding = language_server.offset_encoding(); - - let result = self.handle_show_document(params, offset_encoding); - Ok(json!(result)) - } - Ok(MethodCall::WorkspaceDiagnosticRefresh) => { - let language_server = language_server!().id(); - - let documents: Vec<_> = self - .editor - .documents - .values() - .filter(|x| x.supports_language_server(language_server)) - .map(|x| x.id()) - .collect(); - - for document in documents { - handlers::diagnostics::request_document_diagnostics( - &mut self.editor, - document, - ); - } - - Ok(serde_json::Value::Null) - } - }; - - let language_server = language_server!(); - if let Err(err) = language_server.reply(id.clone(), reply) { - log::error!( - "Failed to send reply to server '{}' request {id}: {err}", - language_server.name() - ); } } - Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), + e => unreachable!("{:?}", e), } } - fn handle_show_document( - &mut self, - params: lsp::ShowDocumentParams, - offset_encoding: helix_lsp::OffsetEncoding, - ) -> lsp::ShowDocumentResult { - if let lsp::ShowDocumentParams { - external: Some(true), - uri, - .. - } = params - { - self.jobs.callback(crate::open_external_url_callback(uri)); - return lsp::ShowDocumentResult { success: true }; - }; - - let lsp::ShowDocumentParams { - uri, - selection, - take_focus, - .. - } = params; - - let uri = match helix_core::Uri::try_from(uri) { - Ok(uri) => uri, - Err(err) => { - log::error!("{err}"); - return lsp::ShowDocumentResult { success: false }; - } - }; - // If `Uri` gets another variant other than `Path` this may not be valid. - let path = uri.as_path().expect("URIs are valid paths"); - - let action = match take_focus { - Some(true) => helix_view::editor::Action::Replace, - _ => helix_view::editor::Action::VerticalSplit, - }; - - let doc_id = match self.editor.open(path, action) { - Ok(id) => id, - Err(err) => { - log::error!("failed to open path: {:?}: {:?}", uri, err); - return lsp::ShowDocumentResult { success: false }; - } - }; - - let doc = doc_mut!(self.editor, &doc_id); - if let Some(range) = selection { - // TODO: convert inside server - if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) { - let view = view_mut!(self.editor); - - // we flip the range so that the cursor sits on the start of the symbol - // (for example start of the function). - doc.set_selection(view.id, Selection::single(new_range.head, new_range.anchor)); - if action.align_view(view, doc.id()) { - align_view(doc, view, Align::Center); - } - } else { - log::warn!("lsp position out of bounds - {:?}", range); - }; - }; - lsp::ShowDocumentResult { success: true } - } - - fn restore_term(&mut self) -> std::io::Result<()> { - use helix_view::graphics::CursorKind; - self.terminal - .backend_mut() - .show_cursor(CursorKind::Block) - .ok(); - self.terminal.restore() - } - - #[cfg(all(not(feature = "integration"), not(windows)))] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - use termina::{escape::csi, Terminal as _}; - let reader = self.terminal.backend().terminal().event_reader(); - termina::EventStream::new(reader, |event| { - // Accept either non-escape sequences or theme mode updates. - !event.is_escape() - || matches!( - event, - termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(_))) - ) - }) + async fn claim_term(&mut self) -> Result<(), Error> { + terminal::enable_raw_mode()?; + let mut stdout = stdout(); + execute!(stdout, terminal::EnterAlternateScreen)?; + if self.config.editor.mouse { + execute!(stdout, EnableMouseCapture)?; + } + Ok(()) } - #[cfg(all(not(feature = "integration"), windows))] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - crossterm::event::EventStream::new() + fn restore_term(&mut self) -> Result<(), Error> { + let mut stdout = stdout(); + // reset cursor shape + write!(stdout, "\x1B[2 q")?; + // Ignore errors on disabling, this might trigger on windows if we call + // disable without calling enable previously + let _ = execute!(stdout, DisableMouseCapture); + execute!(stdout, terminal::LeaveAlternateScreen)?; + terminal::disable_raw_mode()?; + Ok(()) } - #[cfg(feature = "integration")] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - use std::{ - pin::Pin, - task::{Context, Poll}, - }; - - /// A dummy stream that never polls as ready. - pub struct DummyEventStream; - - impl Stream for DummyEventStream { - type Item = std::io::Result<TerminalEvent>; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { - Poll::Pending - } - } - - DummyEventStream - } + pub async fn run(&mut self) -> Result<i32, Error> { + self.claim_term().await?; + + // Exit the alternate screen and disable raw mode before panicking + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + // We can't handle errors properly inside this closure. And it's + // probably not a good idea to `unwrap()` inside a panic handler. + // So we just ignore the `Result`s. + let _ = execute!(std::io::stdout(), DisableMouseCapture); + let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + hook(info); + })); - pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error> - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { - self.terminal.claim()?; + self.event_loop().await; - self.event_loop(input_stream).await; + self.jobs.finish().await; - let close_errs = self.close().await; + if self.editor.close_language_servers(None).await.is_err() { + log::error!("Timed out waiting for language servers to shutdown"); + }; self.restore_term()?; - for err in close_errs { - self.editor.exit_code = 1; - eprintln!("Error: {}", err); - } - Ok(self.editor.exit_code) } - - pub async fn close(&mut self) -> Vec<anyhow::Error> { - // [NOTE] we intentionally do not return early for errors because we - // want to try to run as much cleanup as we can, regardless of - // errors along the way - let mut errs = Vec::new(); - - if let Err(err) = self - .jobs - .finish(&mut self.editor, Some(&mut self.compositor)) - .await - { - log::error!("Error executing job: {}", err); - errs.push(err); - }; - - if let Err(err) = self.editor.flush_writes().await { - log::error!("Error writing: {}", err); - errs.push(err); - } - - if self.editor.close_language_servers(None).await.is_err() { - log::error!("Timed out waiting for language servers to shutdown"); - errs.push(anyhow::format_err!( - "Timed out waiting for language servers to shutdown" - )); - } - - errs - } } |