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

`Picker`s "v2"
This commit is contained in:
Blaž Hrastnik 2024-07-15 23:30:58 +09:00 committed by GitHub
commit 08ee8b9443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1857 additions and 978 deletions

17
Cargo.lock generated
View file

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

View file

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

View file

@ -297,6 +297,8 @@ These scopes are used for theming the editor interface:
| `ui.bufferline.background` | Style for bufferline background |
| `ui.popup` | Documentation popups (e.g. Space + k) |
| `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.help` | Description box for commands |
| `ui.text` | Default text style, command prompts, popup text, etc. |

View file

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

View file

@ -1,6 +1,6 @@
use std::ops::DerefMut;
use nucleo::pattern::{Atom, AtomKind, CaseMatching};
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::Config;
use parking_lot::Mutex;
@ -38,6 +38,12 @@ pub fn fuzzy_match<T: AsRef<str>>(
if path {
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)
}

View file

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

122
helix-core/src/uri.rs Normal file
View file

@ -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,
..
})
));
}
}

View file

@ -34,7 +34,9 @@
use anyhow::Result;
pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx};
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;
mod cancel;

View file

@ -51,3 +51,12 @@ pub fn start_frame() {
pub fn lock_frame() -> RenderLockGuard {
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();
}
}

View file

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

View file

