Merge pull request #9647 from helix-editor/pickers-v2

`Picker`s "v2"
pull/11183/head
Blaž Hrastnik 4 months ago committed by GitHub
commit 08ee8b9443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

17
Cargo.lock generated

@ -209,12 +209,6 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]]
name = "cov-mark"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ffa3d3e0138386cd4361f63537765cac7ee40698028844635a54495a92f67f3"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.3.2" version = "1.3.2"
@ -1337,6 +1331,7 @@ dependencies = [
"unicode-general-category", "unicode-general-category",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
"url",
] ]
[[package]] [[package]]
@ -1467,6 +1462,7 @@ dependencies = [
"smallvec", "smallvec",
"tempfile", "tempfile",
"termini", "termini",
"thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml", "toml",
@ -1784,9 +1780,9 @@ dependencies = [
[[package]] [[package]]
name = "nucleo" name = "nucleo"
version = "0.2.1" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae5331f4bcce475cf28cb29c95366c3091af4b0aa7703f1a6bc858f29718fdf3" checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
dependencies = [ dependencies = [
"nucleo-matcher", "nucleo-matcher",
"parking_lot", "parking_lot",
@ -1795,11 +1791,10 @@ dependencies = [
[[package]] [[package]]
name = "nucleo-matcher" name = "nucleo-matcher"
version = "0.2.0" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b702b402fe286162d1f00b552a046ce74365d2ac473a2607ff36ba650f9bd57" checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
dependencies = [ dependencies = [
"cov-mark",
"memchr", "memchr",
"unicode-segmentation", "unicode-segmentation",
] ]

@ -38,7 +38,7 @@ package.helix-term.opt-level = 2
[workspace.dependencies] [workspace.dependencies]
tree-sitter = { version = "0.22" } tree-sitter = { version = "0.22" }
nucleo = "0.2.0" nucleo = "0.5.0"
slotmap = "1.0.7" slotmap = "1.0.7"
thiserror = "1.0" thiserror = "1.0"

@ -297,6 +297,8 @@ These scopes are used for theming the editor interface:
| `ui.bufferline.background` | Style for bufferline background | | `ui.bufferline.background` | Style for bufferline background |
| `ui.popup` | Documentation popups (e.g. Space + k) | | `ui.popup` | Documentation popups (e.g. Space + k) |
| `ui.popup.info` | Prompt for multiple key options | | `ui.popup.info` | Prompt for multiple key options |
| `ui.picker.header` | Column names in pickers with multiple columns |
| `ui.picker.header.active` | The column name in pickers with multiple columns where the cursor is entering into. |
| `ui.window` | Borderlines separating splits | | `ui.window` | Borderlines separating splits |
| `ui.help` | Description box for commands | | `ui.help` | Description box for commands |
| `ui.text` | Default text style, command prompts, popup text, etc. | | `ui.text` | Default text style, command prompts, popup text, etc. |

@ -34,6 +34,7 @@ bitflags = "2.6"
ahash = "0.8.11" ahash = "0.8.11"
hashbrown = { version = "0.14.5", features = ["raw"] } hashbrown = { version = "0.14.5", features = ["raw"] }
dunce = "1.0" dunce = "1.0"
url = "2.5.0"
log = "0.4" log = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

@ -1,6 +1,6 @@
use std::ops::DerefMut; use std::ops::DerefMut;
use nucleo::pattern::{Atom, AtomKind, CaseMatching}; use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::Config; use nucleo::Config;
use parking_lot::Mutex; use parking_lot::Mutex;
@ -38,6 +38,12 @@ pub fn fuzzy_match<T: AsRef<str>>(
if path { if path {
matcher.config.set_match_paths(); matcher.config.set_match_paths();
} }
let pattern = Atom::new(pattern, CaseMatching::Smart, AtomKind::Fuzzy, false); let pattern = Atom::new(
pattern,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
pattern.match_list(items, &mut matcher) pattern.match_list(items, &mut matcher)
} }

@ -27,6 +27,7 @@ pub mod test;
pub mod text_annotations; pub mod text_annotations;
pub mod textobject; pub mod textobject;
mod transaction; mod transaction;
pub mod uri;
pub mod wrap; pub mod wrap;
pub mod unicode { pub mod unicode {
@ -66,3 +67,5 @@ pub use diagnostic::Diagnostic;
pub use line_ending::{LineEnding, NATIVE_LINE_ENDING}; pub use line_ending::{LineEnding, NATIVE_LINE_ENDING};
pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction}; pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction};
pub use uri::Uri;

@ -0,0 +1,122 @@
use std::path::{Path, PathBuf};
/// A generic pointer to a file location.
///
/// Currently this type only supports paths to local files.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Uri {
File(PathBuf),
}
impl Uri {
// This clippy allow mirrors url::Url::from_file_path
#[allow(clippy::result_unit_err)]
pub fn to_url(&self) -> Result<url::Url, ()> {
match self {
Uri::File(path) => url::Url::from_file_path(path),
}
}
pub fn as_path(&self) -> Option<&Path> {
match self {
Self::File(path) => Some(path),
}
}
pub fn as_path_buf(self) -> Option<PathBuf> {
match self {
Self::File(path) => Some(path),
}
}
}
impl From<PathBuf> for Uri {
fn from(path: PathBuf) -> Self {
Self::File(path)
}
}
impl TryFrom<Uri> for PathBuf {
type Error = ();
fn try_from(uri: Uri) -> Result<Self, Self::Error> {
match uri {
Uri::File(path) => Ok(path),
}
}
}
#[derive(Debug)]
pub struct UrlConversionError {
source: url::Url,
kind: UrlConversionErrorKind,
}
#[derive(Debug)]
pub enum UrlConversionErrorKind {
UnsupportedScheme,
UnableToConvert,
}
impl std::fmt::Display for UrlConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
UrlConversionErrorKind::UnsupportedScheme => {
write!(f, "unsupported scheme in URL: {}", self.source.scheme())
}
UrlConversionErrorKind::UnableToConvert => {
write!(f, "unable to convert URL to file path: {}", self.source)
}
}
}
}
impl std::error::Error for UrlConversionError {}
fn convert_url_to_uri(url: &url::Url) -> Result<Uri, UrlConversionErrorKind> {
if url.scheme() == "file" {
url.to_file_path()
.map(|path| Uri::File(helix_stdx::path::normalize(path)))
.map_err(|_| UrlConversionErrorKind::UnableToConvert)
} else {
Err(UrlConversionErrorKind::UnsupportedScheme)
}
}
impl TryFrom<url::Url> for Uri {
type Error = UrlConversionError;
fn try_from(url: url::Url) -> Result<Self, Self::Error> {
convert_url_to_uri(&url).map_err(|kind| Self::Error { source: url, kind })
}
}
impl TryFrom<&url::Url> for Uri {
type Error = UrlConversionError;
fn try_from(url: &url::Url) -> Result<Self, Self::Error> {
convert_url_to_uri(url).map_err(|kind| Self::Error {
source: url.clone(),
kind,
})
}
}
#[cfg(test)]
mod test {
use super::*;
use url::Url;
#[test]
fn unknown_scheme() {
let url = Url::parse("csharp:/metadata/foo/bar/Baz.cs").unwrap();
assert!(matches!(
Uri::try_from(url),
Err(UrlConversionError {
kind: UrlConversionErrorKind::UnsupportedScheme,
..
})
));
}
}

@ -34,7 +34,9 @@
use anyhow::Result; use anyhow::Result;
pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx}; pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx};
pub use debounce::{send_blocking, AsyncHook}; pub use debounce::{send_blocking, AsyncHook};
pub use redraw::{lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard}; pub use redraw::{
lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard, RequestRedrawOnDrop,
};
pub use registry::Event; pub use registry::Event;
mod cancel; mod cancel;

