zed/crates/file_finder/src/file_finder.rs
Antonio Scandurra 31dfd01fda Make add_local_worktree private and use find_or_create_local_worktree
The former always adds a worktree, even if we have one already in the
project and that could be misused. The public API should always search
for a local worktree containing the requested path first so that the
project can uphold invariants about which worktrees it has.
2022-01-22 11:23:00 +01:00

680 lines
22 KiB
Rust

use editor::{Editor, EditorSettings};
use fuzzy::PathMatch;
use gpui::{
action,
elements::*,
keymap::{self, Binding},
AppContext, Axis, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use postage::watch;
use project::{Project, ProjectPath, WorktreeId};
use std::{
cmp,
path::Path,
sync::{
atomic::{self, AtomicBool},
Arc,
},
};
use util::post_inc;
use workspace::{
menu::{Confirm, SelectNext, SelectPrev},
Settings, Workspace,
};
pub struct FileFinder {
handle: WeakViewHandle<Self>,
settings: watch::Receiver<Settings>,
project: ModelHandle<Project>,
query_editor: ViewHandle<Editor>,
search_count: usize,
latest_search_id: usize,
latest_search_did_cancel: bool,
latest_search_query: String,
matches: Vec<PathMatch>,
selected: Option<(usize, Arc<Path>)>,
cancel_flag: Arc<AtomicBool>,
list_state: UniformListState,
}
action!(Toggle);
action!(Select, ProjectPath);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(FileFinder::toggle);
cx.add_action(FileFinder::confirm);
cx.add_action(FileFinder::select);
cx.add_action(FileFinder::select_prev);
cx.add_action(FileFinder::select_next);
cx.add_bindings(vec![
Binding::new("cmd-p", Toggle, None),
Binding::new("escape", Toggle, Some("FileFinder")),
]);
}
pub enum Event {
Selected(ProjectPath),
Dismissed,
}
impl Entity for FileFinder {
type Event = Event;
}
impl View for FileFinder {
fn ui_name() -> &'static str {
"FileFinder"
}
fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
let settings = self.settings.borrow();
Align::new(
ConstrainedBox::new(
Container::new(
Flex::new(Axis::Vertical)
.with_child(
Container::new(ChildView::new(self.query_editor.id()).boxed())
.with_style(settings.theme.selector.input_editor.container)
.boxed(),
)
.with_child(Flexible::new(1.0, false, self.render_matches()).boxed())
.boxed(),
)
.with_style(settings.theme.selector.container)
.boxed(),
)
.with_max_width(500.0)
.with_max_height(420.0)
.boxed(),
)
.top()
.named("file finder")
}
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
cx.focus(&self.query_editor);
}
fn keymap_context(&self, _: &AppContext) -> keymap::Context {
let mut cx = Self::default_keymap_context();
cx.set.insert("menu".into());
cx
}
}
impl FileFinder {
fn render_matches(&self) -> ElementBox {
if self.matches.is_empty() {
let settings = self.settings.borrow();
return Container::new(
Label::new(
"No matches".into(),
settings.theme.selector.empty.label.clone(),
)
.boxed(),
)
.with_style(settings.theme.selector.empty.container)
.named("empty matches");
}
let handle = self.handle.clone();
let list = UniformList::new(
self.list_state.clone(),
self.matches.len(),
move |mut range, items, cx| {
let cx = cx.as_ref();
let finder = handle.upgrade(cx).unwrap();
let finder = finder.read(cx);
let start = range.start;
range.end = cmp::min(range.end, finder.matches.len());
items.extend(
finder.matches[range]
.iter()
.enumerate()
.map(move |(i, path_match)| finder.render_match(path_match, start + i)),
);
},
);
Container::new(list.boxed())
.with_margin_top(6.0)
.named("matches")
}
fn render_match(&self, path_match: &PathMatch, index: usize) -> ElementBox {
let selected_index = self.selected_index();
let settings = self.settings.borrow();
let style = if index == selected_index {
&settings.theme.selector.active_item
} else {
&settings.theme.selector.item
};
let (file_name, file_name_positions, full_path, full_path_positions) =
self.labels_for_match(path_match);
let container = Container::new(
Flex::row()
// .with_child(
// Container::new(
// LineBox::new(
// Svg::new("icons/file-16.svg")
// .with_color(style.label.text.color)
// .boxed(),
// style.label.text.clone(),
// )
// .boxed(),
// )
// .with_padding_right(6.0)
// .boxed(),
// )
.with_child(
Flexible::new(
1.0,
false,
Flex::column()
.with_child(
Label::new(file_name.to_string(), style.label.clone())
.with_highlights(file_name_positions)
.boxed(),
)
.with_child(
Label::new(full_path, style.label.clone())
.with_highlights(full_path_positions)
.boxed(),
)
.boxed(),
)
.boxed(),
)
.boxed(),
)
.with_style(style.container);
let action = Select(ProjectPath {
worktree_id: WorktreeId::from_usize(path_match.worktree_id),
path: path_match.path.clone(),
});
EventHandler::new(container.boxed())
.on_mouse_down(move |cx| {
cx.dispatch_action(action.clone());
true
})
.named("match")
}
fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
let path_string = path_match.path.to_string_lossy();
let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
let path_positions = path_match.positions.clone();
let file_name = path_match.path.file_name().map_or_else(
|| path_match.path_prefix.to_string(),
|file_name| file_name.to_string_lossy().to_string(),
);
let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
- file_name.chars().count();
let file_name_positions = path_positions
.iter()
.filter_map(|pos| {
if pos >= &file_name_start {
Some(pos - file_name_start)
} else {
None
}
})
.collect();
(file_name, file_name_positions, full_path, path_positions)
}
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
workspace.toggle_modal(cx, |cx, workspace| {
let project = workspace.project().clone();
let finder = cx.add_view(|cx| Self::new(workspace.settings.clone(), project, cx));
cx.subscribe(&finder, Self::on_event).detach();
finder
});
}
fn on_event(
workspace: &mut Workspace,
_: ViewHandle<FileFinder>,
event: &Event,
cx: &mut ViewContext<Workspace>,
) {
match event {
Event::Selected(project_path) => {
workspace
.open_path(project_path.clone(), cx)
.detach_and_log_err(cx);
workspace.dismiss_modal(cx);
}
Event::Dismissed => {
workspace.dismiss_modal(cx);
}
}
}
pub fn new(
settings: watch::Receiver<Settings>,
project: ModelHandle<Project>,
cx: &mut ViewContext<Self>,
) -> Self {
cx.observe(&project, Self::project_updated).detach();
let query_editor = cx.add_view(|cx| {
Editor::single_line(
{
let settings = settings.clone();
Arc::new(move |_| {
let settings = settings.borrow();
EditorSettings {
style: settings.theme.selector.input_editor.as_editor(),
tab_size: settings.tab_size,
soft_wrap: editor::SoftWrap::None,
}
})
},
cx,
)
});
cx.subscribe(&query_editor, Self::on_query_editor_event)
.detach();
Self {
handle: cx.weak_handle(),
settings,
project,
query_editor,
search_count: 0,
latest_search_id: 0,
latest_search_did_cancel: false,
latest_search_query: String::new(),
matches: Vec::new(),
selected: None,
cancel_flag: Arc::new(AtomicBool::new(false)),
list_state: Default::default(),
}
}
fn project_updated(&mut self, _: ModelHandle<Project>, cx: &mut ViewContext<Self>) {
let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
if let Some(task) = self.spawn_search(query, cx) {
task.detach();
}
}
fn on_query_editor_event(
&mut self,
_: ViewHandle<Editor>,
event: &editor::Event,
cx: &mut ViewContext<Self>,
) {
match event {
editor::Event::Edited => {
let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
if query.is_empty() {
self.latest_search_id = post_inc(&mut self.search_count);
self.matches.clear();
cx.notify();
} else {
if let Some(task) = self.spawn_search(query, cx) {
task.detach();
}
}
}
editor::Event::Blurred => cx.emit(Event::Dismissed),
_ => {}
}
}
fn selected_index(&self) -> usize {
if let Some(selected) = self.selected.as_ref() {
for (ix, path_match) in self.matches.iter().enumerate() {
if (path_match.worktree_id, path_match.path.as_ref())
== (selected.0, selected.1.as_ref())
{
return ix;
}
}
}
0
}
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
let mut selected_index = self.selected_index();
if selected_index > 0 {
selected_index -= 1;
let mat = &self.matches[selected_index];
self.selected = Some((mat.worktree_id, mat.path.clone()));
}
self.list_state
.scroll_to(ScrollTarget::Show(selected_index));
cx.notify();
}
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
let mut selected_index = self.selected_index();
if selected_index + 1 < self.matches.len() {
selected_index += 1;
let mat = &self.matches[selected_index];
self.selected = Some((mat.worktree_id, mat.path.clone()));
}
self.list_state
.scroll_to(ScrollTarget::Show(selected_index));
cx.notify();
}
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some(m) = self.matches.get(self.selected_index()) {
cx.emit(Event::Selected(ProjectPath {
worktree_id: WorktreeId::from_usize(m.worktree_id),
path: m.path.clone(),
}));
}
}
fn select(&mut self, Select(project_path): &Select, cx: &mut ViewContext<Self>) {
cx.emit(Event::Selected(project_path.clone()));
}
#[must_use]
fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Option<Task<()>> {
let search_id = util::post_inc(&mut self.search_count);
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
let project = self.project.clone();
Some(cx.spawn(|this, mut cx| async move {
let matches = project
.read_with(&cx, |project, cx| {
project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx)
})
.await;
let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
this.update(&mut cx, |this, cx| {
this.update_matches((search_id, did_cancel, query, matches), cx)
});
}))
}
fn update_matches(
&mut self,
(search_id, did_cancel, query, matches): (usize, bool, String, Vec<PathMatch>),
cx: &mut ViewContext<Self>,
) {
if search_id >= self.latest_search_id {
self.latest_search_id = search_id;
if self.latest_search_did_cancel && query == self.latest_search_query {
util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
} else {
self.matches = matches;
}
self.latest_search_query = query;
self.latest_search_did_cancel = did_cancel;
self.list_state
.scroll_to(ScrollTarget::Show(self.selected_index()));
cx.notify();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use editor::Input;
use serde_json::json;
use std::path::PathBuf;
use workspace::{Workspace, WorkspaceParams};
#[gpui::test]
async fn test_matching_paths(mut cx: gpui::TestAppContext) {
let mut path_openers = Vec::new();
cx.update(|cx| {
super::init(cx);
editor::init(cx, &mut path_openers);
});
let mut params = cx.update(WorkspaceParams::test);
params.path_openers = Arc::from(path_openers);
params
.fs
.as_fake()
.insert_tree(
"/root",
json!({
"a": {
"banana": "",
"bandana": "",
}
}),
)
.await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(&mut cx, |project, cx| {
project.find_or_create_local_worktree("/root", false, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
cx.dispatch_action(window_id, vec![workspace.id()], Toggle);
let finder = cx.read(|cx| {
workspace
.read(cx)
.modal()
.cloned()
.unwrap()
.downcast::<FileFinder>()
.unwrap()
});
let query_buffer = cx.read(|cx| finder.read(cx).query_editor.clone());
let chain = vec![finder.id(), query_buffer.id()];
cx.dispatch_action(window_id, chain.clone(), Input("b".into()));
cx.dispatch_action(window_id, chain.clone(), Input("n".into()));
cx.dispatch_action(window_id, chain.clone(), Input("a".into()));
finder
.condition(&cx, |finder, _| finder.matches.len() == 2)
.await;
let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
cx.dispatch_action(window_id, vec![workspace.id(), finder.id()], SelectNext);
cx.dispatch_action(window_id, vec![workspace.id(), finder.id()], Confirm);
active_pane
.condition(&cx, |pane, _| pane.active_item().is_some())
.await;
cx.read(|cx| {
let active_item = active_pane.read(cx).active_item().unwrap();
assert_eq!(active_item.title(cx), "bandana");
});
}
#[gpui::test]
async fn test_matching_cancellation(mut cx: gpui::TestAppContext) {
let params = cx.update(WorkspaceParams::test);
let fs = params.fs.as_fake();
fs.insert_tree(
"/dir",
json!({
"hello": "",
"goodbye": "",
"halogen-light": "",
"happiness": "",
"height": "",
"hi": "",
"hiccup": "",
}),
)
.await;
let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(&mut cx, |project, cx| {
project.find_or_create_local_worktree("/dir", false, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let (_, finder) = cx.add_window(|cx| {
FileFinder::new(
params.settings.clone(),
workspace.read(cx).project().clone(),
cx,
)
});
let query = "hi".to_string();
finder
.update(&mut cx, |f, cx| f.spawn_search(query.clone(), cx))
.unwrap()
.await;
finder.read_with(&cx, |f, _| assert_eq!(f.matches.len(), 5));
finder.update(&mut cx, |finder, cx| {
let matches = finder.matches.clone();
// Simulate a search being cancelled after the time limit,
// returning only a subset of the matches that would have been found.
finder.spawn_search(query.clone(), cx).unwrap().detach();
finder.update_matches(
(
finder.latest_search_id,
true, // did-cancel
query.clone(),
vec![matches[1].clone(), matches[3].clone()],
),
cx,
);
// Simulate another cancellation.
finder.spawn_search(query.clone(), cx).unwrap().detach();
finder.update_matches(
(
finder.latest_search_id,
true, // did-cancel
query.clone(),
vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
),
cx,
);
assert_eq!(finder.matches, matches[0..4])
});
}
#[gpui::test]
async fn test_single_file_worktrees(mut cx: gpui::TestAppContext) {
let params = cx.update(WorkspaceParams::test);
params
.fs
.as_fake()
.insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
.await;
let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(&mut cx, |project, cx| {
project.find_or_create_local_worktree("/root/the-parent-dir/the-file", false, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let (_, finder) = cx.add_window(|cx| {
FileFinder::new(
params.settings.clone(),
workspace.read(cx).project().clone(),
cx,
)
});
// Even though there is only one worktree, that worktree's filename
// is included in the matching, because the worktree is a single file.
finder
.update(&mut cx, |f, cx| f.spawn_search("thf".into(), cx))
.unwrap()
.await;
cx.read(|cx| {
let finder = finder.read(cx);
assert_eq!(finder.matches.len(), 1);
let (file_name, file_name_positions, full_path, full_path_positions) =
finder.labels_for_match(&finder.matches[0]);
assert_eq!(file_name, "the-file");
assert_eq!(file_name_positions, &[0, 1, 4]);
assert_eq!(full_path, "the-file");
assert_eq!(full_path_positions, &[0, 1, 4]);
});
// Since the worktree root is a file, searching for its name followed by a slash does
// not match anything.
finder
.update(&mut cx, |f, cx| f.spawn_search("thf/".into(), cx))
.unwrap()
.await;
finder.read_with(&cx, |f, _| assert_eq!(f.matches.len(), 0));
}
#[gpui::test(retries = 5)]
async fn test_multiple_matches_with_same_relative_path(mut cx: gpui::TestAppContext) {
let params = cx.update(WorkspaceParams::test);
params
.fs
.as_fake()
.insert_tree(
"/root",
json!({
"dir1": { "a.txt": "" },
"dir2": { "a.txt": "" }
}),
)
.await;
let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
workspace
.update(&mut cx, |workspace, cx| {
workspace.open_paths(
&[PathBuf::from("/root/dir1"), PathBuf::from("/root/dir2")],
cx,
)
})
.await;
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let (_, finder) = cx.add_window(|cx| {
FileFinder::new(
params.settings.clone(),
workspace.read(cx).project().clone(),
cx,
)
});
// Run a search that matches two files with the same relative path.
finder
.update(&mut cx, |f, cx| f.spawn_search("a.t".into(), cx))
.unwrap()
.await;
// Can switch between different matches with the same relative path.
finder.update(&mut cx, |f, cx| {
assert_eq!(f.matches.len(), 2);
assert_eq!(f.selected_index(), 0);
f.select_next(&SelectNext, cx);
assert_eq!(f.selected_index(), 1);
f.select_prev(&SelectPrev, cx);
assert_eq!(f.selected_index(), 0);
});
}
}