@ -735,10 +735,10 @@ impl Application {
}
}
Notification::PublishDiagnostics(mut params) => {
let path = match params.uri.to_file_path() {
Ok(path) => helix_stdx::path::normalize(path),
Err(_) => {
log::error!("Unsupported file URI: {}", params.uri);
let uri = match helix_core::Uri::try_from(params.uri) {
Ok(uri) => uri,
Err(err) => {
log::error!("{err}");
return;
}
};
@ -749,11 +749,11 @@ impl Application {
}
// have to inline the function because of borrow checking...
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| {
if let Some(version) = params.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;
}
}
@ -765,7 +765,7 @@ impl Application {
let lang_conf = doc.language.clone();
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() {
// Sort diagnostics first by severity and then by line numbers.
// 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
// 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.
let diagnostics = match self.editor.diagnostics.entry(path) {
let diagnostics = match self.editor.diagnostics.entry(uri) {
Entry::Occupied(o) => {
let current_diagnostics = o.into_mut();
// there may entries of other language servers, which is why we can't overwrite the whole entry
@ -1132,20 +1132,22 @@ impl Application {
..
} = params;
let path = match uri.to_file_path() {
Ok(path) => path,
let uri = match helix_core::Uri::try_from(uri) {
Ok(uri) => uri,
Err(err) => {
log::error!("unsupported file URI: {}: {:?}", uri, 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) {
let doc_id = match self.editor.open(path, action) {
Ok(id) => id,
Err(err) => {
log::error!("failed to open path: {:?}: {:?}", uri, err);

View file

@ -3,6 +3,7 @@ pub(crate) mod lsp;
pub(crate) mod typed;
pub use dap::*;
use futures_util::FutureExt;
use helix_event::status;
use helix_stdx::{
path::expand_tilde,
@ -10,10 +11,7 @@ use helix_stdx::{
};
use helix_vcs::{FileChange, Hunk};
pub use lsp::*;
use tui::{
text::Span,
widgets::{Cell, Row},
};
use tui::text::Span;
pub use typed::*;
use helix_core::{
@ -61,8 +59,7 @@ use crate::{
compositor::{self, Component, Compositor},
filter_picker_entry,
job::Callback,
keymap::ReverseKeymap,
ui::{self, menu::Item, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent},
};
use crate::job::{self, Jobs};
@ -2257,216 +2254,193 @@ fn global_search(cx: &mut Context) {
}
}
impl ui::menu::Item for FileResult {
type Data = Option<PathBuf>;
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()
}
}
struct GlobalSearchConfig {
smart_case: bool,
file_picker_config: helix_view::editor::FilePickerConfig,
}
let config = cx.editor.config();
let smart_case = config.search.smart_case;
let file_picker_config = config.file_picker.clone();
let config = GlobalSearchConfig {
smart_case: config.search.smart_case,
file_picker_config: config.file_picker.clone(),
};
let reg = cx.register.unwrap_or('/');
let completions = search_completions(cx, Some(reg));
ui::raw_regex_prompt(
cx,
"global-search:".into(),
Some(reg),
move |_editor: &Editor, input: &str| {
completions
.iter()
.filter(|comp| comp.starts_with(input))
.map(|comp| (0.., std::borrow::Cow::Owned(comp.clone())))
.collect()
},
move |cx, _, input, event| {
if event != PromptEvent::Validate {
return;
let columns = [
PickerColumn::new("path", |item: &FileResult, _| {
let path = helix_stdx::path::get_relative_path(&item.path);
format!("{}:{}", path.to_string_lossy(), item.line_num + 1).into()
}),
PickerColumn::hidden("contents"),
];
let get_files = |query: &str,
editor: &mut Editor,
config: std::sync::Arc<GlobalSearchConfig>,
injector: &ui::picker::Injector<_, _>| {
if query.is_empty() {
return async { Ok(()) }.boxed();
}
let search_root = helix_stdx::env::current_working_dir();
if !search_root.exists() {
return async { Err(anyhow::anyhow!("Current working directory does not exist")) }
.boxed();
}
let documents: Vec<_> = editor
.documents()
.map(|doc| (doc.path().cloned(), doc.text().to_owned()))
.collect();
let matcher = match RegexMatcherBuilder::new()
.case_smart(config.smart_case)
.build(query)
{
Ok(matcher) => {
// Clear any "Failed to compile regex" errors out of the statusline.
editor.clear_status();
matcher
}
cx.editor.registers.last_search_register = reg;
Err(err) => {
log::info!("Failed to compile search pattern in global search: {}", err);
return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed();
}
};
let current_path = doc_mut!(cx.editor).path().cloned();
let documents: Vec<_> = cx
.editor
.documents()
.map(|doc| (doc.path().cloned(), doc.text().to_owned()))
.collect();
let dedup_symlinks = config.file_picker_config.deduplicate_links;
let absolute_root = search_root
.canonicalize()
.unwrap_or_else(|_| search_root.clone());
if let Ok(matcher) = RegexMatcherBuilder::new()
.case_smart(smart_case)
.build(input)
{
let search_root = helix_stdx::env::current_working_dir();
if !search_root.exists() {
cx.editor
.set_error("Current working directory does not exist");
return;
}
let injector = injector.clone();
async move {
let searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.build();
WalkBuilder::new(search_root)
.hidden(config.file_picker_config.hidden)
.parents(config.file_picker_config.parents)
.ignore(config.file_picker_config.ignore)
.follow_links(config.file_picker_config.follow_symlinks)
.git_ignore(config.file_picker_config.git_ignore)
.git_global(config.file_picker_config.git_global)
.git_exclude(config.file_picker_config.git_exclude)
.max_depth(config.file_picker_config.max_depth)
.filter_entry(move |entry| {
filter_picker_entry(entry, &absolute_root, dedup_symlinks)
})
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"))
.add_custom_ignore_filename(".helix/ignore")
.build_parallel()
.run(|| {
let mut searcher = searcher.clone();
let matcher = matcher.clone();
let injector = injector.clone();
let documents = &documents;
Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return WalkState::Continue,
};
let (picker, injector) = Picker::stream(current_path);
match entry.file_type() {
Some(entry) if entry.is_file() => {}
// skip everything else
_ => return WalkState::Continue,
};
let dedup_symlinks = file_picker_config.deduplicate_links;
let absolute_root = search_root
.canonicalize()
.unwrap_or_else(|_| search_root.clone());
let injector_ = injector.clone();
let mut stop = false;
let sink = sinks::UTF8(|line_num, _line_content| {
stop = injector
.push(FileResult::new(entry.path(), line_num as usize - 1))
.is_err();
std::thread::spawn(move || {
let searcher = SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.build();
let mut walk_builder = WalkBuilder::new(search_root);
walk_builder
.hidden(file_picker_config.hidden)
.parents(file_picker_config.parents)
.ignore(file_picker_config.ignore)
.follow_links(file_picker_config.follow_symlinks)
.git_ignore(file_picker_config.git_ignore)
.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_picker_entry(entry, &absolute_root, dedup_symlinks)
Ok(!stop)
});
let doc = documents.iter().find(|&(doc_path, _)| {
doc_path
.as_ref()
.map_or(false, |doc_path| doc_path == entry.path())
});
walk_builder
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"));
walk_builder.add_custom_ignore_filename(".helix/ignore");
walk_builder.build_parallel().run(|| {
let mut searcher = searcher.clone();
let matcher = matcher.clone();
let injector = injector_.clone();
let documents = &documents;
Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return WalkState::Continue,
};
match entry.file_type() {
Some(entry) if entry.is_file() => {}
// skip everything else
_ => return WalkState::Continue,
};
let mut stop = false;
let sink = sinks::UTF8(|line_num, _| {
stop = injector
.push(FileResult::new(entry.path(), line_num as usize - 1))
.is_err();
Ok(!stop)
});
let doc = documents.iter().find(|&(doc_path, _)| {
doc_path
.as_ref()
.map_or(false, |doc_path| doc_path == entry.path())
});
let result = if let Some((_, doc)) = doc {
// there is already a buffer for this file
// search the buffer instead of the file because it's faster
// and captures new edits without requiring a save
if searcher.multi_line_with_matcher(&matcher) {
// in this case a continous buffer is required
// convert the rope to a string
let text = doc.to_string();
searcher.search_slice(&matcher, text.as_bytes(), sink)
} else {
searcher.search_reader(
&matcher,
RopeReader::new(doc.slice(..)),
sink,
)
}
let result = if let Some((_, doc)) = doc {
// there is already a buffer for this file
// search the buffer instead of the file because it's faster
// and captures new edits without requiring a save
if searcher.multi_line_with_matcher(&matcher) {
// in this case a continous buffer is required
// convert the rope to a string
let text = doc.to_string();
searcher.search_slice(&matcher, text.as_bytes(), sink)
} else {
searcher.search_path(&matcher, entry.path(), sink)
};
searcher.search_reader(
&matcher,
RopeReader::new(doc.slice(..)),
sink,
)
}
} else {
searcher.search_path(&matcher, entry.path(), sink)
};
if let Err(err) = result {
log::error!(
"Global search error: {}, {}",
entry.path().display(),
err
);
}
if stop {
WalkState::Quit
} else {
WalkState::Continue
}
})
});
if let Err(err) = result {
log::error!("Global search error: {}, {}", entry.path().display(), err);
}
if stop {
WalkState::Quit
} else {
WalkState::Continue
}
})
});
Ok(())
}
.boxed()
};
cx.jobs.callback(async move {
let call = move |_: &mut Editor, compositor: &mut Compositor| {
let picker = Picker::with_stream(
picker,
injector,
move |cx, FileResult { path, line_num }, action| {
let doc = match cx.editor.open(path, action) {
Ok(id) => doc_mut!(cx.editor, &id),
Err(e) => {
cx.editor.set_error(format!(
"Failed to open file '{}': {}",
path.display(),
e
));
return;
}
};
let reg = cx.register.unwrap_or('/');
cx.editor.registers.last_search_register = reg;
let line_num = *line_num;
let view = view_mut!(cx.editor);
let text = doc.text();
if line_num >= text.len_lines() {
cx.editor.set_error(
let picker = Picker::new(
columns,
1, // contents
[],
config,
move |cx, FileResult { path, line_num, .. }, action| {
let doc = match cx.editor.open(path, action) {
Ok(id) => doc_mut!(cx.editor, &id),
Err(e) => {
cx.editor
.set_error(format!("Failed to open file '{}': {}", path.display(), e));
return;
}
};
let line_num = *line_num;
let view = view_mut!(cx.editor);
let text = doc.text();
if line_num >= text.len_lines() {
cx.editor.set_error(
"The line you jumped to does not exist anymore because the file has changed.",
);
return;
}
let start = text.line_to_char(line_num);
let end = text.line_to_char((line_num + 1).min(text.len_lines()));
return;
}
let start = text.line_to_char(line_num);
let end = text.line_to_char((line_num + 1).min(text.len_lines()));
doc.set_selection(view.id, Selection::single(start, end));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center);
}
},
)
.with_preview(
|_editor, FileResult { path, line_num }| {
Some((path.clone().into(), Some((*line_num, *line_num))))
},
);
compositor.push(Box::new(overlaid(picker)))
};
Ok(Callback::EditorCompositor(Box::new(call)))
})
} else {
// Otherwise do nothing
// log::warn!("Global Search Invalid Pattern")
doc.set_selection(view.id, Selection::single(start, end));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center);
}
},
);
)
.with_preview(|_editor, FileResult { path, line_num, .. }| {
Some((path.as_path().into(), Some((*line_num, *line_num))))
})
.with_history_register(Some(reg))
.with_dynamic_query(get_files, Some(275));
cx.push_layer(Box::new(overlaid(picker)));
}
enum Extend {
@ -2894,31 +2868,6 @@ fn buffer_picker(cx: &mut Context) {
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 {
id: doc.id(),
path: doc.path().cloned(),
@ -2937,7 +2886,31 @@ fn buffer_picker(cx: &mut Context) {
// mru
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);
})
.with_preview(|editor, meta| {
@ -2961,33 +2934,6 @@ fn jumplist_picker(cx: &mut Context) {
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 doc_id in view.jumps.iter().map(|e| e.0).collect::<Vec<_>>().iter() {
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(
cx.editor
.tree
.views()
.flat_map(|(view, _)| {
view.jumps
.iter()
.rev()
.map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone()))
})
.collect(),
columns,
1, // path
cx.editor.tree.views().flat_map(|(view, _)| {
view.jumps
.iter()
.rev()
.map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone()))
}),
(),
|cx, meta, action| {
cx.editor.switch(meta.id, action);
@ -3054,33 +3026,6 @@ fn changed_file_picker(cx: &mut Context) {
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();
if !cwd.exists() {
cx.editor
@ -3094,8 +3039,41 @@ fn changed_file_picker(cx: &mut Context) {
let deleted = cx.editor.theme.get("diff.minus");
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(
Vec::new(),
columns,
1, // path
[],
FileChangeData {
cwd: cwd.clone(),
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();
cx.editor
@ -3132,35 +3110,6 @@ fn changed_file_picker(cx: &mut Context) {
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) {
let register = cx.register;
let count = cx.count;
@ -3171,16 +3120,45 @@ pub fn command_palette(cx: &mut Context) {
[&cx.editor.mode]
.reverse_map();
let mut commands: Vec<MappableCommand> = MappableCommand::STATIC_COMMAND_LIST.into();
commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| {
MappableCommand::Typable {
name: cmd.name.to_owned(),
doc: cmd.doc.to_owned(),
args: Vec::new(),
}
}));
let commands = MappableCommand::STATIC_COMMAND_LIST.iter().cloned().chain(
typed::TYPABLE_COMMAND_LIST
.iter()
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(),
args: Vec::new(),
doc: cmd.doc.to_owned(),
}),
);
let picker = Picker::new(commands, keymap, move |cx, command, _action| {
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(columns, 0, commands, keymap, move |cx, command, _action| {
let mut ctx = Context {
register,
count,

View file

@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint;
use serde_json::{to_value, Value};
use tokio_stream::wrappers::UnboundedReceiverStream;
use tui::{text::Spans, widgets::Row};
use tui::text::Spans;
use std::collections::HashMap;
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};
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(
cx: &mut Context,
callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static,
@ -73,13 +41,27 @@ fn thread_picker(
let debugger = debugger!(editor);
let thread_states = debugger.thread_states.clone();
let picker = Picker::new(threads, thread_states, move |cx, thread, _action| {
callback_fn(cx.editor, thread)
})
let columns = [
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| {
let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
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((
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 columns = [ui::PickerColumn::new(
"template",
|item: &DebugTemplate, _| item.name.as_str().into(),
)];
cx.push_layer(Box::new(overlaid(Picker::new(
columns,
0,
templates,
(),
|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 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);
// TODO: this should be simpler to find
let pos = debugger.stack_frames[&thread_id]
@ -755,10 +747,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
frame
.source
.as_ref()
.and_then(|source| source.path.clone())
.and_then(|source| source.path.as_ref())
.map(|path| {
(
path.into(),
path.as_path().into(),
Some((
frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1),

View file

@ -9,14 +9,13 @@ use helix_lsp::{
Client, LanguageServerId, OffsetEncoding,
};
use tokio_stream::StreamExt;
use tui::{
text::{Span, Spans},
widgets::Row,
};
use tui::{text::Span, widgets::Row};
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_view::{
document::{DocumentInlayHints, DocumentInlayHintsId},
@ -29,7 +28,7 @@ use helix_view::{
use crate::{
compositor::{self, Compositor},
job::Callback,
ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent},
ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent},
};
use std::{
@ -37,7 +36,7 @@ use std::{
collections::{BTreeMap, HashSet},
fmt::Write,
future::Future,
path::{Path, PathBuf},
path::Path,
};
/// 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 {
symbol: lsp::SymbolInformation,
offset_encoding: OffsetEncoding,
}
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(),
}
}
}
uri: Uri,
}
struct DiagnosticStyles {
@ -133,60 +75,15 @@ struct DiagnosticStyles {
}
struct PickerDiagnostic {
path: PathBuf,
uri: Uri,
diag: lsp::Diagnostic,
offset_encoding: OffsetEncoding,
}
impl ui::menu::Item for PickerDiagnostic {
type Data = (DiagnosticStyles, DiagnosticsFormat);
fn format(&self, (styles, format): &Self::Data) -> Row {
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 uri_to_file_location<'a>(uri: &'a Uri, range: &lsp::Range) -> Option<FileLocation<'a>> {
let path = uri.as_path()?;
let line = Some((range.start.line as usize, range.end.line as usize));
Some((path.into(), line))
}
fn jump_to_location(
@ -241,20 +138,39 @@ fn jump_to_position(
}
}
type SymbolPicker = Picker<SymbolInformationItem>;
fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag?
Picker::new(symbols, current_path, move |cx, item, action| {
jump_to_location(
cx.editor,
&item.symbol.location,
item.offset_encoding,
action,
);
})
.with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location)))
.truncate_start(false)
fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str {
match kind {
lsp::SymbolKind::FILE => "file",
lsp::SymbolKind::MODULE => "module",
lsp::SymbolKind::NAMESPACE => "namespace",
lsp::SymbolKind::PACKAGE => "package",
lsp::SymbolKind::CLASS => "class",
lsp::SymbolKind::METHOD => "method",
lsp::SymbolKind::PROPERTY => "property",
lsp::SymbolKind::FIELD => "field",
lsp::SymbolKind::CONSTRUCTOR => "construct",
lsp::SymbolKind::ENUM => "enum",
lsp::SymbolKind::INTERFACE => "interface",
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)]
@ -263,22 +179,24 @@ enum DiagnosticsFormat {
HideSourcePath,
}
type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>;
fn diag_picker(
cx: &Context,
diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
diagnostics: BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
format: DiagnosticsFormat,
) -> Picker<PickerDiagnostic> {
) -> DiagnosticsPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag?
// flatten the map to a vec of (url, diag) pairs
let mut flat_diag = Vec::new();
for (path, diags) in diagnostics {
for (uri, diags) in diagnostics {
flat_diag.reserve(diags.len());
for (diag, ls) in diags {
if let Some(ls) = cx.editor.language_server_by_id(ls) {
flat_diag.push(PickerDiagnostic {
path: path.clone(),
uri: uri.clone(),
diag,
offset_encoding: ls.offset_encoding(),
});
@ -293,22 +211,72 @@ fn diag_picker(
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(
columns,
primary_column,
flat_diag,
(styles, format),
styles,
move |cx,
PickerDiagnostic {
path,
uri,
diag,
offset_encoding,
},
action| {
let Some(path) = uri.as_path() else {
return;
};
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));
Some((path.clone().into(), line))
Some((uri.as_path()?.into(), line))
})
.truncate_start(false)
}
@ -317,6 +285,7 @@ pub fn symbol_picker(cx: &mut Context) {
fn nested_to_flat(
list: &mut Vec<SymbolInformationItem>,
file: &lsp::TextDocumentIdentifier,
uri: &Uri,
symbol: lsp::DocumentSymbol,
offset_encoding: OffsetEncoding,
) {
@ -331,9 +300,10 @@ pub fn symbol_picker(cx: &mut Context) {
container_name: None,
},
offset_encoding,
uri: uri.clone(),
});
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);
@ -347,6 +317,9 @@ pub fn symbol_picker(cx: &mut Context) {
let request = language_server.document_symbols(doc.identifier()).unwrap();
let offset_encoding = language_server.offset_encoding();
let doc_id = doc.identifier();
let doc_uri = doc
.uri()
.expect("docs with active language servers must be backed by paths");
async move {
let json = request.await?;
@ -361,6 +334,7 @@ pub fn symbol_picker(cx: &mut Context) {
lsp::DocumentSymbolResponse::Flat(symbols) => symbols
.into_iter()
.map(|symbol| SymbolInformationItem {
uri: doc_uri.clone(),
symbol,
offset_encoding,
})
@ -368,7 +342,13 @@ pub fn symbol_picker(cx: &mut Context) {
lsp::DocumentSymbolResponse::Nested(symbols) => {
let mut flat_symbols = Vec::new();
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
}
@ -377,7 +357,6 @@ pub fn symbol_picker(cx: &mut Context) {
}
})
.collect();
let current_url = doc.url();
if futures.is_empty() {
cx.editor
@ -392,7 +371,37 @@ pub fn symbol_picker(cx: &mut Context) {
symbols.append(&mut lsp_items);
}
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)))
};
@ -401,6 +410,8 @@ pub fn symbol_picker(cx: &mut Context) {
}
pub fn workspace_symbol_picker(cx: &mut Context) {
use crate::ui::picker::Injector;
let doc = doc!(cx.editor);
if doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
@ -412,25 +423,37 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
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 mut seen_language_servers = HashSet::new();
let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
.filter(|ls| seen_language_servers.insert(ls.id()))
.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();
async move {
let json = request.await?;
let response =
let response: Vec<_> =
serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)?
.unwrap_or_default()
.into_iter()
.map(|symbol| SymbolInformationItem {
symbol,
offset_encoding,
.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,
uri,
offset_encoding,
})
})
.collect();
@ -443,44 +466,66 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
editor.set_error("No configured language server supports workspace symbols");
}
let injector = injector.clone();
async move {
let mut symbols = Vec::new();
// 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? {
symbols.append(&mut lsp_items);
while let Some(lsp_items) = futures.try_next().await? {
for item in lsp_items {
injector.push(item)?;
}
}
anyhow::Ok(symbols)
Ok(())
}
.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 initial_symbols = get_symbols("".to_owned(), cx.editor);
let picker = Picker::new(
columns,
1, // name column
[],
(),
move |cx, item, action| {
jump_to_location(
cx.editor,
&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);
cx.jobs.callback(async move {
let symbols = initial_symbols.await?;
let call = move |_editor: &mut Editor, compositor: &mut Compositor| {
let picker = sym_picker(symbols, current_url);
let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols));
compositor.push(Box::new(overlaid(dyn_picker)))
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
cx.push_layer(Box::new(overlaid(picker)));
}
pub fn diagnostics_picker(cx: &mut Context) {
let doc = doc!(cx.editor);
if let Some(current_path) = doc.path() {
let diagnostics = cx
.editor
.diagnostics
.get(current_path)
.cloned()
.unwrap_or_default();
if let Some(uri) = doc.uri() {
let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default();
let picker = diag_picker(
cx,
[(current_path.clone(), diagnostics)].into(),
[(uri, diagnostics)].into(),
DiagnosticsFormat::HideSourcePath,
);
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(
editor: &mut Editor,
language_server_id: LanguageServerId,
@ -817,10 +855,67 @@ fn goto_impl(
}
[] => unreachable!("`locations` should be non-empty for `goto_impl`"),
_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)
})
.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)));
}
}

View file

@ -9,7 +9,6 @@ use super::*;
use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT;
use helix_core::{line_ending, shellwords::Shellwords};
use helix_lsp::LanguageServerId;
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent};
use serde_json::Value;
@ -1378,16 +1377,6 @@ fn lsp_workspace_command(
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 ls_id_commands = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
@ -1402,7 +1391,7 @@ fn lsp_workspace_command(
if args.is_empty() {
let commands = ls_id_commands
.map(|(ls_id, command)| {
LsIdCommand(
(
ls_id,
helix_lsp::lsp::Command {
title: command.clone(),
@ -1415,10 +1404,18 @@ fn lsp_workspace_command(
let callback = async move {
let call: job::Callback = Callback::EditorCompositor(Box::new(
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(
columns,
0,
commands,
(),
move |cx, LsIdCommand(ls_id, command), _action| {
move |cx, (ls_id, command), _action| {
execute_lsp_command(cx.editor, *ls_id, command.clone());
},
);

View file

@ -1,11 +1,11 @@
use std::{borrow::Cow, cmp::Reverse, path::PathBuf};
use std::{borrow::Cow, cmp::Reverse};
use crate::{
compositor::{Callback, Component, Compositor, Context, Event, EventResult},
ctrl, key, shift,
};
use helix_core::fuzzy::MATCHER;
use nucleo::pattern::{Atom, AtomKind, CaseMatching};
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::{Config, Utf32Str};
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 struct Menu<T: Item> {
@ -92,7 +80,13 @@ impl<T: Item> Menu<T> {
pub fn score(&mut self, pattern: &str, incremental: bool) {
let mut matcher = MATCHER.lock();
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();
if incremental {
self.matches.retain_mut(|(index, score)| {

View file

@ -21,7 +21,7 @@ pub use editor::EditorView;
use helix_stdx::rope;
pub use markdown::Markdown;
pub use menu::Menu;
pub use picker::{DynamicPicker, FileLocation, Picker};
pub use picker::{Column as PickerColumn, FileLocation, Picker};
pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner};
@ -170,7 +170,9 @@ pub fn raw_regex_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 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));
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) {
let err = if let Some(err) = e.source() {
format!("{}", err)
@ -227,7 +238,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
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 timeout = std::time::Instant::now() + std::time::Duration::from_millis(30);

File diff suppressed because it is too large Load diff

View file

@ -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.
});
})
}
}

View file

@ -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")
);
}
}

View file

@ -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 {
self.set_line(line, editor);
self
}
pub fn set_line(&mut self, line: String, editor: &Editor) {
let cursor = line.len();
self.line = line;
self.cursor = cursor;
self.recalculate_completion(editor);
self
}
pub fn with_language(
@ -113,6 +123,19 @@ impl Prompt {
&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) {
self.exit_selection();
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);
if self.line.is_empty() {
// Show the most recently entered value as a suggestion.
if let Some(suggestion) = self
.history_register
.and_then(|reg| cx.editor.registers.first(reg, cx.editor))
{
if let Some(suggestion) = self.first_history_completion(cx.editor) {
surface.set_string(line_area.x, line_area.y, suggestion, suggestion_color);
}
} else if let Some((language, loader)) = self.language.as_ref() {
@ -574,8 +594,7 @@ impl Component for Prompt {
self.recalculate_completion(cx.editor);
} else {
let last_item = self
.history_register
.and_then(|reg| cx.editor.registers.first(reg, cx.editor))
.first_history_completion(cx.editor)
.map(|entry| entry.to_string())
.unwrap_or_else(|| String::from(""));

View file

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

View file

@ -44,7 +44,7 @@ pub use helix_core::diagnostic::Severity;
use helix_core::{
auto_pairs::AutoPairs,
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_lsp::lsp;
@ -1022,7 +1022,7 @@ pub struct Editor {
pub macro_recording: Option<(char, Vec<KeyEvent>)>,
pub macro_replaying: Vec<char>,
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 debugger: Option<dap::Client>,
@ -1931,7 +1931,7 @@ impl Editor {
/// Returns all supported diagnostics for the document
pub fn doc_diagnostics<'a>(
language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
diagnostics: &'a BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
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
pub fn doc_diagnostics_with_filter<'a>(
language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
diagnostics: &'a BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document,
filter: impl Fn(&lsp::Diagnostic, LanguageServerId) -> bool + 'a,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
let text = document.text().clone();
let language_config = document.language.clone();
document
.path()
.and_then(|path| diagnostics.get(path))
.uri()
.and_then(|uri| diagnostics.get(&uri))
.map(|diags| {
diags.iter().filter_map(move |(diagnostic, lsp_id)| {
let ls = language_servers.get_by_id(*lsp_id)?;

View file

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