Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Globbing support in :open #9723

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ members = [
]

default-members = [
"helix-term"
"helix-term",
]

[profile.release]
Expand All @@ -37,9 +37,11 @@ package.helix-tui.opt-level = 2
package.helix-term.opt-level = 2

[workspace.dependencies]
tree-sitter = { version = "0.22" }
globset = "0.4.14"
ignore = "0.4"
nucleo = "0.2.0"
slotmap = "1.0.7"
tree-sitter = "0.22"

[workspace.package]
version = "24.3.0"
Expand Down
2 changes: 1 addition & 1 deletion helix-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ textwrap = "0.16.1"

nucleo.workspace = true
parking_lot = "0.12"
globset = "0.4.14"
globset.workspace = true

[dev-dependencies]
quickcheck = { version = "1", default-features = false }
Expand Down
2 changes: 1 addition & 1 deletion helix-lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ helix-parsec = { path = "../helix-parsec" }
anyhow = "1.0"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
globset = "0.4.14"
globset.workspace = true
log = "0.4"
lsp-types = { version = "0.95" }
serde = { version = "1.0", features = ["derive"] }
Expand Down
6 changes: 4 additions & 2 deletions helix-term/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ log = "0.4"

# File picker
nucleo.workspace = true
ignore = "0.4"
ignore.workspace = true
# markdown doc rendering
pulldown-cmark = { version = "0.10", default-features = false }
# file type detection
Expand All @@ -71,7 +71,9 @@ serde = { version = "1.0", features = ["derive"] }
grep-regex = "0.1.12"
grep-searcher = "0.1.13"

[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
globset.workspace = true

[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
libc = "0.2.154"

Expand Down
20 changes: 5 additions & 15 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ use url::Url;

use grep_regex::RegexMatcherBuilder;
use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
use ignore::{DirEntry, WalkBuilder, WalkState};
use ignore::{DirEntry, WalkState};

pub type OnKeyCallback = Box<dyn FnOnce(&mut Context, KeyEvent)>;

Expand Down Expand Up @@ -2324,26 +2324,16 @@ fn global_search(cx: &mut Context) {
.canonicalize()
.unwrap_or_else(|_| search_root.clone());
let injector_ = injector.clone();
let mut walk_builder = file_picker_config.walk_builder(search_root);

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)
});
walk_builder.filter_entry(move |entry| {
filter_picker_entry(entry, &absolute_root, dedup_symlinks)
});

walk_builder
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"));
Expand Down
160 changes: 136 additions & 24 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
use std::fmt::Write;
use std::fs;
use std::io::BufReader;
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;

use crate::job::Job;

use super::*;

use globset::{GlobBuilder, GlobSetBuilder};
use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT;
use helix_core::{line_ending, shellwords::Shellwords};
use helix_core::{encoding, line_ending, shellwords::Shellwords};
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent};
use helix_view::editor::{Action, CloseError, ConfigEvent};
use ignore::WalkBuilder;
use serde_json::Value;
use ui::completers::{self, Completer};

// The maximum number of files to open with globbing to avoid freezing while trying to open too many files.
const GLOBBING_MAX_N_FILES: usize = 64;

#[derive(Clone)]
pub struct TypableCommand {
pub name: &'static str,
Expand Down Expand Up @@ -103,38 +111,142 @@ fn force_quit(
Ok(())
}

fn open_file(cx: &mut compositor::Context, path: &Path, pos: Position) -> anyhow::Result<()> {
let _ = cx.editor.open(path, Action::Replace)?;
let (view, doc) = current!(cx.editor);
let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true));
doc.set_selection(view.id, pos);
// does not affect opening a buffer without pos
align_view(doc, view, Align::Center);
Ok(())
}

fn open(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}

ensure!(!args.is_empty(), "wrong argument count");

for arg in args {
let (path, pos) = args::parse_file(arg);
let path = helix_stdx::path::expand_tilde(path);
// If the path is a directory, open a file picker on that directory and update the status
// message
if let Ok(true) = std::fs::canonicalize(&path).map(|p| p.is_dir()) {
let callback = async move {
let call: job::Callback = job::Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
let picker = ui::file_picker(path.into_owned(), &editor.config());
compositor.push(Box::new(overlaid(picker)));
},
));
Ok(call)
};
cx.jobs.callback(callback);
} else {
// Otherwise, just open the file
let _ = cx.editor.open(&path, Action::Replace)?;
let (view, doc) = current!(cx.editor);
let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true));
doc.set_selection(view.id, pos);
// does not affect opening a buffer without pos
align_view(doc, view, Align::Center);
let path = helix_stdx::path::canonicalize(path);

// Shortcut for opening an existing path without globbing
if let Ok(metadata) = fs::metadata(&path) {
// Path exists
let file_type = metadata.file_type();
if file_type.is_dir() {
// If the path is a directory, open a file picker on that directory
let callback = async move {
let call: job::Callback = job::Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
let picker = ui::file_picker(path, &editor.config());
compositor.push(Box::new(overlaid(picker)));
},
));
Ok(call)
};
cx.jobs.callback(callback);
} else if file_type.is_file() {
open_file(cx, &path, pos)?;
} else {
bail!("{} is not a regular file", path.display());
}
continue;
}