@ -51,3 +51,12 @@ pub fn start_frame() {
pub fn lock_frame() -> RenderLockGuard { pub fn lock_frame() -> RenderLockGuard {
RENDER_LOCK.read() RENDER_LOCK.read()
} }
/// A zero sized type that requests a redraw via [request_redraw] when the type [Drop]s.
pub struct RequestRedrawOnDrop;
impl Drop for RequestRedrawOnDrop {
fn drop(&mut self) {
request_redraw();
}
}

@ -56,6 +56,7 @@ ignore = "0.4"
pulldown-cmark = { version = "0.11", default-features = false } pulldown-cmark = { version = "0.11", default-features = false }
# file type detection # file type detection
content_inspector = "0.2.4" content_inspector = "0.2.4"
thiserror = "1.0"
# opening URLs # opening URLs
open = "5.2.0" open = "5.2.0"

@ -735,10 +735,10 @@ impl Application {
} }
} }
Notification::PublishDiagnostics(mut params) => { Notification::PublishDiagnostics(mut params) => {
let path = match params.uri.to_file_path() { let uri = match helix_core::Uri::try_from(params.uri) {
Ok(path) => helix_stdx::path::normalize(path), Ok(uri) => uri,
Err(_) => { Err(err) => {
log::error!("Unsupported file URI: {}", params.uri); log::error!("{err}");
return; return;
} }
}; };
@ -749,11 +749,11 @@ impl Application {
} }
// have to inline the function because of borrow checking... // have to inline the function because of borrow checking...
let doc = self.editor.documents.values_mut() let doc = self.editor.documents.values_mut()
.find(|doc| doc.path().map(|p| p == &path).unwrap_or(false)) .find(|doc| doc.uri().is_some_and(|u| u == uri))
.filter(|doc| { .filter(|doc| {
if let Some(version) = params.version { if let Some(version) = params.version {
if version != doc.version() { if version != doc.version() {
log::info!("Version ({version}) is out of date for {path:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); log::info!("Version ({version}) is out of date for {uri:?} (expected ({}), dropping PublishDiagnostic notification", doc.version());
return false; return false;
} }
} }
@ -765,7 +765,7 @@ impl Application {
let lang_conf = doc.language.clone(); let lang_conf = doc.language.clone();
if let Some(lang_conf) = &lang_conf { if let Some(lang_conf) = &lang_conf {
if let Some(old_diagnostics) = self.editor.diagnostics.get(&path) { if let Some(old_diagnostics) = self.editor.diagnostics.get(&uri) {
if !lang_conf.persistent_diagnostic_sources.is_empty() { if !lang_conf.persistent_diagnostic_sources.is_empty() {
// Sort diagnostics first by severity and then by line numbers. // Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
@ -798,7 +798,7 @@ impl Application {
// Insert the original lsp::Diagnostics here because we may have no open document // Insert the original lsp::Diagnostics here because we may have no open document
// for diagnosic message and so we can't calculate the exact position. // for diagnosic message and so we can't calculate the exact position.
// When using them later in the diagnostics picker, we calculate them on-demand. // When using them later in the diagnostics picker, we calculate them on-demand.
let diagnostics = match self.editor.diagnostics.entry(path) { let diagnostics = match self.editor.diagnostics.entry(uri) {
Entry::Occupied(o) => { Entry::Occupied(o) => {
let current_diagnostics = o.into_mut(); let current_diagnostics = o.into_mut();
// there may entries of other language servers, which is why we can't overwrite the whole entry // there may entries of other language servers, which is why we can't overwrite the whole entry
@ -1132,20 +1132,22 @@ impl Application {
.. ..
} = params; } = params;
let path = match uri.to_file_path() { let uri = match helix_core::Uri::try_from(uri) {
Ok(path) => path, Ok(uri) => uri,
Err(err) => { Err(err) => {
log::error!("unsupported file URI: {}: {:?}", uri, err); log::error!("{err}");
return lsp::ShowDocumentResult { success: false }; 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 { let action = match take_focus {
Some(true) => helix_view::editor::Action::Replace, Some(true) => helix_view::editor::Action::Replace,
_ => helix_view::editor::Action::VerticalSplit, _ => helix_view::editor::Action::VerticalSplit,
}; };
let doc_id = match self.editor.open(&path, action) { let doc_id = match self.editor.open(path, action) {
Ok(id) => id, Ok(id) => id,
Err(err) => { Err(err) => {
log::error!("failed to open path: {:?}: {:?}", uri, err); log::error!("failed to open path: {:?}: {:?}", uri, err);

@ -3,6 +3,7 @@ pub(crate) mod lsp;
pub(crate) mod typed; pub(crate) mod typed;
pub use dap::*; pub use dap::*;
use futures_util::FutureExt;
use helix_event::status; use helix_event::status;
use helix_stdx::{ use helix_stdx::{
path::expand_tilde, path::expand_tilde,
@ -10,10 +11,7 @@ use helix_stdx::{
}; };
use helix_vcs::{FileChange, Hunk}; use helix_vcs::{FileChange, Hunk};
pub use lsp::*; pub use lsp::*;
use tui::{ use tui::text::Span;
text::Span,
widgets::{Cell, Row},
};
pub use typed::*; pub use typed::*;
use helix_core::{ use helix_core::{
@ -61,8 +59,7 @@ use crate::{
compositor::{self, Component, Compositor}, compositor::{self, Component, Compositor},
filter_picker_entry, filter_picker_entry,
job::Callback, job::Callback,
keymap::ReverseKeymap, ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent},
ui::{self, menu::Item, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
}; };
use crate::job::{self, Jobs}; use crate::job::{self, Jobs};
@ -2257,102 +2254,88 @@ fn global_search(cx: &mut Context) {
} }
} }
impl ui::menu::Item for FileResult { struct GlobalSearchConfig {
type Data = Option<PathBuf>; smart_case: bool,
file_picker_config: helix_view::editor::FilePickerConfig,
fn format(&self, current_path: &Self::Data) -> Row {
let relative_path = helix_stdx::path::get_relative_path(&self.path)
.to_string_lossy()
.into_owned();
if current_path
.as_ref()
.map(|p| p == &self.path)
.unwrap_or(false)
{
format!("{} (*)", relative_path).into()
} else {
relative_path.into()
}
}
} }
let config = cx.editor.config(); let config = cx.editor.config();
let smart_case = config.search.smart_case; let config = GlobalSearchConfig {
let file_picker_config = config.file_picker.clone(); smart_case: config.search.smart_case,
file_picker_config: config.file_picker.clone(),
};
let reg = cx.register.unwrap_or('/'); let columns = [
let completions = search_completions(cx, Some(reg)); PickerColumn::new("path", |item: &FileResult, _| {
ui::raw_regex_prompt( let path = helix_stdx::path::get_relative_path(&item.path);
cx, format!("{}:{}", path.to_string_lossy(), item.line_num + 1).into()
"global-search:".into(), }),
Some(reg), PickerColumn::hidden("contents"),
move |_editor: &Editor, input: &str| { ];
completions
.iter() let get_files = |query: &str,
.filter(|comp| comp.starts_with(input)) editor: &mut Editor,
.map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) config: std::sync::Arc<GlobalSearchConfig>,
.collect() injector: &ui::picker::Injector<_, _>| {
}, if query.is_empty() {
move |cx, _, input, event| { return async { Ok(()) }.boxed();
if event != PromptEvent::Validate {
return;
} }
cx.editor.registers.last_search_register = reg;
let current_path = doc_mut!(cx.editor).path().cloned(); let search_root = helix_stdx::env::current_working_dir();
let documents: Vec<_> = cx if !search_root.exists() {
.editor return async { Err(anyhow::anyhow!("Current working directory does not exist")) }
.boxed();
}
let documents: Vec<_> = editor
.documents() .documents()
.map(|doc| (doc.path().cloned(), doc.text().to_owned())) .map(|doc| (doc.path().cloned(), doc.text().to_owned()))
.collect(); .collect();
if let Ok(matcher) = RegexMatcherBuilder::new() let matcher = match RegexMatcherBuilder::new()
.case_smart(smart_case) .case_smart(config.smart_case)
.build(input) .build(query)
{ {
let search_root = helix_stdx::env::current_working_dir(); Ok(matcher) => {
if !search_root.exists() { // Clear any "Failed to compile regex" errors out of the statusline.
cx.editor editor.clear_status();
.set_error("Current working directory does not exist"); matcher
return;
} }
Err(err) => {
log::info!("Failed to compile search pattern in global search: {}", err);
return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed();
}
};
let (picker, injector) = Picker::stream(current_path); let dedup_symlinks = config.file_picker_config.deduplicate_links;
let dedup_symlinks = file_picker_config.deduplicate_links;
let absolute_root = search_root let absolute_root = search_root
.canonicalize() .canonicalize()
.unwrap_or_else(|_| search_root.clone()); .unwrap_or_else(|_| search_root.clone());
let injector_ = injector.clone();
std::thread::spawn(move || { let injector = injector.clone();
async move {
let searcher = SearcherBuilder::new() let searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00')) .binary_detection(BinaryDetection::quit(b'\x00'))
.build(); .build();
WalkBuilder::new(search_root)
let mut walk_builder = WalkBuilder::new(search_root); .hidden(config.file_picker_config.hidden)
.parents(config.file_picker_config.parents)
walk_builder .ignore(config.file_picker_config.ignore)
.hidden(file_picker_config.hidden) .follow_links(config.file_picker_config.follow_symlinks)
.parents(file_picker_config.parents) .git_ignore(config.file_picker_config.git_ignore)
.ignore(file_picker_config.ignore) .git_global(config.file_picker_config.git_global)
.follow_links(file_picker_config.follow_symlinks) .git_exclude(config.file_picker_config.git_exclude)
.git_ignore(file_picker_config.git_ignore) .max_depth(config.file_picker_config.max_depth)
.git_global(file_picker_config.git_global)
.git_exclude(file_picker_config.git_exclude)
.max_depth(file_picker_config.max_depth)
.filter_entry(move |entry| { .filter_entry(move |entry| {
filter_picker_entry(entry, &absolute_root, dedup_symlinks) filter_picker_entry(entry, &absolute_root, dedup_symlinks)
}); })
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"))
walk_builder .add_custom_ignore_filename(".helix/ignore")
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore")); .build_parallel()
walk_builder.add_custom_ignore_filename(".helix/ignore"); .run(|| {
walk_builder.build_parallel().run(|| {
let mut searcher = searcher.clone(); let mut searcher = searcher.clone();
let matcher = matcher.clone(); let matcher = matcher.clone();
let injector = injector_.clone(); let injector = injector.clone();
let documents = &documents; let documents = &documents;
Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState { Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState {
let entry = match entry { let entry = match entry {
@ -2367,7 +2350,7 @@ fn global_search(cx: &mut Context) {
}; };
let mut stop = false; let mut stop = false;
let sink = sinks::UTF8(|line_num, _| { let sink = sinks::UTF8(|line_num, _line_content| {
stop = injector stop = injector
.push(FileResult::new(entry.path(), line_num as usize - 1)) .push(FileResult::new(entry.path(), line_num as usize - 1))
.is_err(); .is_err();
@ -2401,11 +2384,7 @@ fn global_search(cx: &mut Context) {
}; };
if let Err(err) = result { if let Err(err) = result {
log::error!( log::error!("Global search error: {}, {}", entry.path().display(), err);
"Global search error: {}, {}",
entry.path().display(),
err
);
} }
if stop { if stop {
WalkState::Quit WalkState::Quit
@ -2414,22 +2393,25 @@ fn global_search(cx: &mut Context) {
} }
}) })
}); });
}); Ok(())
}
.boxed()
};
cx.jobs.callback(async move { let reg = cx.register.unwrap_or('/');
let call = move |_: &mut Editor, compositor: &mut Compositor| { cx.editor.registers.last_search_register = reg;
let picker = Picker::with_stream(
picker, let picker = Picker::new(
injector, columns,
move |cx, FileResult { path, line_num }, action| { 1, // contents
[],
config,
move |cx, FileResult { path, line_num, .. }, action| {
let doc = match cx.editor.open(path, action) { let doc = match cx.editor.open(path, action) {
Ok(id) => doc_mut!(cx.editor, &id), Ok(id) => doc_mut!(cx.editor, &id),
Err(e) => { Err(e) => {
cx.editor.set_error(format!( cx.editor
"Failed to open file '{}': {}", .set_error(format!("Failed to open file '{}': {}", path.display(), e));
path.display(),
e
));
return; return;
} }
}; };
@ -2452,21 +2434,13 @@ fn global_search(cx: &mut Context) {
} }
}, },
) )
.with_preview( .with_preview(|_editor, FileResult { path, line_num, .. }| {
|_editor, FileResult { path, line_num }| { Some((path.as_path().into(), Some((*line_num, *line_num))))
Some((path.clone().into(), Some((*line_num, *line_num))))
},
);
compositor.push(Box::new(overlaid(picker)))
};
Ok(Callback::EditorCompositor(Box::new(call)))
}) })
} else { .with_history_register(Some(reg))
// Otherwise do nothing .with_dynamic_query(get_files, Some(275));
// log::warn!("Global Search Invalid Pattern")
} cx.push_layer(Box::new(overlaid(picker)));
},
);
} }
enum Extend { enum Extend {
@ -2894,31 +2868,6 @@ fn buffer_picker(cx: &mut Context) {
focused_at: std::time::Instant, focused_at: std::time::Instant,
} }
impl ui::menu::Item for BufferMeta {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
let path = self
.path
.as_deref()
.map(helix_stdx::path::get_relative_path);
let path = match path.as_deref().and_then(Path::to_str) {
Some(path) => path,
None => SCRATCH_BUFFER_NAME,
};
let mut flags = String::new();
if self.is_modified {
flags.push('+');
}
if self.is_current {
flags.push('*');
}
Row::new([self.id.to_string(), flags, path.to_string()])
}
}
let new_meta = |doc: &Document| BufferMeta { let new_meta = |doc: &Document| BufferMeta {
id: doc.id(), id: doc.id(),
path: doc.path().cloned(), path: doc.path().cloned(),
@ -2937,7 +2886,31 @@ fn buffer_picker(cx: &mut Context) {
// mru // mru
items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at));
let picker = Picker::new(items, (), |cx, meta, action| { let columns = [
PickerColumn::new("id", |meta: &BufferMeta, _| meta.id.to_string().into()),
PickerColumn::new("flags", |meta: &BufferMeta, _| {
let mut flags = String::new();
if meta.is_modified {
flags.push('+');
}
if meta.is_current {
flags.push('*');
}
flags.into()
}),
PickerColumn::new("path", |meta: &BufferMeta, _| {
let path = meta
.path
.as_deref()
.map(helix_stdx::path::get_relative_path);
path.as_deref()
.and_then(Path::to_str)
.unwrap_or(SCRATCH_BUFFER_NAME)
.to_string()
.into()
}),
];
let picker = Picker::new(columns, 2, items, (), |cx, meta, action| {
cx.editor.switch(meta.id, action); cx.editor.switch(meta.id, action);
}) })
.with_preview(|editor, meta| { .with_preview(|editor, meta| {
@ -2961,33 +2934,6 @@ fn jumplist_picker(cx: &mut Context) {
is_current: bool, is_current: bool,
} }
impl ui::menu::Item for JumpMeta {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
let path = self
.path
.as_deref()
.map(helix_stdx::path::get_relative_path);
let path = match path.as_deref().and_then(Path::to_str) {
Some(path) => path,
None => SCRATCH_BUFFER_NAME,
};
let mut flags = Vec::new();
if self.is_current {
flags.push("*");
}
let flag = if flags.is_empty() {
"".into()
} else {
format!(" ({})", flags.join(""))
};
format!("{} {}{} {}", self.id, path, flag, self.text).into()
}
}
for (view, _) in cx.editor.tree.views_mut() { for (view, _) in cx.editor.tree.views_mut() {
for doc_id in view.jumps.iter().map(|e| e.0).collect::<Vec<_>>().iter() { for doc_id in view.jumps.iter().map(|e| e.0).collect::<Vec<_>>().iter() {
let doc = doc_mut!(cx.editor, doc_id); let doc = doc_mut!(cx.editor, doc_id);
@ -3014,17 +2960,43 @@ fn jumplist_picker(cx: &mut Context) {
} }
}; };
let columns = [
ui::PickerColumn::new("id", |item: &JumpMeta, _| item.id.to_string().into()),
ui::PickerColumn::new("path", |item: &JumpMeta, _| {
let path = item
.path
.as_deref()
.map(helix_stdx::path::get_relative_path);
path.as_deref()
.and_then(Path::to_str)
.unwrap_or(SCRATCH_BUFFER_NAME)
.to_string()
.into()
}),
ui::PickerColumn::new("flags", |item: &JumpMeta, _| {
let mut flags = Vec::new();
if item.is_current {
flags.push("*");
}
if flags.is_empty() {
"".into()
} else {
format!(" ({})", flags.join("")).into()
}
}),
ui::PickerColumn::new("contents", |item: &JumpMeta, _| item.text.as_str().into()),
];
let picker = Picker::new( let picker = Picker::new(
cx.editor columns,
.tree 1, // path
.views() cx.editor.tree.views().flat_map(|(view, _)| {
.flat_map(|(view, _)| {
view.jumps view.jumps
.iter() .iter()
.rev() .rev()
.map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone()))
}) }),
.collect(),
(), (),
|cx, meta, action| { |cx, meta, action| {
cx.editor.switch(meta.id, action); cx.editor.switch(meta.id, action);
@ -3054,33 +3026,6 @@ fn changed_file_picker(cx: &mut Context) {
style_renamed: Style, style_renamed: Style,
} }
impl Item for FileChange {
type Data = FileChangeData;
fn format(&self, data: &Self::Data) -> Row {
let process_path = |path: &PathBuf| {
path.strip_prefix(&data.cwd)
.unwrap_or(path)
.display()
.to_string()
};
let (sign, style, content) = match self {
Self::Untracked { path } => ("[+]", data.style_untracked, process_path(path)),
Self::Modified { path } => ("[~]", data.style_modified, process_path(path)),
Self::Conflict { path } => ("[x]", data.style_conflict, process_path(path)),
Self::Deleted { path } => ("[-]", data.style_deleted, process_path(path)),
Self::Renamed { from_path, to_path } => (
"[>]",
data.style_renamed,
format!("{} -> {}", process_path(from_path), process_path(to_path)),
),
};
Row::new([Cell::from(Span::styled(sign, style)), Cell::from(content)])
}
}
let cwd = helix_stdx::env::current_working_dir(); let cwd = helix_stdx::env::current_working_dir();
if !cwd.exists() { if !cwd.exists() {
cx.editor cx.editor
@ -3094,8 +3039,41 @@ fn changed_file_picker(cx: &mut Context) {
let deleted = cx.editor.theme.get("diff.minus"); let deleted = cx.editor.theme.get("diff.minus");
let renamed = cx.editor.theme.get("diff.delta.moved"); let renamed = cx.editor.theme.get("diff.delta.moved");
let columns = [
PickerColumn::new("change", |change: &FileChange, data: &FileChangeData| {
match change {
FileChange::Untracked { .. } => Span::styled("+ untracked", data.style_untracked),
FileChange::Modified { .. } => Span::styled("~ modified", data.style_modified),
FileChange::Conflict { .. } => Span::styled("x conflict", data.style_conflict),
FileChange::Deleted { .. } => Span::styled("- deleted", data.style_deleted),
FileChange::Renamed { .. } => Span::styled("> renamed", data.style_renamed),
}
.into()
}),
PickerColumn::new("path", |change: &FileChange, data: &FileChangeData| {
let display_path = |path: &PathBuf| {
path.strip_prefix(&data.cwd)
.unwrap_or(path)
.display()
.to_string()
};
match change {
FileChange::Untracked { path } => display_path(path),
FileChange::Modified { path } => display_path(path),
FileChange::Conflict { path } => display_path(path),
FileChange::Deleted { path } => display_path(path),
FileChange::Renamed { from_path, to_path } => {
format!("{} -> {}", display_path(from_path), display_path(to_path))
}
}
.into()
}),
];
let picker = Picker::new( let picker = Picker::new(
Vec::new(), columns,
1, // path
[],
FileChangeData { FileChangeData {
cwd: cwd.clone(), cwd: cwd.clone(),
style_untracked: added, style_untracked: added,
@ -3116,7 +3094,7 @@ fn changed_file_picker(cx: &mut Context) {
} }
}, },
) )
.with_preview(|_editor, meta| Some((meta.path().to_path_buf().into(), None))); .with_preview(|_editor, meta| Some((meta.path().into(), None)));
let injector = picker.injector(); let injector = picker.injector();
cx.editor cx.editor
@ -3132,35 +3110,6 @@ fn changed_file_picker(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker))); cx.push_layer(Box::new(overlaid(picker)));
} }
impl ui::menu::Item for MappableCommand {
type Data = ReverseKeymap;
fn format(&self, keymap: &Self::Data) -> Row {
let fmt_binding = |bindings: &Vec<Vec<KeyEvent>>| -> String {
bindings.iter().fold(String::new(), |mut acc, bind| {
if !acc.is_empty() {
acc.push(' ');
}
for key in bind {
acc.push_str(&key.key_sequence_format());
}
acc
})
};
match self {
MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) {
Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(),
None => format!("{} [:{}]", doc, name).into(),
},
MappableCommand::Static { doc, name, .. } => match keymap.get(*name) {
Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(),
None => format!("{} [{}]", doc, name).into(),
},
}
}
}
pub fn command_palette(cx: &mut Context) { pub fn command_palette(cx: &mut Context) {
let register = cx.register; let register = cx.register;
let count = cx.count; let count = cx.count;
@ -3171,16 +3120,45 @@ pub fn command_palette(cx: &mut Context) {
[&cx.editor.mode] [&cx.editor.mode]
.reverse_map(); .reverse_map();
let mut commands: Vec<MappableCommand> = MappableCommand::STATIC_COMMAND_LIST.into(); let commands = MappableCommand::STATIC_COMMAND_LIST.iter().cloned().chain(
commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { typed::TYPABLE_COMMAND_LIST
MappableCommand::Typable { .iter()
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(), name: cmd.name.to_owned(),
doc: cmd.doc.to_owned(),
args: Vec::new(), args: Vec::new(),
doc: cmd.doc.to_owned(),
}),
);
let columns = [
ui::PickerColumn::new("name", |item, _| match item {
MappableCommand::Typable { name, .. } => format!(":{name}").into(),
MappableCommand::Static { name, .. } => (*name).into(),
}),
ui::PickerColumn::new(
"bindings",
|item: &MappableCommand, keymap: &crate::keymap::ReverseKeymap| {
keymap
.get(item.name())
.map(|bindings| {
bindings.iter().fold(String::new(), |mut acc, bind| {
if !acc.is_empty() {
acc.push(' ');
} }
})); for key in bind {
acc.push_str(&key.key_sequence_format());
}
acc
})
})
.unwrap_or_default()
.into()
},
),
ui::PickerColumn::new("doc", |item: &MappableCommand, _| item.doc().into()),
];
let picker = Picker::new(commands, keymap, move |cx, command, _action| { let picker = Picker::new(columns, 0, commands, keymap, move |cx, command, _action| {
let mut ctx = Context { let mut ctx = Context {
register, register,
count, count,

@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint;
use serde_json::{to_value, Value}; use serde_json::{to_value, Value};
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use tui::{text::Spans, widgets::Row}; use tui::text::Spans;
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
@ -22,38 +22,6 @@ use anyhow::{anyhow, bail};
use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id}; use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id};
impl ui::menu::Item for StackFrame {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
self.name.as_str().into() // TODO: include thread_states in the label
}
}
impl ui::menu::Item for DebugTemplate {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
self.name.as_str().into()
}
}
impl ui::menu::Item for Thread {
type Data = ThreadStates;
fn format(&self, thread_states: &Self::Data) -> Row {
format!(
"{} ({})",
self.name,
thread_states
.get(&self.id)
.map(|state| state.as_str())
.unwrap_or("unknown")
)
.into()
}
}
fn thread_picker( fn thread_picker(
cx: &mut Context, cx: &mut Context,
callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static, callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static,
@ -73,13 +41,27 @@ fn thread_picker(
let debugger = debugger!(editor); let debugger = debugger!(editor);
let thread_states = debugger.thread_states.clone(); let thread_states = debugger.thread_states.clone();
let picker = Picker::new(threads, thread_states, move |cx, thread, _action| { let columns = [
callback_fn(cx.editor, thread) ui::PickerColumn::new("name", |item: &Thread, _| item.name.as_str().into()),
}) ui::PickerColumn::new("state", |item: &Thread, thread_states: &ThreadStates| {
thread_states
.get(&item.id)
.map(|state| state.as_str())
.unwrap_or("unknown")
.into()
}),
];
let picker = Picker::new(
columns,
0,
threads,
thread_states,
move |cx, thread, _action| callback_fn(cx.editor, thread),
)
.with_preview(move |editor, thread| { .with_preview(move |editor, thread| {
let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
let frame = frames.first()?; let frame = frames.first()?;
let path = frame.source.as_ref()?.path.clone()?; let path = frame.source.as_ref()?.path.as_ref()?.as_path();
let pos = Some(( let pos = Some((
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),
@ -268,7 +250,14 @@ pub fn dap_launch(cx: &mut Context) {
let templates = config.templates.clone(); let templates = config.templates.clone();
let columns = [ui::PickerColumn::new(
"template",
|item: &DebugTemplate, _| item.name.as_str().into(),
)];
cx.push_layer(Box::new(overlaid(Picker::new( cx.push_layer(Box::new(overlaid(Picker::new(
columns,
0,
templates, templates,
(), (),
|cx, template, _action| { |cx, template, _action| {
@ -736,7 +725,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
let frames = debugger.stack_frames[&thread_id].clone(); let frames = debugger.stack_frames[&thread_id].clone();
let picker = Picker::new(frames, (), move |cx, frame, _action| { let columns = [ui::PickerColumn::new("frame", |item: &StackFrame, _| {
item.name.as_str().into() // TODO: include thread_states in the label
})];
let picker = Picker::new(columns, 0, frames, (), move |cx, frame, _action| {
let debugger = debugger!(cx.editor); let debugger = debugger!(cx.editor);
// TODO: this should be simpler to find // TODO: this should be simpler to find
let pos = debugger.stack_frames[&thread_id] let pos = debugger.stack_frames[&thread_id]
@ -755,10 +747,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
frame frame
.source .source
.as_ref() .as_ref()
.and_then(|source| source.path.clone()) .and_then(|source| source.path.as_ref())
.map(|path| { .map(|path| {
( (
path.into(), path.as_path().into(),
Some(( Some((
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),

@ -9,14 +9,13 @@ use helix_lsp::{
Client, LanguageServerId, OffsetEncoding, Client, LanguageServerId, OffsetEncoding,
}; };
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tui::{ use tui::{text::Span, widgets::Row};
text::{Span, Spans},
widgets::Row,
};
use super::{align_view, push_jump, Align, Context, Editor}; use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection}; use helix_core::{
syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri,
};
use helix_stdx::path; use helix_stdx::path;
use helix_view::{ use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId}, document::{DocumentInlayHints, DocumentInlayHintsId},
@ -29,7 +28,7 @@ use helix_view::{
use crate::{ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
job::Callback, job::Callback,
ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent}, ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent},
}; };
use std::{ use std::{
@ -37,7 +36,7 @@ use std::{
collections::{BTreeMap, HashSet}, collections::{BTreeMap, HashSet},
fmt::Write, fmt::Write,
future::Future, future::Future,
path::{Path, PathBuf}, path::Path,
}; };
/// Gets the first language server that is attached to a document which supports a specific feature. /// Gets the first language server that is attached to a document which supports a specific feature.
@ -62,67 +61,10 @@ macro_rules! language_server_with_feature {
}}; }};
} }
impl ui::menu::Item for lsp::Location {
/// Current working directory.
type Data = PathBuf;
fn format(&self, cwdir: &Self::Data) -> Row {
// The preallocation here will overallocate a few characters since it will account for the
// URL's scheme, which is not used most of the time since that scheme will be "file://".
// Those extra chars will be used to avoid allocating when writing the line number (in the
// common case where it has 5 digits or less, which should be enough for a cast majority
// of usages).
let mut res = String::with_capacity(self.uri.as_str().len());
if self.uri.scheme() == "file" {
// With the preallocation above and UTF-8 paths already, this closure will do one (1)
// allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`.
let mut write_path_to_res = || -> Option<()> {
let path = self.uri.to_file_path().ok()?;
res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy());
Some(())
};
write_path_to_res();
} else {
// Never allocates since we declared the string with this capacity already.
res.push_str(self.uri.as_str());
}
// Most commonly, this will not allocate, especially on Unix systems where the root prefix
// is a simple `/` and not `C:\` (with whatever drive letter)
write!(&mut res, ":{}", self.range.start.line + 1)
.expect("Will only failed if allocating fail");
res.into()
}
}
struct SymbolInformationItem { struct SymbolInformationItem {
symbol: lsp::SymbolInformation, symbol: lsp::SymbolInformation,
offset_encoding: OffsetEncoding, offset_encoding: OffsetEncoding,
} uri: Uri,
impl ui::menu::Item for SymbolInformationItem {
/// Path to currently focussed document
type Data = Option<lsp::Url>;
fn format(&self, current_doc_path: &Self::Data) -> Row {
if current_doc_path.as_ref() == Some(&self.symbol.location.uri) {
self.symbol.name.as_str().into()
} else {
match self.symbol.location.uri.to_file_path() {
Ok(path) => {
let get_relative_path = path::get_relative_path(path.as_path());
format!(
"{} ({})",
&self.symbol.name,
get_relative_path.to_string_lossy()
)
.into()
}
Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(),
}
}
}
} }
struct DiagnosticStyles { struct DiagnosticStyles {
@ -133,60 +75,15 @@ struct DiagnosticStyles {
} }
struct PickerDiagnostic { struct PickerDiagnostic {
path: PathBuf, uri: Uri,
diag: lsp::Diagnostic, diag: lsp::Diagnostic,
offset_encoding: OffsetEncoding, offset_encoding: OffsetEncoding,
} }
impl ui::menu::Item for PickerDiagnostic { fn uri_to_file_location<'a>(uri: &'a Uri, range: &lsp::Range) -> Option<FileLocation<'a>> {
type Data = (DiagnosticStyles, DiagnosticsFormat); let path = uri.as_path()?;
let line = Some((range.start.line as usize, range.end.line as usize));
fn format(&self, (styles, format): &Self::Data) -> Row { Some((path.into(), line))
let mut style = self
.diag
.severity
.map(|s| match s {
DiagnosticSeverity::HINT => styles.hint,
DiagnosticSeverity::INFORMATION => styles.info,
DiagnosticSeverity::WARNING => styles.warning,
DiagnosticSeverity::ERROR => styles.error,
_ => Style::default(),
})
.unwrap_or_default();
// remove background as it is distracting in the picker list
style.bg = None;
let code = match self.diag.code.as_ref() {
Some(NumberOrString::Number(n)) => format!(" ({n})"),
Some(NumberOrString::String(s)) => format!(" ({s})"),
None => String::new(),
};
let path = match format {
DiagnosticsFormat::HideSourcePath => String::new(),
DiagnosticsFormat::ShowSourcePath => {
let path = path::get_truncated_path(&self.path);
format!("{}: ", path.to_string_lossy())
}
};
Spans::from(vec![
Span::raw(path),
Span::styled(&self.diag.message, style),
Span::styled(code, style),
])
.into()
}
}
fn location_to_file_location(location: &lsp::Location) -> FileLocation {
let path = location.uri.to_file_path().unwrap();
let line = Some((
location.range.start.line as usize,
location.range.end.line as usize,
));
(path.into(), line)
} }
fn jump_to_location( fn jump_to_location(
@ -241,20 +138,39 @@ fn jump_to_position(
} }
} }
type SymbolPicker = Picker<SymbolInformationItem>; fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str {
match kind {
fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker { lsp::SymbolKind::FILE => "file",
// TODO: drop current_path comparison and instead use workspace: bool flag? lsp::SymbolKind::MODULE => "module",
Picker::new(symbols, current_path, move |cx, item, action| { lsp::SymbolKind::NAMESPACE => "namespace",
jump_to_location( lsp::SymbolKind::PACKAGE => "package",
cx.editor, lsp::SymbolKind::CLASS => "class",
&item.symbol.location, lsp::SymbolKind::METHOD => "method",
item.offset_encoding, lsp::SymbolKind::PROPERTY => "property",
action, lsp::SymbolKind::FIELD => "field",
); lsp::SymbolKind::CONSTRUCTOR => "construct",
}) lsp::SymbolKind::ENUM => "enum",
.with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location))) lsp::SymbolKind::INTERFACE => "interface",
.truncate_start(false) lsp::SymbolKind::FUNCTION => "function",
lsp::SymbolKind::VARIABLE => "variable",
lsp::SymbolKind::CONSTANT => "constant",
lsp::SymbolKind::STRING => "string",
lsp::SymbolKind::NUMBER => "number",
lsp::SymbolKind::BOOLEAN => "boolean",
lsp::SymbolKind::ARRAY => "array",
lsp::SymbolKind::OBJECT => "object",
lsp::SymbolKind::KEY => "key",
lsp::SymbolKind::NULL => "null",
lsp::SymbolKind::ENUM_MEMBER => "enummem",
lsp::SymbolKind::STRUCT => "struct",
lsp::SymbolKind::EVENT => "event",
lsp::SymbolKind::OPERATOR => "operator",
lsp::SymbolKind::TYPE_PARAMETER => "typeparam",
_ => {
log::warn!("Unknown symbol kind: {:?}", kind);
""
}
}
} }
#[derive(Copy, Clone, PartialEq)] #[derive(Copy, Clone, PartialEq)]
@ -263,22 +179,24 @@ enum DiagnosticsFormat {
HideSourcePath, HideSourcePath,
} }
type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>;
fn diag_picker( fn diag_picker(
cx: &Context, cx: &Context,
diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>, diagnostics: BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
format: DiagnosticsFormat, format: DiagnosticsFormat,
) -> Picker<PickerDiagnostic> { ) -> DiagnosticsPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag? // TODO: drop current_path comparison and instead use workspace: bool flag?
// flatten the map to a vec of (url, diag) pairs // flatten the map to a vec of (url, diag) pairs
let mut flat_diag = Vec::new(); let mut flat_diag = Vec::new();
for (path, diags) in diagnostics { for (uri, diags) in diagnostics {
flat_diag.reserve(diags.len()); flat_diag.reserve(diags.len());
for (diag, ls) in diags { for (diag, ls) in diags {
if let Some(ls) = cx.editor.language_server_by_id(ls) { if let Some(ls) = cx.editor.language_server_by_id(ls) {
flat_diag.push(PickerDiagnostic { flat_diag.push(PickerDiagnostic {
path: path.clone(), uri: uri.clone(),
diag, diag,
offset_encoding: ls.offset_encoding(), offset_encoding: ls.offset_encoding(),
}); });
@ -293,22 +211,72 @@ fn diag_picker(
error: cx.editor.theme.get("error"), error: cx.editor.theme.get("error"),
}; };
let mut columns = vec![
ui::PickerColumn::new(
"severity",
|item: &PickerDiagnostic, styles: &DiagnosticStyles| {
match item.diag.severity {
Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint),
Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info),
Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning),
Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error),
_ => Span::raw(""),
}
.into()
},
),
ui::PickerColumn::new("code", |item: &PickerDiagnostic, _| {
match item.diag.code.as_ref() {
Some(NumberOrString::Number(n)) => n.to_string().into(),
Some(NumberOrString::String(s)) => s.as_str().into(),
None => "".into(),
}
}),
ui::PickerColumn::new("message", |item: &PickerDiagnostic, _| {
item.diag.message.as_str().into()
}),
];
let mut primary_column = 2; // message
if format == DiagnosticsFormat::ShowSourcePath {
columns.insert(
// between message code and message
2,
ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| {
if let Some(path) = item.uri.as_path() {
path::get_truncated_path(path)
.to_string_lossy()
.to_string()
.into()
} else {
Default::default()
}
}),
);
primary_column += 1;
}
Picker::new( Picker::new(
columns,
primary_column,
flat_diag, flat_diag,
(styles, format), styles,
move |cx, move |cx,
PickerDiagnostic { PickerDiagnostic {
path, uri,
diag, diag,
offset_encoding, offset_encoding,
}, },
action| { action| {
let Some(path) = uri.as_path() else {
return;
};
jump_to_position(cx.editor, path, diag.range, *offset_encoding, action) jump_to_position(cx.editor, path, diag.range, *offset_encoding, action)
}, },
) )
.with_preview(move |_editor, PickerDiagnostic { path, diag, .. }| { .with_preview(move |_editor, PickerDiagnostic { uri, diag, .. }| {
let line = Some((diag.range.start.line as usize, diag.range.end.line as usize)); let line = Some((diag.range.start.line as usize, diag.range.end.line as usize));
Some((path.clone().into(), line)) Some((uri.as_path()?.into(), line))
}) })
.truncate_start(false) .truncate_start(false)
} }
@ -317,6 +285,7 @@ pub fn symbol_picker(cx: &mut Context) {
fn nested_to_flat( fn nested_to_flat(
list: &mut Vec<SymbolInformationItem>, list: &mut Vec<SymbolInformationItem>,
file: &lsp::TextDocumentIdentifier, file: &lsp::TextDocumentIdentifier,
uri: &Uri,
symbol: lsp::DocumentSymbol, symbol: lsp::DocumentSymbol,
offset_encoding: OffsetEncoding, offset_encoding: OffsetEncoding,
) { ) {
@ -331,9 +300,10 @@ pub fn symbol_picker(cx: &mut Context) {
container_name: None, container_name: None,
}, },
offset_encoding, offset_encoding,
uri: uri.clone(),
}); });
for child in symbol.children.into_iter().flatten() { for child in symbol.children.into_iter().flatten() {
nested_to_flat(list, file, child, offset_encoding); nested_to_flat(list, file, uri, child, offset_encoding);
} }
} }
let doc = doc!(cx.editor); let doc = doc!(cx.editor);
@ -347,6 +317,9 @@ pub fn symbol_picker(cx: &mut Context) {
let request = language_server.document_symbols(doc.identifier()).unwrap(); let request = language_server.document_symbols(doc.identifier()).unwrap();
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
let doc_id = doc.identifier(); let doc_id = doc.identifier();
let doc_uri = doc
.uri()
.expect("docs with active language servers must be backed by paths");
async move { async move {
let json = request.await?; let json = request.await?;
@ -361,6 +334,7 @@ pub fn symbol_picker(cx: &mut Context) {
lsp::DocumentSymbolResponse::Flat(symbols) => symbols lsp::DocumentSymbolResponse::Flat(symbols) => symbols
.into_iter() .into_iter()
.map(|symbol| SymbolInformationItem { .map(|symbol| SymbolInformationItem {
uri: doc_uri.clone(),
symbol, symbol,
offset_encoding, offset_encoding,
}) })
@ -368,7 +342,13 @@ pub fn symbol_picker(cx: &mut Context) {
lsp::DocumentSymbolResponse::Nested(symbols) => { lsp::DocumentSymbolResponse::Nested(symbols) => {
let mut flat_symbols = Vec::new(); let mut flat_symbols = Vec::new();
for symbol in symbols { for symbol in symbols {
nested_to_flat(&mut flat_symbols, &doc_id, symbol, offset_encoding) nested_to_flat(
&mut flat_symbols,
&doc_id,
&doc_uri,
symbol,
offset_encoding,
)
} }
flat_symbols flat_symbols
} }
@ -377,7 +357,6 @@ pub fn symbol_picker(cx: &mut Context) {
} }
}) })
.collect(); .collect();
let current_url = doc.url();
if futures.is_empty() { if futures.is_empty() {
cx.editor cx.editor
@ -392,7 +371,37 @@ pub fn symbol_picker(cx: &mut Context) {
symbols.append(&mut lsp_items); symbols.append(&mut lsp_items);
} }
let call = move |_editor: &mut Editor, compositor: &mut Compositor| { let call = move |_editor: &mut Editor, compositor: &mut Compositor| {
let picker = sym_picker(symbols, current_url); let columns = [
ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| {
display_symbol_kind(item.symbol.kind).into()
}),
// Some symbols in the document symbol picker may have a URI that isn't
// the current file. It should be rare though, so we concatenate that
// URI in with the symbol name in this picker.
ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| {
item.symbol.name.as_str().into()
}),
];
let picker = Picker::new(
columns,
1, // name column
symbols,
(),
move |cx, item, action| {
jump_to_location(
cx.editor,
&item.symbol.location,
item.offset_encoding,
action,
);
},
)
.with_preview(move |_editor, item| {
uri_to_file_location(&item.uri, &item.symbol.location.range)
})
.truncate_start(false);
compositor.push(Box::new(overlaid(picker))) compositor.push(Box::new(overlaid(picker)))
}; };
@ -401,6 +410,8 @@ pub fn symbol_picker(cx: &mut Context) {
} }
pub fn workspace_symbol_picker(cx: &mut Context) { pub fn workspace_symbol_picker(cx: &mut Context) {
use crate::ui::picker::Injector;
let doc = doc!(cx.editor); let doc = doc!(cx.editor);
if doc if doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
@ -412,26 +423,38 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
return; return;
} }
let get_symbols = move |pattern: String, editor: &mut Editor| { let get_symbols = |pattern: &str, editor: &mut Editor, _data, injector: &Injector<_, _>| {
let doc = doc!(editor); let doc = doc!(editor);
let mut seen_language_servers = HashSet::new(); let mut seen_language_servers = HashSet::new();
let mut futures: FuturesOrdered<_> = doc let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
.filter(|ls| seen_language_servers.insert(ls.id())) .filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| { .map(|language_server| {
let request = language_server.workspace_symbols(pattern.clone()).unwrap(); let request = language_server
.workspace_symbols(pattern.to_string())
.unwrap();
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
async move { async move {
let json = request.await?; let json = request.await?;
let response = let response: Vec<_> =
serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)? serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)?
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|symbol| SymbolInformationItem { .filter_map(|symbol| {
let uri = match Uri::try_from(&symbol.location.uri) {
Ok(uri) => uri,
Err(err) => {
log::warn!("discarding symbol with invalid URI: {err}");
return None;
}
};
Some(SymbolInformationItem {
symbol, symbol,
uri,
offset_encoding, offset_encoding,
}) })
})
.collect(); .collect();
anyhow::Ok(response) anyhow::Ok(response)
@ -443,44 +466,66 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
editor.set_error("No configured language server supports workspace symbols"); editor.set_error("No configured language server supports workspace symbols");
} }
let injector = injector.clone();
async move { async move {
let mut symbols = Vec::new();
// TODO if one symbol request errors, all other requests are discarded (even if they're valid) // TODO if one symbol request errors, all other requests are discarded (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? { while let Some(lsp_items) = futures.try_next().await? {
symbols.append(&mut lsp_items); for item in lsp_items {
injector.push(item)?;
}
} }
anyhow::Ok(symbols) Ok(())
} }
.boxed() .boxed()
}; };
let columns = [
ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| {
display_symbol_kind(item.symbol.kind).into()
}),
ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| {
item.symbol.name.as_str().into()
})
.without_filtering(),
ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| {
if let Some(path) = item.uri.as_path() {
path::get_relative_path(path)
.to_string_lossy()
.to_string()
.into()
} else {
item.symbol.location.uri.to_string().into()
}
}),
];
let current_url = doc.url(); let picker = Picker::new(
let initial_symbols = get_symbols("".to_owned(), cx.editor); columns,
1, // name column
cx.jobs.callback(async move { [],
let symbols = initial_symbols.await?; (),
let call = move |_editor: &mut Editor, compositor: &mut Compositor| { move |cx, item, action| {
let picker = sym_picker(symbols, current_url); jump_to_location(
let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); cx.editor,
compositor.push(Box::new(overlaid(dyn_picker))) &item.symbol.location,
}; item.offset_encoding,
action,
);
},
)
.with_preview(|_editor, item| uri_to_file_location(&item.uri, &item.symbol.location.range))
.with_dynamic_query(get_symbols, None)
.truncate_start(false);
Ok(Callback::EditorCompositor(Box::new(call))) cx.push_layer(Box::new(overlaid(picker)));
});
} }
pub fn diagnostics_picker(cx: &mut Context) { pub fn diagnostics_picker(cx: &mut Context) {
let doc = doc!(cx.editor); let doc = doc!(cx.editor);
if let Some(current_path) = doc.path() { if let Some(uri) = doc.uri() {
let diagnostics = cx let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default();
.editor
.diagnostics
.get(current_path)
.cloned()
.unwrap_or_default();
let picker = diag_picker( let picker = diag_picker(
cx, cx,
[(current_path.clone(), diagnostics)].into(), [(uri, diagnostics)].into(),
DiagnosticsFormat::HideSourcePath, DiagnosticsFormat::HideSourcePath,
); );
cx.push_layer(Box::new(overlaid(picker))); cx.push_layer(Box::new(overlaid(picker)));
@ -741,13 +786,6 @@ pub fn code_action(cx: &mut Context) {
}); });
} }
impl ui::menu::Item for lsp::Command {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
self.title.as_str().into()
}
}
pub fn execute_lsp_command( pub fn execute_lsp_command(
editor: &mut Editor, editor: &mut Editor,
language_server_id: LanguageServerId, language_server_id: LanguageServerId,
@ -817,10 +855,67 @@ fn goto_impl(
} }
[] => unreachable!("`locations` should be non-empty for `goto_impl`"), [] => unreachable!("`locations` should be non-empty for `goto_impl`"),
_locations => { _locations => {
let picker = Picker::new(locations, cwdir, move |cx, location, action| { let columns = [ui::PickerColumn::new(
"location",
|item: &lsp::Location, cwdir: &std::path::PathBuf| {
// The preallocation here will overallocate a few characters since it will account for the
// URL's scheme, which is not used most of the time since that scheme will be "file://".
// Those extra chars will be used to avoid allocating when writing the line number (in the
// common case where it has 5 digits or less, which should be enough for a cast majority
// of usages).
let mut res = String::with_capacity(item.uri.as_str().len());
if item.uri.scheme() == "file" {
// With the preallocation above and UTF-8 paths already, this closure will do one (1)
// allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`.
if let Ok(path) = item.uri.to_file_path() {
// We don't convert to a `helix_core::Uri` here because we've already checked the scheme.
// This path won't be normalized but it's only used for display.
res.push_str(
&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy(),
);
}
} else {
// Never allocates since we declared the string with this capacity already.
res.push_str(item.uri.as_str());
}
// Most commonly, this will not allocate, especially on Unix systems where the root prefix
// is a simple `/` and not `C:\` (with whatever drive letter)
write!(&mut res, ":{}", item.range.start.line + 1)
.expect("Will only failed if allocating fail");
res.into()
},
)];
let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| {
jump_to_location(cx.editor, location, offset_encoding, action) jump_to_location(cx.editor, location, offset_encoding, action)
}) })
.with_preview(move |_editor, location| Some(location_to_file_location(location))); .with_preview(move |_editor, location| {
use crate::ui::picker::PathOrId;
let lines = Some((
location.range.start.line as usize,
location.range.end.line as usize,
));
// TODO: we should avoid allocating by doing the Uri conversion ahead of time.
//
// To do this, introduce a `Location` type in `helix-core` that reuses the core
// `Uri` type instead of the LSP `Url` type and replaces the LSP `Range` type.
// Refactor the callers of `goto_impl` to pass iterators that translate the
// LSP location type to the custom one in core, or have them collect and pass
// `Vec<Location>`s. Replace the `uri_to_file_location` function with
// `location_to_file_location` that takes only `&helix_core::Location` as
// parameters.
//
// By doing this we can also eliminate the duplicated URI info in the
// `SymbolInformationItem` type and introduce a custom Symbol type in `helix-core`
// which will be reused in the future for tree-sitter based symbol pickers.
let path = Uri::try_from(&location.uri).ok()?.as_path_buf()?;
#[allow(deprecated)]
Some((PathOrId::from_path_buf(path), lines))
});
compositor.push(Box::new(overlaid(picker))); compositor.push(Box::new(overlaid(picker)));
} }
} }

@ -9,7 +9,6 @@ use super::*;
use helix_core::fuzzy::fuzzy_match; use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT; use helix_core::indent::MAX_INDENT;
use helix_core::{line_ending, shellwords::Shellwords}; use helix_core::{line_ending, shellwords::Shellwords};
use helix_lsp::LanguageServerId;
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent}; use helix_view::editor::{CloseError, ConfigEvent};
use serde_json::Value; use serde_json::Value;
@ -1378,16 +1377,6 @@ fn lsp_workspace_command(
return Ok(()); return Ok(());
} }
struct LsIdCommand(LanguageServerId, helix_lsp::lsp::Command);
impl ui::menu::Item for LsIdCommand {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
self.1.title.as_str().into()
}
}
let doc = doc!(cx.editor); let doc = doc!(cx.editor);
let ls_id_commands = doc let ls_id_commands = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
@ -1402,7 +1391,7 @@ fn lsp_workspace_command(
if args.is_empty() { if args.is_empty() {
let commands = ls_id_commands let commands = ls_id_commands
.map(|(ls_id, command)| { .map(|(ls_id, command)| {
LsIdCommand( (
ls_id, ls_id,
helix_lsp::lsp::Command { helix_lsp::lsp::Command {
title: command.clone(), title: command.clone(),
@ -1415,10 +1404,18 @@ fn lsp_workspace_command(
let callback = async move { let callback = async move {
let call: job::Callback = Callback::EditorCompositor(Box::new( let call: job::Callback = Callback::EditorCompositor(Box::new(
move |_editor: &mut Editor, compositor: &mut Compositor| { move |_editor: &mut Editor, compositor: &mut Compositor| {
let columns = [ui::PickerColumn::new(
"title",
|(_ls_id, command): &(_, helix_lsp::lsp::Command), _| {
command.title.as_str().into()
},
)];
let picker = ui::Picker::new( let picker = ui::Picker::new(
columns,
0,
commands, commands,
(), (),
move |cx, LsIdCommand(ls_id, command), _action| { move |cx, (ls_id, command), _action| {
execute_lsp_command(cx.editor, *ls_id, command.clone()); execute_lsp_command(cx.editor, *ls_id, command.clone());
}, },
); );

@ -1,11 +1,11 @@
use std::{borrow::Cow, cmp::Reverse, path::PathBuf}; use std::{borrow::Cow, cmp::Reverse};
use crate::{ use crate::{
compositor::{Callback, Component, Compositor, Context, Event, EventResult}, compositor::{Callback, Component, Compositor, Context, Event, EventResult},
ctrl, key, shift, ctrl, key, shift,
}; };
use helix_core::fuzzy::MATCHER; use helix_core::fuzzy::MATCHER;
use nucleo::pattern::{Atom, AtomKind, CaseMatching}; use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::{Config, Utf32Str}; use nucleo::{Config, Utf32Str};
use tui::{buffer::Buffer as Surface, widgets::Table}; use tui::{buffer::Buffer as Surface, widgets::Table};
@ -31,18 +31,6 @@ pub trait Item: Sync + Send + 'static {
} }
} }
impl Item for PathBuf {
/// Root prefix to strip.
type Data = PathBuf;
fn format(&self, root_path: &Self::Data) -> Row {
self.strip_prefix(root_path)
.unwrap_or(self)
.to_string_lossy()
.into()
}
}
pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>; pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>;
pub struct Menu<T: Item> { pub struct Menu<T: Item> {
@ -92,7 +80,13 @@ impl<T: Item> Menu<T> {
pub fn score(&mut self, pattern: &str, incremental: bool) { pub fn score(&mut self, pattern: &str, incremental: bool) {
let mut matcher = MATCHER.lock(); let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT; matcher.config = Config::DEFAULT;
let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false); let pattern = Atom::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let mut buf = Vec::new(); let mut buf = Vec::new();
if incremental { if incremental {
self.matches.retain_mut(|(index, score)| { self.matches.retain_mut(|(index, score)| {

@ -21,7 +21,7 @@ pub use editor::EditorView;
use helix_stdx::rope; use helix_stdx::rope;
pub use markdown::Markdown; pub use markdown::Markdown;
pub use menu::Menu; pub use menu::Menu;
pub use picker::{DynamicPicker, FileLocation, Picker}; pub use picker::{Column as PickerColumn, FileLocation, Picker};
pub use popup::Popup; pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent}; pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner}; pub use spinner::{ProgressSpinners, Spinner};
@ -170,7 +170,9 @@ pub fn raw_regex_prompt(
cx.push_layer(Box::new(prompt)); cx.push_layer(Box::new(prompt));
} }
pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> { type FilePicker = Picker<PathBuf, PathBuf>;
pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker {
use ignore::{types::TypesBuilder, WalkBuilder}; use ignore::{types::TypesBuilder, WalkBuilder};
use std::time::Instant; use std::time::Instant;
@ -217,7 +219,16 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
}); });
log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| { let columns = [PickerColumn::new(
"path",
|item: &PathBuf, root: &PathBuf| {
item.strip_prefix(root)
.unwrap_or(item)
.to_string_lossy()
.into()
},
)];
let picker = Picker::new(columns, 0, [], root, move |cx, path: &PathBuf, action| {
if let Err(e) = cx.editor.open(path, action) { if let Err(e) = cx.editor.open(path, action) {
let err = if let Some(err) = e.source() { let err = if let Some(err) = e.source() {
format!("{}", err) format!("{}", err)
@ -227,7 +238,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
cx.editor.set_error(err); cx.editor.set_error(err);
} }
}) })
.with_preview(|_editor, path| Some((path.clone().into(), None))); .with_preview(|_editor, path| Some((path.as_path().into(), None)));
let injector = picker.injector(); let injector = picker.injector();
let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30); let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30);

File diff suppressed because it is too large Load Diff

@ -0,0 +1,182 @@
use std::{
path::Path,
sync::{atomic, Arc},
time::Duration,
};
use helix_event::AsyncHook;
use tokio::time::Instant;
use crate::{job, ui::overlay::Overlay};
use super::{CachedPreview, DynQueryCallback, Picker};
pub(super) struct PreviewHighlightHandler<T: 'static + Send + Sync, D: 'static + Send + Sync> {
trigger: Option<Arc<Path>>,
phantom_data: std::marker::PhantomData<(T, D)>,
}
impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Default for PreviewHighlightHandler<T, D> {
fn default() -> Self {
Self {
trigger: None,
phantom_data: Default::default(),
}
}
}
impl<T: 'static + Send + Sync, D: 'static + Send + Sync> AsyncHook
for PreviewHighlightHandler<T, D>
{
type Event = Arc<Path>;
fn handle_event(
&mut self,
path: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
if self
.trigger
.as_ref()
.is_some_and(|trigger| trigger == &path)
{
// If the path hasn't changed, don't reset the debounce
timeout
} else {
self.trigger = Some(path);
Some(Instant::now() + Duration::from_millis(150))
}
}
fn finish_debounce(&mut self) {
let Some(path) = self.trigger.take() else {
return;
};
job::dispatch_blocking(move |editor, compositor| {
let Some(Overlay {
content: picker, ..
}) = compositor.find::<Overlay<Picker<T, D>>>()
else {
return;
};
let Some(CachedPreview::Document(ref mut doc)) = picker.preview_cache.get_mut(&path)
else {
return;
};
if doc.language_config().is_some() {
return;
}
let Some(language_config) = doc.detect_language_config(&editor.syn_loader.load())
else {
return;
};
doc.language = Some(language_config.clone());
let text = doc.text().clone();
let loader = editor.syn_loader.clone();
tokio::task::spawn_blocking(move || {
let Some(syntax) = language_config
.highlight_config(&loader.load().scopes())
.and_then(|highlight_config| {
helix_core::Syntax::new(text.slice(..), highlight_config, loader)
})
else {
log::info!("highlighting picker item failed");
return;
};
job::dispatch_blocking(move |editor, compositor| {
let Some(Overlay {
content: picker, ..
}) = compositor.find::<Overlay<Picker<T, D>>>()
else {
log::info!("picker closed before syntax highlighting finished");
return;
};
let Some(CachedPreview::Document(ref mut doc)) =
picker.preview_cache.get_mut(&path)
else {
return;
};
let diagnostics = helix_view::Editor::doc_diagnostics(
&editor.language_servers,
&editor.diagnostics,
doc,
);
doc.replace_diagnostics(diagnostics, &[], None);
doc.syntax = Some(syntax);
});
});
});
}
}
pub(super) struct DynamicQueryHandler<T: 'static + Send + Sync, D: 'static + Send + Sync> {
callback: Arc<DynQueryCallback<T, D>>,
// Duration used as a debounce.
// Defaults to 100ms if not provided via `Picker::with_dynamic_query`. Callers may want to set
// this higher if the dynamic query is expensive - for example global search.
debounce: Duration,
last_query: Arc<str>,
query: Option<Arc<str>>,
}
impl<T: 'static + Send + Sync, D: 'static + Send + Sync> DynamicQueryHandler<T, D> {
pub(super) fn new(callback: DynQueryCallback<T, D>, duration_ms: Option<u64>) -> Self {
Self {
callback: Arc::new(callback),
debounce: Duration::from_millis(duration_ms.unwrap_or(100)),
last_query: "".into(),
query: None,
}
}
}
impl<T: 'static + Send + Sync, D: 'static + Send + Sync> AsyncHook for DynamicQueryHandler<T, D> {
type Event = Arc<str>;
fn handle_event(&mut self, query: Self::Event, _timeout: Option<Instant>) -> Option<Instant> {
if query == self.last_query {
// If the search query reverts to the last one we requested, no need to
// make a new request.
self.query = None;
None
} else {
self.query = Some(query);
Some(Instant::now() + self.debounce)
}
}
fn finish_debounce(&mut self) {
let Some(query) = self.query.take() else {
return;
};
self.last_query = query.clone();
let callback = self.callback.clone();
job::dispatch_blocking(move |editor, compositor| {
let Some(Overlay {
content: picker, ..
}) = compositor.find::<Overlay<Picker<T, D>>>()
else {
return;
};
// Increment the version number to cancel any ongoing requests.
picker.version.fetch_add(1, atomic::Ordering::Relaxed);
picker.matcher.restart(false);
let injector = picker.injector();
let get_options = (callback)(&query, editor, picker.editor_data.clone(), &injector);
tokio::spawn(async move {
if let Err(err) = get_options.await {
log::info!("Dynamic request failed: {err}");
}
// NOTE: the Drop implementation of Injector will request a redraw when the
// injector falls out of scope here, clearing the "running" indicator.
});
})
}
}

@ -0,0 +1,368 @@
use std::{collections::HashMap, mem, ops::Range, sync::Arc};
#[derive(Debug)]
pub(super) struct PickerQuery {
/// The column names of the picker.
column_names: Box<[Arc<str>]>,
/// The index of the primary column in `column_names`.
/// The primary column is selected by default unless another
/// field is specified explicitly with `%fieldname`.
primary_column: usize,
/// The mapping between column names and input in the query
/// for those columns.
inner: HashMap<Arc<str>, Arc<str>>,
/// The byte ranges of the input text which are used as input for each column.
/// This is calculated at parsing time for use in [Self::active_column].
/// This Vec is naturally sorted in ascending order and ranges do not overlap.
column_ranges: Vec<(Range<usize>, Option<Arc<str>>)>,
}
impl PartialEq<HashMap<Arc<str>, Arc<str>>> for PickerQuery {
fn eq(&self, other: &HashMap<Arc<str>, Arc<str>>) -> bool {
self.inner.eq(other)
}
}
impl PickerQuery {
pub(super) fn new<I: Iterator<Item = Arc<str>>>(
column_names: I,
primary_column: usize,
) -> Self {
let column_names: Box<[_]> = column_names.collect();
let inner = HashMap::with_capacity(column_names.len());
let column_ranges = vec![(0..usize::MAX, Some(column_names[primary_column].clone()))];
Self {
column_names,
primary_column,
inner,
column_ranges,
}
}
pub(super) fn get(&self, column: &str) -> Option<&Arc<str>> {
self.inner.get(column)
}
pub(super) fn parse(&mut self, input: &str) -> HashMap<Arc<str>, Arc<str>> {
let mut fields: HashMap<Arc<str>, String> = HashMap::new();
let primary_field = &self.column_names[self.primary_column];
let mut escaped = false;
let mut in_field = false;
let mut field = None;
let mut text = String::new();
self.column_ranges.clear();
self.column_ranges
.push((0..usize::MAX, Some(primary_field.clone())));
macro_rules! finish_field {
() => {
let key = field.take().unwrap_or(primary_field);
if let Some(pattern) = fields.get_mut(key) {
pattern.push(' ');
pattern.push_str(text.trim());
} else {
fields.insert(key.clone(), text.trim().to_string());
}
text.clear();
};
}
for (idx, ch) in input.char_indices() {
match ch {
// Backslash escaping
_ if escaped => {
// '%' is the only character that is special cased.
// You can escape it to prevent parsing the text that
// follows it as a field name.
if ch != '%' {
text.push('\\');
}
text.push(ch);
escaped = false;
}
'\\' => escaped = !escaped,
'%' => {
if !text.is_empty() {
finish_field!();
}
let (range, _field) = self
.column_ranges
.last_mut()
.expect("column_ranges is non-empty");
range.end = idx;
in_field = true;
}
' ' if in_field => {
text.clear();
in_field = false;
}
_ if in_field => {
text.push(ch);
// Go over all columns and their indices, find all that starts with field key,
// select a column that fits key the most.
field = self
.column_names
.iter()
.filter(|col| col.starts_with(&text))
// select "fittest" column
.min_by_key(|col| col.len());
// Update the column range for this column.
if let Some((_range, current_field)) = self
.column_ranges
.last_mut()
.filter(|(range, _)| range.end == usize::MAX)
{
*current_field = field.cloned();
} else {
self.column_ranges.push((idx..usize::MAX, field.cloned()));
}
}
_ => text.push(ch),
}
}
if !in_field && !text.is_empty() {
finish_field!();
}
let new_inner: HashMap<_, _> = fields
.into_iter()
.map(|(field, query)| (field, query.as_str().into()))
.collect();
mem::replace(&mut self.inner, new_inner)
}
/// Finds the column which the cursor is 'within' in the last parse.
///
/// The cursor is considered to be within a column when it is placed within any
/// of a column's text. See the `active_column_test` unit test below for examples.
///
/// `cursor` is a byte index that represents the location of the prompt's cursor.
pub fn active_column(&self, cursor: usize) -> Option<&Arc<str>> {
let point = self
.column_ranges
.partition_point(|(range, _field)| cursor > range.end);
self.column_ranges
.get(point)
.filter(|(range, _field)| cursor >= range.start && cursor <= range.end)
.and_then(|(_range, field)| field.as_ref())
}
}
#[cfg(test)]
mod test {
use helix_core::hashmap;
use super::*;
#[test]
fn parse_query_test() {
let mut query = PickerQuery::new(
[
"primary".into(),
"field1".into(),
"field2".into(),
"another".into(),
"anode".into(),
]
.into_iter(),
0,
);
// Basic field splitting
query.parse("hello world");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello world".into(),
)
);
query.parse("hello %field1 world %field2 !");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"field1".into() => "world".into(),
"field2".into() => "!".into(),
)
);
query.parse("%field1 abc %field2 def xyz");
assert_eq!(
query,
hashmap!(
"field1".into() => "abc".into(),
"field2".into() => "def xyz".into(),
)
);
// Trailing space is trimmed
query.parse("hello ");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
)
);
// Unknown fields are trimmed.
query.parse("hello %foo");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
)
);
// Multiple words in a field
query.parse("hello %field1 a b c");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"field1".into() => "a b c".into(),
)
);
// Escaping
query.parse(r#"hello\ world"#);
assert_eq!(
query,
hashmap!(
"primary".into() => r#"hello\ world"#.into(),
)
);
query.parse(r#"hello \%field1 world"#);
assert_eq!(
query,
hashmap!(
"primary".into() => "hello %field1 world".into(),
)
);
query.parse(r#"%field1 hello\ world"#);
assert_eq!(
query,
hashmap!(
"field1".into() => r#"hello\ world"#.into(),
)
);
query.parse(r#"hello %field1 a\"b"#);
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"field1".into() => r#"a\"b"#.into(),
)
);
query.parse(r#"%field1 hello\ world"#);
assert_eq!(
query,
hashmap!(
"field1".into() => r#"hello\ world"#.into(),
)
);
query.parse(r#"\bfoo\b"#);
assert_eq!(
query,
hashmap!(
"primary".into() => r#"\bfoo\b"#.into(),
)
);
query.parse(r#"\\n"#);
assert_eq!(
query,
hashmap!(
"primary".into() => r#"\\n"#.into(),
)
);
// Only the prefix of a field is required.
query.parse("hello %anot abc");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"another".into() => "abc".into(),
)
);
// The shortest matching the prefix is selected.
query.parse("hello %ano abc");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"anode".into() => "abc".into()
)
);
// Multiple uses of a column are concatenated with space separators.
query.parse("hello %field1 xyz %fie abc");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"field1".into() => "xyz abc".into()
)
);
query.parse("hello %fie abc");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello".into(),
"field1".into() => "abc".into()
)
);
// The primary column can be explicitly qualified.
query.parse("hello %fie abc %prim world");
assert_eq!(
query,
hashmap!(
"primary".into() => "hello world".into(),
"field1".into() => "abc".into()
)
);
}
#[test]
fn active_column_test() {
fn active_column<'a>(query: &'a mut PickerQuery, input: &str) -> Option<&'a str> {
let cursor = input.find('|').expect("cursor must be indicated with '|'");
let input = input.replace('|', "");
query.parse(&input);
query.active_column(cursor).map(AsRef::as_ref)
}
let mut query = PickerQuery::new(
["primary".into(), "foo".into(), "bar".into()].into_iter(),
0,
);
assert_eq!(active_column(&mut query, "|"), Some("primary"));
assert_eq!(active_column(&mut query, "hello| world"), Some("primary"));
assert_eq!(active_column(&mut query, "|%foo hello"), Some("primary"));
assert_eq!(active_column(&mut query, "%foo|"), Some("foo"));
assert_eq!(active_column(&mut query, "%|"), None);
assert_eq!(active_column(&mut query, "%baz|"), None);
assert_eq!(active_column(&mut query, "%quiz%|"), None);
assert_eq!(active_column(&mut query, "%foo hello| world"), Some("foo"));
assert_eq!(active_column(&mut query, "%foo hello world|"), Some("foo"));
assert_eq!(active_column(&mut query, "%foo| hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "%|foo hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "%f|oo hello world"), Some("foo"));
assert_eq!(active_column(&mut query, "hello %f|oo world"), Some("foo"));
assert_eq!(
active_column(&mut query, "hello %f|oo world %bar !"),
Some("foo")
);
assert_eq!(
active_column(&mut query, "hello %foo wo|rld %bar !"),
Some("foo")
);
assert_eq!(
active_column(&mut query, "hello %foo world %bar !|"),
Some("bar")
);
}
}

@ -92,12 +92,22 @@ impl Prompt {
} }
} }
/// Gets the byte index in the input representing the current cursor location.
#[inline]
pub(crate) fn position(&self) -> usize {
self.cursor
}
pub fn with_line(mut self, line: String, editor: &Editor) -> Self { pub fn with_line(mut self, line: String, editor: &Editor) -> Self {
self.set_line(line, editor);
self
}
pub fn set_line(&mut self, line: String, editor: &Editor) {
let cursor = line.len(); let cursor = line.len();
self.line = line; self.line = line;
self.cursor = cursor; self.cursor = cursor;
self.recalculate_completion(editor); self.recalculate_completion(editor);
self
} }
pub fn with_language( pub fn with_language(
@ -113,6 +123,19 @@ impl Prompt {
&self.line &self.line
} }
pub fn with_history_register(&mut self, history_register: Option<char>) -> &mut Self {
self.history_register = history_register;
self
}
pub(crate) fn first_history_completion<'a>(
&'a self,
editor: &'a Editor,
) -> Option<Cow<'a, str>> {
self.history_register
.and_then(|reg| editor.registers.first(reg, editor))
}
pub fn recalculate_completion(&mut self, editor: &Editor) { pub fn recalculate_completion(&mut self, editor: &Editor) {
self.exit_selection(); self.exit_selection();
self.completion = (self.completion_fn)(editor, &self.line); self.completion = (self.completion_fn)(editor, &self.line);
@ -476,10 +499,7 @@ impl Prompt {
let line_area = area.clip_left(self.prompt.len() as u16).clip_top(line); let line_area = area.clip_left(self.prompt.len() as u16).clip_top(line);
if self.line.is_empty() { if self.line.is_empty() {
// Show the most recently entered value as a suggestion. // Show the most recently entered value as a suggestion.
if let Some(suggestion) = self if let Some(suggestion) = self.first_history_completion(cx.editor) {
.history_register
.and_then(|reg| cx.editor.registers.first(reg, cx.editor))
{
surface.set_string(line_area.x, line_area.y, suggestion, suggestion_color); surface.set_string(line_area.x, line_area.y, suggestion, suggestion_color);
} }
} else if let Some((language, loader)) = self.language.as_ref() { } else if let Some((language, loader)) = self.language.as_ref() {
@ -574,8 +594,7 @@ impl Component for Prompt {
self.recalculate_completion(cx.editor); self.recalculate_completion(cx.editor);
} else { } else {
let last_item = self let last_item = self
.history_register .first_history_completion(cx.editor)
.and_then(|reg| cx.editor.registers.first(reg, cx.editor))
.map(|entry| entry.to_string()) .map(|entry| entry.to_string())
.unwrap_or_else(|| String::from("")); .unwrap_or_else(|| String::from(""));

@ -1741,6 +1741,10 @@ impl Document {
Url::from_file_path(self.path()?).ok() Url::from_file_path(self.path()?).ok()
} }
pub fn uri(&self) -> Option<helix_core::Uri> {
Some(self.path()?.clone().into())
}
#[inline] #[inline]
pub fn text(&self) -> &Rope { pub fn text(&self) -> &Rope {
&self.text &self.text

@ -44,7 +44,7 @@ pub use helix_core::diagnostic::Severity;
use helix_core::{ use helix_core::{
auto_pairs::AutoPairs, auto_pairs::AutoPairs,
syntax::{self, AutoPairConfig, IndentationHeuristic, LanguageServerFeature, SoftWrap}, syntax::{self, AutoPairConfig, IndentationHeuristic, LanguageServerFeature, SoftWrap},
Change, LineEnding, Position, Range, Selection, NATIVE_LINE_ENDING, Change, LineEnding, Position, Range, Selection, Uri, NATIVE_LINE_ENDING,
}; };
use helix_dap as dap; use helix_dap as dap;
use helix_lsp::lsp; use helix_lsp::lsp;
@ -1022,7 +1022,7 @@ pub struct Editor {
pub macro_recording: Option<(char, Vec<KeyEvent>)>, pub macro_recording: Option<(char, Vec<KeyEvent>)>,
pub macro_replaying: Vec<char>, pub macro_replaying: Vec<char>,
pub language_servers: helix_lsp::Registry, pub language_servers: helix_lsp::Registry,
pub diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>, pub diagnostics: BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
pub diff_providers: DiffProviderRegistry, pub diff_providers: DiffProviderRegistry,
pub debugger: Option<dap::Client>, pub debugger: Option<dap::Client>,
@ -1931,7 +1931,7 @@ impl Editor {
/// Returns all supported diagnostics for the document /// Returns all supported diagnostics for the document
pub fn doc_diagnostics<'a>( pub fn doc_diagnostics<'a>(
language_servers: &'a helix_lsp::Registry, language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>, diagnostics: &'a BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document, document: &Document,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a { ) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
Editor::doc_diagnostics_with_filter(language_servers, diagnostics, document, |_, _| true) Editor::doc_diagnostics_with_filter(language_servers, diagnostics, document, |_, _| true)
@ -1941,15 +1941,15 @@ impl Editor {
/// filtered by `filter` which is invocated with the raw `lsp::Diagnostic` and the language server id it came from /// filtered by `filter` which is invocated with the raw `lsp::Diagnostic` and the language server id it came from
pub fn doc_diagnostics_with_filter<'a>( pub fn doc_diagnostics_with_filter<'a>(
language_servers: &'a helix_lsp::Registry, language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>, diagnostics: &'a BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document, document: &Document,
filter: impl Fn(&lsp::Diagnostic, LanguageServerId) -> bool + 'a, filter: impl Fn(&lsp::Diagnostic, LanguageServerId) -> bool + 'a,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a { ) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
let text = document.text().clone(); let text = document.text().clone();
let language_config = document.language.clone(); let language_config = document.language.clone();
document document
.path() .uri()
.and_then(|path| diagnostics.get(path)) .and_then(|uri| diagnostics.get(&uri))
.map(|diags| { .map(|diags| {
diags.iter().filter_map(move |(diagnostic, lsp_id)| { diags.iter().filter_map(move |(diagnostic, lsp_id)| {
let ls = language_servers.get_by_id(*lsp_id)?; let ls = language_servers.get_by_id(*lsp_id)?;

@ -1,6 +1,7 @@
use crate::editor::Action; use crate::editor::Action;
use crate::Editor; use crate::Editor;
use crate::{DocumentId, ViewId}; use crate::{DocumentId, ViewId};
use helix_core::Uri;
use helix_lsp::util::generate_transaction_from_edits; use helix_lsp::util::generate_transaction_from_edits;
use helix_lsp::{lsp, OffsetEncoding}; use helix_lsp::{lsp, OffsetEncoding};
@ -54,18 +55,30 @@ pub struct ApplyEditError {
pub enum ApplyEditErrorKind { pub enum ApplyEditErrorKind {
DocumentChanged, DocumentChanged,
FileNotFound, FileNotFound,
UnknownURISchema, InvalidUrl(helix_core::uri::UrlConversionError),
IoError(std::io::Error), IoError(std::io::Error),
// TODO: check edits before applying and propagate failure // TODO: check edits before applying and propagate failure
// InvalidEdit, // InvalidEdit,
} }
impl From<std::io::Error> for ApplyEditErrorKind {
fn from(err: std::io::Error) -> Self {
ApplyEditErrorKind::IoError(err)
}
}
impl From<helix_core::uri::UrlConversionError> for ApplyEditErrorKind {
fn from(err: helix_core::uri::UrlConversionError) -> Self {
ApplyEditErrorKind::InvalidUrl(err)
}
}
impl ToString for ApplyEditErrorKind { impl ToString for ApplyEditErrorKind {
fn to_string(&self) -> String { fn to_string(&self) -> String {
match self { match self {
ApplyEditErrorKind::DocumentChanged => "document has changed".to_string(), ApplyEditErrorKind::DocumentChanged => "document has changed".to_string(),
ApplyEditErrorKind::FileNotFound => "file not found".to_string(), ApplyEditErrorKind::FileNotFound => "file not found".to_string(),
ApplyEditErrorKind::UnknownURISchema => "URI schema not supported".to_string(), ApplyEditErrorKind::InvalidUrl(err) => err.to_string(),
ApplyEditErrorKind::IoError(err) => err.to_string(), ApplyEditErrorKind::IoError(err) => err.to_string(),
} }
} }
@ -74,25 +87,28 @@ impl ToString for ApplyEditErrorKind {
impl Editor { impl Editor {
fn apply_text_edits( fn apply_text_edits(
&mut self, &mut self,
uri: &helix_lsp::Url, url: &helix_lsp::Url,
version: Option<i32>, version: Option<i32>,
text_edits: Vec<lsp::TextEdit>, text_edits: Vec<lsp::TextEdit>,
offset_encoding: OffsetEncoding, offset_encoding: OffsetEncoding,
) -> Result<(), ApplyEditErrorKind> { ) -> Result<(), ApplyEditErrorKind> {
let path = match uri.to_file_path() { let uri = match Uri::try_from(url) {
Ok(path) => path, Ok(uri) => uri,
Err(_) => { Err(err) => {
let err = format!("unable to convert URI to filepath: {}", uri); log::error!("{err}");
log::error!("{}", err); return Err(err.into());
self.set_error(err);
return Err(ApplyEditErrorKind::UnknownURISchema);
} }
}; };
let path = uri.as_path().expect("URIs are valid paths");
let doc_id = match self.open(&path, Action::Load) { let doc_id = match self.open(path, Action::Load) {
Ok(doc_id) => doc_id, Ok(doc_id) => doc_id,
Err(err) => { Err(err) => {
let err = format!("failed to open document: {}: {}", uri, err); let err = format!(
"failed to open document: {}: {}",
path.to_string_lossy(),
err
);
log::error!("{}", err); log::error!("{}", err);
self.set_error(err); self.set_error(err);
return Err(ApplyEditErrorKind::FileNotFound); return Err(ApplyEditErrorKind::FileNotFound);
@ -158,9 +174,9 @@ impl Editor {
for (i, operation) in operations.iter().enumerate() { for (i, operation) in operations.iter().enumerate() {
match operation { match operation {
lsp::DocumentChangeOperation::Op(op) => { lsp::DocumentChangeOperation::Op(op) => {
self.apply_document_resource_op(op).map_err(|io| { self.apply_document_resource_op(op).map_err(|err| {
ApplyEditError { ApplyEditError {
kind: ApplyEditErrorKind::IoError(io), kind: err,
failed_change_idx: i, failed_change_idx: i,
} }
})?; })?;
@ -214,12 +230,18 @@ impl Editor {
Ok(()) Ok(())
} }
fn apply_document_resource_op(&mut self, op: &lsp::ResourceOp) -> std::io::Result<()> { fn apply_document_resource_op(
&mut self,
op: &lsp::ResourceOp,
) -> Result<(), ApplyEditErrorKind> {
use lsp::ResourceOp; use lsp::ResourceOp;
use std::fs; use std::fs;
// NOTE: If `Uri` gets another variant than `Path`, the below `expect`s
// may no longer be valid.
match op { match op {
ResourceOp::Create(op) => { ResourceOp::Create(op) => {
let path = op.uri.to_file_path().unwrap(); let uri = Uri::try_from(&op.uri)?;
let path = uri.as_path_buf().expect("URIs are valid paths");
let ignore_if_exists = op.options.as_ref().map_or(false, |options| { let ignore_if_exists = op.options.as_ref().map_or(false, |options| {
!options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
}); });
@ -236,7 +258,8 @@ impl Editor {
} }
} }
ResourceOp::Delete(op) => { ResourceOp::Delete(op) => {
let path = op.uri.to_file_path().unwrap(); let uri = Uri::try_from(&op.uri)?;
let path = uri.as_path_buf().expect("URIs are valid paths");
if path.is_dir() { if path.is_dir() {
let recursive = op let recursive = op
.options .options
@ -251,17 +274,19 @@ impl Editor {
} }
self.language_servers.file_event_handler.file_changed(path); self.language_servers.file_event_handler.file_changed(path);
} else if path.is_file() { } else if path.is_file() {
fs::remove_file(&path)?; fs::remove_file(path)?;
} }
} }
ResourceOp::Rename(op) => { ResourceOp::Rename(op) => {
let from = op.old_uri.to_file_path().unwrap(); let from_uri = Uri::try_from(&op.old_uri)?;
let to = op.new_uri.to_file_path().unwrap(); let from = from_uri.as_path().expect("URIs are valid paths");
let to_uri = Uri::try_from(&op.new_uri)?;
let to = to_uri.as_path().expect("URIs are valid paths");
let ignore_if_exists = op.options.as_ref().map_or(false, |options| { let ignore_if_exists = op.options.as_ref().map_or(false, |options| {
!options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
}); });
if !ignore_if_exists || !to.exists() { if !ignore_if_exists || !to.exists() {
self.move_path(&from, &to)?; self.move_path(from, to)?;
} }
} }
} }

Loading…
Cancel
Save