let path_str = path.to_str().context("invalid unicode")?;
if !path_str.as_bytes().iter().any(|c| b"*?{}[]".contains(c)) {
// Not a glob, open the file to avoid unneeded walking of huge directories
open_file(cx, &path, pos)?;
continue;
}

let glob = GlobBuilder::new(path_str)
.literal_separator(true)
.empty_alternates(true)
.build()
.context("invalid glob")?;
// Using a glob set instead of `compile_matcher` because the single matcher is always
// a regex matcher. A glob set tries other strategies first which can be more efficient.
// Example: `**/FILENAME` only compares the base name instead of matching with regex.
let glob_set = GlobSetBuilder::new()
.add(glob)
.build()
.context("invalid glob")?;

let mut root = None;
let mut comps = path.components();

// Iterate over all parents
while comps.next_back().is_some() {
let parent = comps.as_path();

if parent.exists() {
// Found the first parent that exists
root = Some(parent);
break;
}
}

let root = root.context("invalid glob")?;
let to_open = Mutex::new(Vec::with_capacity(GLOBBING_MAX_N_FILES));
let exceeded_max_n_files = AtomicBool::new(false);

WalkBuilder::new(root)
// Traversing symlinks makes the time explode.
// Not even sure if non-trivial cycles are detected.
// Because we don't have a timeout, we should better ignore symlinks.
.follow_links(false)
.standard_filters(false)
.build_parallel()
.run(|| {
Box::new(|entry| {
if exceeded_max_n_files.load(Ordering::Relaxed) {
return WalkState::Quit;
}

let Ok(entry) = entry else {
return WalkState::Continue;
};
if !glob_set.is_match(entry.path()) {
return WalkState::Continue;
}
let Ok(metadata) = entry.metadata() else {
return WalkState::Continue;
};

if metadata.is_file() {
let Ok(mut to_open) = to_open.lock() else {
return WalkState::Quit;
};
if to_open.len() == GLOBBING_MAX_N_FILES {
exceeded_max_n_files.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
to_open.push(entry.into_path());
}

WalkState::Continue
})
});

if exceeded_max_n_files.load(Ordering::Relaxed) {
bail!("tried to open more than {GLOBBING_MAX_N_FILES} files at once");
}
let to_open = to_open.into_inner().context("walker thread panicked")?;
if to_open.is_empty() {
// Nothing found to open after globbing. Open a new file
open_file(cx, &path, pos)?;
continue;
}

for path in to_open {
open_file(cx, &path, pos)?;
}
}

Ok(())
}

Expand Down
12 changes: 2 additions & 10 deletions helix-term/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,25 +171,17 @@ pub fn raw_regex_prompt(
}

pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> {
use ignore::{types::TypesBuilder, WalkBuilder};
use ignore::types::TypesBuilder;
use std::time::Instant;

let now = Instant::now();

let dedup_symlinks = config.file_picker.deduplicate_links;
let absolute_root = root.canonicalize().unwrap_or_else(|_| root.clone());

let mut walk_builder = WalkBuilder::new(&root);
let mut walk_builder = config.file_picker.walk_builder(&root);
walk_builder
.hidden(config.file_picker.hidden)
.parents(config.file_picker.parents)
.ignore(config.file_picker.ignore)
.follow_links(config.file_picker.follow_symlinks)
.git_ignore(config.file_picker.git_ignore)
.git_global(config.file_picker.git_global)
.git_exclude(config.file_picker.git_exclude)
.sort_by_file_name(|name1, name2| name1.cmp(name2))
.max_depth(config.file_picker.max_depth)
.filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks));

walk_builder.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"));
Expand Down
1 change: 1 addition & 0 deletions helix-view/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ log = "~0.4"

parking_lot = "0.12.2"

ignore.workspace = true

[target.'cfg(windows)'.dependencies]
clipboard-win = { version = "5.3", features = ["std"] }
Expand Down
20 changes: 20 additions & 0 deletions helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use helix_vcs::DiffProviderRegistry;
use futures_util::stream::select_all::SelectAll;
use futures_util::{future, StreamExt};
use helix_lsp::{Call, LanguageServerId};
use ignore::WalkBuilder;
use tokio_stream::wrappers::UnboundedReceiverStream;

use std::{
Expand Down Expand Up @@ -212,6 +213,25 @@ impl Default for FilePickerConfig {
}
}

impl FilePickerConfig {
pub fn walk_builder<P>(&self, path: P) -> WalkBuilder
where
P: AsRef<Path>,
{
let mut builder = WalkBuilder::new(path);
builder
.hidden(self.hidden)
.follow_links(self.follow_symlinks)
.parents(self.parents)
.ignore(self.ignore)
.git_ignore(self.git_ignore)
.git_global(self.git_global)
.git_exclude(self.git_exclude)
.max_depth(self.max_depth);
builder
}
}

fn serialize_alphabet<S>(alphabet: &[char], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
Expand Down
Loading