diff --git a/Cargo.lock b/Cargo.lock index a0088a1695..a7eb358ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3061,6 +3061,31 @@ dependencies = [ "workspace", ] +[[package]] +name = "file_finder2" +version = "0.1.0" +dependencies = [ + "collections", + "ctor", + "editor2", + "env_logger 0.9.3", + "fuzzy2", + "gpui2", + "language2", + "menu2", + "picker2", + "postage", + "project2", + "serde", + "serde_json", + "settings2", + "text2", + "theme2", + "ui2", + "util", + "workspace2", +] + [[package]] name = "filetime" version = "0.2.22" @@ -11424,6 +11449,7 @@ dependencies = [ "editor2", "env_logger 0.9.3", "feature_flags2", + "file_finder2", "fs2", "fsevent", "futures 0.3.28", diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index 0f349ceda7..fc8f7a88b7 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -9168,6 +9168,10 @@ impl Editor { cx.focus(&self.focus_handle) } + pub fn is_focused(&self, cx: &WindowContext) -> bool { + self.focus_handle.is_focused(cx) + } + fn handle_focus_in(&mut self, cx: &mut ViewContext) { if self.focus_handle.is_focused(cx) { // todo!() @@ -9379,8 +9383,8 @@ impl Render for Editor { EditorMode::SingleLine => { TextStyle { color: cx.theme().colors().text, - font_family: "Zed Sans".into(), // todo!() - font_features: FontFeatures::default(), + font_family: settings.ui_font.family.clone(), // todo!() + font_features: settings.ui_font.features, font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, diff --git a/crates/editor2/src/element.rs b/crates/editor2/src/element.rs index d06f73c92b..74208d010e 100644 --- a/crates/editor2/src/element.rs +++ b/crates/editor2/src/element.rs @@ -1448,6 +1448,7 @@ impl EditorElement { let snapshot = editor.snapshot(cx); let style = self.style.clone(); + let font_id = cx.text_system().font_id(&style.text.font()).unwrap(); let font_size = style.text.font_size.to_pixels(cx.rem_size()); let line_height = style.text.line_height_in_pixels(cx.rem_size()); diff --git a/crates/file_finder2/Cargo.toml b/crates/file_finder2/Cargo.toml new file mode 100644 index 0000000000..22b9f2cbc8 --- /dev/null +++ b/crates/file_finder2/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "file_finder2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/file_finder.rs" +doctest = false + +[dependencies] +editor = { package = "editor2", path = "../editor2" } +collections = { path = "../collections" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } +gpui = { package = "gpui2", path = "../gpui2" } +menu = { package = "menu2", path = "../menu2" } +picker = { package = "picker2", path = "../picker2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +text = { package = "text2", path = "../text2" } +util = { path = "../util" } +theme = { package = "theme2", path = "../theme2" } +ui = { package = "ui2", path = "../ui2" } +workspace = { package = "workspace2", path = "../workspace2" } +postage.workspace = true +serde.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +language = { package = "language2", path = "../language2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } +theme = { package = "theme2", path = "../theme2", features = ["test-support"] } + +serde_json.workspace = true +ctor.workspace = true +env_logger.workspace = true diff --git a/crates/file_finder2/src/file_finder.rs b/crates/file_finder2/src/file_finder.rs new file mode 100644 index 0000000000..39bbb91465 --- /dev/null +++ b/crates/file_finder2/src/file_finder.rs @@ -0,0 +1,1977 @@ +use collections::HashMap; +use editor::{scroll::autoscroll::Autoscroll, Bias, Editor}; +use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; +use gpui::{ + actions, div, AppContext, Component, Div, EventEmitter, InteractiveComponent, Model, + ParentComponent, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, + WindowContext, +}; +use picker::{Picker, PickerDelegate}; +use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; +use std::{ + path::{Path, PathBuf}, + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, +}; +use text::Point; +use theme::ActiveTheme; +use ui::{v_stack, HighlightedLabel, StyledExt}; +use util::{paths::PathLikeWithPosition, post_inc, ResultExt}; +use workspace::{Modal, ModalEvent, Workspace}; + +actions!(Toggle); + +pub struct FileFinder { + picker: View>, +} + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(FileFinder::register).detach(); +} + +impl FileFinder { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + dbg!("REGISTERING"); + workspace.register_action(|workspace, _: &Toggle, cx| { + dbg!("CALLING ACTION"); + let Some(file_finder) = workspace.current_modal::(cx) else { + Self::open(workspace, cx); + return; + }; + + file_finder.update(cx, |file_finder, cx| { + file_finder + .picker + .update(cx, |picker, cx| picker.cycle_selection(cx)) + }); + }); + } + + fn open(workspace: &mut Workspace, cx: &mut ViewContext) { + let project = workspace.project().read(cx); + + let currently_opened_path = workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .map(|project_path| { + let abs_path = project + .worktree_for_id(project_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path)); + FoundPath::new(project_path, abs_path) + }); + + // if exists, bubble the currently opened path to the top + let history_items = currently_opened_path + .clone() + .into_iter() + .chain( + workspace + .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx) + .into_iter() + .filter(|(history_path, _)| { + Some(history_path) + != currently_opened_path + .as_ref() + .map(|found_path| &found_path.project) + }) + .filter(|(_, history_abs_path)| { + history_abs_path.as_ref() + != currently_opened_path + .as_ref() + .and_then(|found_path| found_path.absolute.as_ref()) + }) + .filter(|(_, history_abs_path)| match history_abs_path { + Some(abs_path) => history_file_exists(abs_path), + None => true, + }) + .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)), + ) + .collect(); + + let project = workspace.project().clone(); + let weak_workspace = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = FileFinderDelegate::new( + cx.view().downgrade(), + weak_workspace, + project, + currently_opened_path, + history_items, + cx, + ); + + FileFinder::new(delegate, cx) + }); + } + + fn new(delegate: FileFinderDelegate, cx: &mut ViewContext) -> Self { + Self { + picker: cx.build_view(|cx| Picker::new(delegate, cx)), + } + } +} + +impl EventEmitter for FileFinder {} +impl Modal for FileFinder { + fn focus(&self, cx: &mut WindowContext) { + self.picker.update(cx, |picker, cx| picker.focus(cx)) + } +} +impl Render for FileFinder { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + v_stack().w_96().child(self.picker.clone()) + } +} + +pub struct FileFinderDelegate { + file_finder: WeakView, + workspace: WeakView, + project: Model, + search_count: usize, + latest_search_id: usize, + latest_search_did_cancel: bool, + latest_search_query: Option>, + currently_opened_path: Option, + matches: Matches, + selected_index: Option, + cancel_flag: Arc, + history_items: Vec, +} + +#[derive(Debug, Default)] +struct Matches { + history: Vec<(FoundPath, Option)>, + search: Vec, +} + +#[derive(Debug)] +enum Match<'a> { + History(&'a FoundPath, Option<&'a PathMatch>), + Search(&'a PathMatch), +} + +impl Matches { + fn len(&self) -> usize { + self.history.len() + self.search.len() + } + + fn get(&self, index: usize) -> Option> { + if index < self.history.len() { + self.history + .get(index) + .map(|(path, path_match)| Match::History(path, path_match.as_ref())) + } else { + self.search + .get(index - self.history.len()) + .map(Match::Search) + } + } + + fn push_new_matches( + &mut self, + history_items: &Vec, + query: &PathLikeWithPosition, + mut new_search_matches: Vec, + extend_old_matches: bool, + ) { + let matching_history_paths = matching_history_item_paths(history_items, query); + new_search_matches + .retain(|path_match| !matching_history_paths.contains_key(&path_match.path)); + let history_items_to_show = history_items + .iter() + .filter_map(|history_item| { + Some(( + history_item.clone(), + Some( + matching_history_paths + .get(&history_item.project.path)? + .clone(), + ), + )) + }) + .collect::>(); + self.history = history_items_to_show; + if extend_old_matches { + self.search + .retain(|path_match| !matching_history_paths.contains_key(&path_match.path)); + util::extend_sorted( + &mut self.search, + new_search_matches.into_iter(), + 100, + |a, b| b.cmp(a), + ) + } else { + self.search = new_search_matches; + } + } +} + +fn matching_history_item_paths( + history_items: &Vec, + query: &PathLikeWithPosition, +) -> HashMap, PathMatch> { + let history_items_by_worktrees = history_items + .iter() + .filter_map(|found_path| { + let candidate = PathMatchCandidate { + path: &found_path.project.path, + // Only match history items names, otherwise their paths may match too many queries, producing false positives. + // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item, + // it would be shown first always, despite the latter being a better match. + char_bag: CharBag::from_iter( + found_path + .project + .path + .file_name()? + .to_string_lossy() + .to_lowercase() + .chars(), + ), + }; + Some((found_path.project.worktree_id, candidate)) + }) + .fold( + HashMap::default(), + |mut candidates, (worktree_id, new_candidate)| { + candidates + .entry(worktree_id) + .or_insert_with(Vec::new) + .push(new_candidate); + candidates + }, + ); + let mut matching_history_paths = HashMap::default(); + for (worktree, candidates) in history_items_by_worktrees { + let max_results = candidates.len() + 1; + matching_history_paths.extend( + fuzzy::match_fixed_path_set( + candidates, + worktree.to_usize(), + query.path_like.path_query(), + false, + max_results, + ) + .into_iter() + .map(|path_match| (Arc::clone(&path_match.path), path_match)), + ); + } + matching_history_paths +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FoundPath { + project: ProjectPath, + absolute: Option, +} + +impl FoundPath { + fn new(project: ProjectPath, absolute: Option) -> Self { + Self { project, absolute } + } +} + +const MAX_RECENT_SELECTIONS: usize = 20; + +#[cfg(not(test))] +fn history_file_exists(abs_path: &PathBuf) -> bool { + abs_path.exists() +} + +#[cfg(test)] +fn history_file_exists(abs_path: &PathBuf) -> bool { + !abs_path.ends_with("nonexistent.rs") +} + +pub enum Event { + Selected(ProjectPath), + Dismissed, +} + +#[derive(Debug, Clone)] +struct FileSearchQuery { + raw_query: String, + file_query_end: Option, +} + +impl FileSearchQuery { + fn path_query(&self) -> &str { + match self.file_query_end { + Some(file_path_end) => &self.raw_query[..file_path_end], + None => &self.raw_query, + } + } +} + +impl FileFinderDelegate { + fn new( + file_finder: WeakView, + workspace: WeakView, + project: Model, + currently_opened_path: Option, + history_items: Vec, + cx: &mut ViewContext, + ) -> Self { + cx.observe(&project, |file_finder, _, cx| { + //todo!() We should probably not re-render on every project anything + file_finder + .picker + .update(cx, |picker, cx| picker.refresh(cx)) + }) + .detach(); + + Self { + file_finder, + workspace, + project, + search_count: 0, + latest_search_id: 0, + latest_search_did_cancel: false, + latest_search_query: None, + currently_opened_path, + matches: Matches::default(), + selected_index: None, + cancel_flag: Arc::new(AtomicBool::new(false)), + history_items, + } + } + + fn spawn_search( + &mut self, + query: PathLikeWithPosition, + cx: &mut ViewContext>, + ) -> Task<()> { + let relative_to = self + .currently_opened_path + .as_ref() + .map(|found_path| Arc::clone(&found_path.project.path)); + let worktrees = self + .project + .read(cx) + .visible_worktrees(cx) + .collect::>(); + let include_root_name = worktrees.len() > 1; + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name, + } + }) + .collect::>(); + + 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(); + cx.spawn(|picker, mut cx| async move { + let matches = fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.path_like.path_query(), + relative_to, + false, + 100, + &cancel_flag, + cx.background_executor().clone(), + ) + .await; + let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed); + picker + .update(&mut cx, |picker, cx| { + picker + .delegate + .set_search_matches(search_id, did_cancel, query, matches, cx) + }) + .log_err(); + }) + } + + fn set_search_matches( + &mut self, + search_id: usize, + did_cancel: bool, + query: PathLikeWithPosition, + matches: Vec, + cx: &mut ViewContext>, + ) { + if search_id >= self.latest_search_id { + self.latest_search_id = search_id; + let extend_old_matches = self.latest_search_did_cancel + && Some(query.path_like.path_query()) + == self + .latest_search_query + .as_ref() + .map(|query| query.path_like.path_query()); + self.matches + .push_new_matches(&self.history_items, &query, matches, extend_old_matches); + self.latest_search_query = Some(query); + self.latest_search_did_cancel = did_cancel; + cx.notify(); + } + } + + fn labels_for_match( + &self, + path_match: Match, + cx: &AppContext, + ix: usize, + ) -> (String, Vec, String, Vec) { + let (file_name, file_name_positions, full_path, full_path_positions) = match path_match { + Match::History(found_path, found_path_match) => { + let worktree_id = found_path.project.worktree_id; + let project_relative_path = &found_path.project.path; + let has_worktree = self + .project + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some(); + + if !has_worktree { + if let Some(absolute_path) = &found_path.absolute { + return ( + absolute_path + .file_name() + .map_or_else( + || project_relative_path.to_string_lossy(), + |file_name| file_name.to_string_lossy(), + ) + .to_string(), + Vec::new(), + absolute_path.to_string_lossy().to_string(), + Vec::new(), + ); + } + } + + let mut path = Arc::clone(project_relative_path); + if project_relative_path.as_ref() == Path::new("") { + if let Some(absolute_path) = &found_path.absolute { + path = Arc::from(absolute_path.as_path()); + } + } + + let mut path_match = PathMatch { + score: ix as f64, + positions: Vec::new(), + worktree_id: worktree_id.to_usize(), + path, + path_prefix: "".into(), + distance_to_relative_ancestor: usize::MAX, + }; + if let Some(found_path_match) = found_path_match { + path_match + .positions + .extend(found_path_match.positions.iter()) + } + + self.labels_for_path_match(&path_match) + } + Match::Search(path_match) => self.labels_for_path_match(path_match), + }; + + if file_name_positions.is_empty() { + if let Some(user_home_path) = std::env::var("HOME").ok() { + let user_home_path = user_home_path.trim(); + if !user_home_path.is_empty() { + if (&full_path).starts_with(user_home_path) { + return ( + file_name, + file_name_positions, + full_path.replace(user_home_path, "~"), + full_path_positions, + ); + } + } + } + } + + ( + file_name, + file_name_positions, + full_path, + full_path_positions, + ) + } + + fn labels_for_path_match( + &self, + path_match: &PathMatch, + ) -> (String, Vec, String, Vec) { + let path = &path_match.path; + let path_string = 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.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) + } +} + +impl PickerDelegate for FileFinderDelegate { + type ListItem = Div>; + + fn placeholder_text(&self) -> Arc { + "Search project files...".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index.unwrap_or(0) + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = Some(ix); + cx.notify(); + } + + fn update_matches( + &mut self, + raw_query: String, + cx: &mut ViewContext>, + ) -> Task<()> { + if raw_query.is_empty() { + let project = self.project.read(cx); + self.latest_search_id = post_inc(&mut self.search_count); + self.matches = Matches { + history: self + .history_items + .iter() + .filter(|history_item| { + project + .worktree_for_id(history_item.project.worktree_id, cx) + .is_some() + || (project.is_local() && history_item.absolute.is_some()) + }) + .cloned() + .map(|p| (p, None)) + .collect(), + search: Vec::new(), + }; + cx.notify(); + Task::ready(()) + } else { + let raw_query = &raw_query; + let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| { + Ok::<_, std::convert::Infallible>(FileSearchQuery { + raw_query: raw_query.to_owned(), + file_query_end: if path_like_str == raw_query { + None + } else { + Some(path_like_str.len()) + }, + }) + }) + .expect("infallible"); + self.spawn_search(query, cx) + } + } + + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>) { + dbg!("CONFIRMING???"); + if let Some(m) = self.matches.get(self.selected_index()) { + if let Some(workspace) = self.workspace.upgrade() { + let open_task = workspace.update(cx, move |workspace, cx| { + let split_or_open = |workspace: &mut Workspace, project_path, cx| { + if secondary { + workspace.split_path(project_path, cx) + } else { + workspace.open_path(project_path, None, true, cx) + } + }; + match m { + Match::History(history_match, _) => { + let worktree_id = history_match.project.worktree_id; + if workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some() + { + split_or_open( + workspace, + ProjectPath { + worktree_id, + path: Arc::clone(&history_match.project.path), + }, + cx, + ) + } else { + match history_match.absolute.as_ref() { + Some(abs_path) => { + if secondary { + workspace.split_abs_path( + abs_path.to_path_buf(), + false, + cx, + ) + } else { + workspace.open_abs_path( + abs_path.to_path_buf(), + false, + cx, + ) + } + } + None => split_or_open( + workspace, + ProjectPath { + worktree_id, + path: Arc::clone(&history_match.project.path), + }, + cx, + ), + } + } + } + Match::Search(m) => split_or_open( + workspace, + ProjectPath { + worktree_id: WorktreeId::from_usize(m.worktree_id), + path: m.path.clone(), + }, + cx, + ), + } + }); + + let row = self + .latest_search_query + .as_ref() + .and_then(|query| query.row) + .map(|row| row.saturating_sub(1)); + let col = self + .latest_search_query + .as_ref() + .and_then(|query| query.column) + .unwrap_or(0) + .saturating_sub(1); + let finder = self.file_finder.clone(); + + cx.spawn(|_, mut cx| async move { + let item = open_task.await.log_err()?; + if let Some(row) = row { + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update(&mut cx, |editor, cx| { + let snapshot = editor.snapshot(cx).display_snapshot; + let point = snapshot + .buffer_snapshot + .clip_point(Point::new(row, col), Bias::Left); + editor.change_selections(Some(Autoscroll::center()), cx, |s| { + s.select_ranges([point..point]) + }); + }) + .log_err(); + } + } + dbg!("DISMISSING"); + finder + .update(&mut cx, |_, cx| cx.emit(ModalEvent::Dismissed)) + .ok()?; + + Some(()) + }) + .detach(); + } + } + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + self.file_finder + .update(cx, |_, cx| cx.emit(ModalEvent::Dismissed)) + .log_err(); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Self::ListItem { + let path_match = self + .matches + .get(ix) + .expect("Invalid matches state: no element for index {ix}"); + let theme = cx.theme(); + let colors = theme.colors(); + + let (file_name, file_name_positions, full_path, full_path_positions) = + self.labels_for_match(path_match, cx, ix); + + div() + .px_1() + .text_color(colors.text) + .text_ui() + .bg(colors.ghost_element_background) + .rounded_md() + .when(selected, |this| this.bg(colors.ghost_element_selected)) + .hover(|this| this.bg(colors.ghost_element_hover)) + .child( + v_stack() + .child(HighlightedLabel::new(file_name, file_name_positions)) + .child(HighlightedLabel::new(full_path, full_path_positions)), + ) + } +} + +// #[cfg(test)] +// mod tests { +// use std::{assert_eq, collections::HashMap, path::Path, time::Duration}; + +// use super::*; +// use editor::Editor; +// use gpui::{Entity, TestAppContext, VisualTestContext}; +// use menu::{Confirm, SelectNext}; +// use serde_json::json; +// use workspace::{AppState, Workspace}; + +// #[ctor::ctor] +// fn init_logger() { +// if std::env::var("RUST_LOG").is_ok() { +// env_logger::init(); +// } +// } + +// #[gpui::test] +// async fn test_matching_paths(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/root", +// json!({ +// "a": { +// "banana": "", +// "bandana": "", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + +// let (picker, workspace, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// picker +// .update(cx, |picker, cx| { +// picker.delegate.update_matches("bna".to_string(), cx) +// }) +// .await; + +// picker.update(cx, |picker, _| { +// assert_eq!(picker.delegate.matches.len(), 2); +// }); + +// let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); +// cx.dispatch_action(SelectNext); +// cx.dispatch_action(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 +// .to_any() +// .downcast::() +// .unwrap() +// .read(cx) +// .title(cx), +// "bandana" +// ); +// }); +// } + +// #[gpui::test] +// async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { +// let app_state = init_test(cx); + +// let first_file_name = "first.rs"; +// let first_file_contents = "// First Rust file"; +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// first_file_name: first_file_contents, +// "second.rs": "// Second Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + +// let (picker, workspace, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// let file_query = &first_file_name[..3]; +// let file_row = 1; +// let file_column = 3; +// assert!(file_column <= first_file_contents.len()); +// let query_inside_file = format!("{file_query}:{file_row}:{file_column}"); +// picker +// .update(cx, |finder, cx| { +// finder +// .delegate +// .update_matches(query_inside_file.to_string(), cx) +// }) +// .await; +// picker.update(cx, |finder, _| { +// let finder = &finder.delegate; +// assert_eq!(finder.matches.len(), 1); +// let latest_search_query = finder +// .latest_search_query +// .as_ref() +// .expect("Finder should have a query after the update_matches call"); +// assert_eq!(latest_search_query.path_like.raw_query, query_inside_file); +// assert_eq!( +// latest_search_query.path_like.file_query_end, +// Some(file_query.len()) +// ); +// assert_eq!(latest_search_query.row, Some(file_row)); +// assert_eq!(latest_search_query.column, Some(file_column as u32)); +// }); + +// let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); +// cx.dispatch_action(SelectNext); +// cx.dispatch_action(Confirm); +// active_pane +// .condition(cx, |pane, _| pane.active_item().is_some()) +// .await; +// let editor = cx.update(|cx| { +// let active_item = active_pane.read(cx).active_item().unwrap(); +// active_item.downcast::().unwrap() +// }); +// cx.executor().advance_clock(Duration::from_secs(2)); + +// editor.update(cx, |editor, cx| { +// let all_selections = editor.selections.all_adjusted(cx); +// assert_eq!( +// all_selections.len(), +// 1, +// "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}" +// ); +// let caret_selection = all_selections.into_iter().next().unwrap(); +// assert_eq!(caret_selection.start, caret_selection.end, +// "Caret selection should have its start and end at the same position"); +// assert_eq!(file_row, caret_selection.start.row + 1, +// "Query inside file should get caret with the same focus row"); +// assert_eq!(file_column, caret_selection.start.column as usize + 1, +// "Query inside file should get caret with the same focus column"); +// }); +// } + +// #[gpui::test] +// async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) { +// let app_state = init_test(cx); + +// let first_file_name = "first.rs"; +// let first_file_contents = "// First Rust file"; +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// first_file_name: first_file_contents, +// "second.rs": "// Second Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + +// let (picker, workspace, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// let file_query = &first_file_name[..3]; +// let file_row = 200; +// let file_column = 300; +// assert!(file_column > first_file_contents.len()); +// let query_outside_file = format!("{file_query}:{file_row}:{file_column}"); +// picker +// .update(cx, |picker, cx| { +// picker +// .delegate +// .update_matches(query_outside_file.to_string(), cx) +// }) +// .await; +// picker.update(cx, |finder, _| { +// let delegate = &finder.delegate; +// assert_eq!(delegate.matches.len(), 1); +// let latest_search_query = delegate +// .latest_search_query +// .as_ref() +// .expect("Finder should have a query after the update_matches call"); +// assert_eq!(latest_search_query.path_like.raw_query, query_outside_file); +// assert_eq!( +// latest_search_query.path_like.file_query_end, +// Some(file_query.len()) +// ); +// assert_eq!(latest_search_query.row, Some(file_row)); +// assert_eq!(latest_search_query.column, Some(file_column as u32)); +// }); + +// let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); +// cx.dispatch_action(SelectNext); +// cx.dispatch_action(Confirm); +// active_pane +// .condition(cx, |pane, _| pane.active_item().is_some()) +// .await; +// let editor = cx.update(|cx| { +// let active_item = active_pane.read(cx).active_item().unwrap(); +// active_item.downcast::().unwrap() +// }); +// cx.executor().advance_clock(Duration::from_secs(2)); + +// editor.update(cx, |editor, cx| { +// let all_selections = editor.selections.all_adjusted(cx); +// assert_eq!( +// all_selections.len(), +// 1, +// "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}" +// ); +// let caret_selection = all_selections.into_iter().next().unwrap(); +// assert_eq!(caret_selection.start, caret_selection.end, +// "Caret selection should have its start and end at the same position"); +// assert_eq!(0, caret_selection.start.row, +// "Excessive rows (as in query outside file borders) should get trimmed to last file row"); +// assert_eq!(first_file_contents.len(), caret_selection.start.column as usize, +// "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column"); +// }); +// } + +// #[gpui::test] +// async fn test_matching_cancellation(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/dir", +// json!({ +// "hello": "", +// "goodbye": "", +// "halogen-light": "", +// "happiness": "", +// "height": "", +// "hi": "", +// "hiccup": "", +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await; + +// let (picker, _, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// let query = test_path_like("hi"); +// picker +// .update(cx, |picker, cx| { +// picker.delegate.spawn_search(query.clone(), cx) +// }) +// .await; + +// picker.update(cx, |picker, _cx| { +// assert_eq!(picker.delegate.matches.len(), 5) +// }); + +// picker.update(cx, |picker, cx| { +// let delegate = &mut picker.delegate; +// assert!( +// delegate.matches.history.is_empty(), +// "Search matches expected" +// ); +// let matches = delegate.matches.search.clone(); + +// // Simulate a search being cancelled after the time limit, +// // returning only a subset of the matches that would have been found. +// drop(delegate.spawn_search(query.clone(), cx)); +// delegate.set_search_matches( +// delegate.latest_search_id, +// true, // did-cancel +// query.clone(), +// vec![matches[1].clone(), matches[3].clone()], +// cx, +// ); + +// // Simulate another cancellation. +// drop(delegate.spawn_search(query.clone(), cx)); +// delegate.set_search_matches( +// delegate.latest_search_id, +// true, // did-cancel +// query.clone(), +// vec![matches[0].clone(), matches[2].clone(), matches[3].clone()], +// cx, +// ); + +// assert!( +// delegate.matches.history.is_empty(), +// "Search matches expected" +// ); +// assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]); +// }); +// } + +// #[gpui::test] +// async fn test_ignored_files(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/ancestor", +// json!({ +// ".gitignore": "ignored-root", +// "ignored-root": { +// "happiness": "", +// "height": "", +// "hi": "", +// "hiccup": "", +// }, +// "tracked-root": { +// ".gitignore": "height", +// "happiness": "", +// "height": "", +// "hi": "", +// "hiccup": "", +// }, +// }), +// ) +// .await; + +// let project = Project::test( +// app_state.fs.clone(), +// [ +// "/ancestor/tracked-root".as_ref(), +// "/ancestor/ignored-root".as_ref(), +// ], +// cx, +// ) +// .await; + +// let (picker, _, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// picker +// .update(cx, |picker, cx| { +// picker.delegate.spawn_search(test_path_like("hi"), cx) +// }) +// .await; +// picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7)); +// } + +// #[gpui::test] +// async fn test_single_file_worktrees(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } })) +// .await; + +// let project = Project::test( +// app_state.fs.clone(), +// ["/root/the-parent-dir/the-file".as_ref()], +// cx, +// ) +// .await; + +// let (picker, _, mut cx) = build_find_picker(project, cx); +// let cx = &mut cx; + +// // Even though there is only one worktree, that worktree's filename +// // is included in the matching, because the worktree is a single file. +// picker +// .update(cx, |picker, cx| { +// picker.delegate.spawn_search(test_path_like("thf"), cx) +// }) +// .await; +// cx.read(|cx| { +// let picker = picker.read(cx); +// let delegate = &picker.delegate; +// assert!( +// delegate.matches.history.is_empty(), +// "Search matches expected" +// ); +// let matches = delegate.matches.search.clone(); +// assert_eq!(matches.len(), 1); + +// let (file_name, file_name_positions, full_path, full_path_positions) = +// delegate.labels_for_path_match(&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. +// picker +// .update(cx, |f, cx| { +// f.delegate.spawn_search(test_path_like("thf/"), cx) +// }) +// .await; +// picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0)); +// } + +// #[gpui::test] +// async fn test_path_distance_ordering(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/root", +// json!({ +// "dir1": { "a.txt": "" }, +// "dir2": { +// "a.txt": "", +// "b.txt": "" +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; +// let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; + +// let worktree_id = cx.read(|cx| { +// let worktrees = workspace.read(cx).worktrees(cx).collect::>(); +// assert_eq!(worktrees.len(), 1); +// WorktreeId::from_usize(worktrees[0].id()) +// }); + +// // When workspace has an active item, sort items which are closer to that item +// // first when they have the same name. In this case, b.txt is closer to dir2's a.txt +// // so that one should be sorted earlier +// let b_path = Some(dummy_found_path(ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("/root/dir2/b.txt")), +// })); +// cx.dispatch_action(Toggle); + +// let finder = cx +// .add_window(|cx| { +// Picker::new( +// FileFinderDelegate::new( +// workspace.downgrade(), +// workspace.read(cx).project().clone(), +// b_path, +// Vec::new(), +// cx, +// ), +// cx, +// ) +// }) +// .root(cx); + +// finder +// .update(cx, |f, cx| { +// f.delegate.spawn_search(test_path_like("a.txt"), cx) +// }) +// .await; + +// finder.read_with(cx, |f, _| { +// let delegate = &f.delegate; +// assert!( +// delegate.matches.history.is_empty(), +// "Search matches expected" +// ); +// let matches = delegate.matches.search.clone(); +// assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt")); +// assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt")); +// }); +// } + +// #[gpui::test] +// async fn test_search_worktree_without_files(cx: &mut TestAppContext) { +// let app_state = init_test(cx); +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/root", +// json!({ +// "dir1": {}, +// "dir2": { +// "dir3": {} +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; +// let workspace = cx +// .add_window(|cx| Workspace::test_new(project, cx)) +// .root(cx); +// let finder = cx +// .add_window(|cx| { +// Picker::new( +// FileFinderDelegate::new( +// workspace.downgrade(), +// workspace.read(cx).project().clone(), +// None, +// Vec::new(), +// cx, +// ), +// cx, +// ) +// }) +// .root(cx); +// finder +// .update(cx, |f, cx| { +// f.delegate.spawn_search(test_path_like("dir"), cx) +// }) +// .await; +// cx.read(|cx| { +// let finder = finder.read(cx); +// assert_eq!(finder.delegate.matches.len(), 0); +// }); +// } + +// #[gpui::test] +// async fn test_query_history(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// "first.rs": "// First Rust file", +// "second.rs": "// Second Rust file", +// "third.rs": "// Third Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; +// let worktree_id = cx.read(|cx| { +// let worktrees = workspace.read(cx).worktrees(cx).collect::>(); +// assert_eq!(worktrees.len(), 1); +// WorktreeId::from_usize(worktrees[0].id()) +// }); + +// // Open and close panels, getting their history items afterwards. +// // Ensure history items get populated with opened items, and items are kept in a certain order. +// // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen. +// // +// // TODO: without closing, the opened items do not propagate their history changes for some reason +// // it does work in real app though, only tests do not propagate. + +// let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// assert!( +// initial_history.is_empty(), +// "Should have no history before opening any files" +// ); + +// let history_after_first = +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// assert_eq!( +// history_after_first, +// vec![FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// )], +// "Should show 1st opened item in the history when opening the 2nd item" +// ); + +// let history_after_second = +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// assert_eq!( +// history_after_second, +// vec![ +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/second.rs")), +// }, +// Some(PathBuf::from("/src/test/second.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// ), +// ], +// "Should show 1st and 2nd opened items in the history when opening the 3rd item. \ +// 2nd item should be the first in the history, as the last opened." +// ); + +// let history_after_third = +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// assert_eq!( +// history_after_third, +// vec![ +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/third.rs")), +// }, +// Some(PathBuf::from("/src/test/third.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/second.rs")), +// }, +// Some(PathBuf::from("/src/test/second.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// ), +// ], +// "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \ +// 3rd item should be the first in the history, as the last opened." +// ); + +// let history_after_second_again = +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// assert_eq!( +// history_after_second_again, +// vec![ +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/second.rs")), +// }, +// Some(PathBuf::from("/src/test/second.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/third.rs")), +// }, +// Some(PathBuf::from("/src/test/third.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// ), +// ], +// "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \ +// 2nd item, as the last opened, 3rd item should go next as it was opened right before." +// ); +// } + +// #[gpui::test] +// async fn test_external_files_history(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// "first.rs": "// First Rust file", +// "second.rs": "// Second Rust file", +// } +// }), +// ) +// .await; + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/external-src", +// json!({ +// "test": { +// "third.rs": "// Third Rust file", +// "fourth.rs": "// Fourth Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// cx.update(|cx| { +// project.update(cx, |project, cx| { +// project.find_or_create_local_worktree("/external-src", false, cx) +// }) +// }) +// .detach(); +// cx.background_executor.run_until_parked(); + +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; +// let worktree_id = cx.read(|cx| { +// let worktrees = workspace.read(cx).worktrees(cx).collect::>(); +// assert_eq!(worktrees.len(), 1,); + +// WorktreeId::from_usize(worktrees[0].id()) +// }); +// workspace +// .update(cx, |workspace, cx| { +// workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx) +// }) +// .detach(); +// cx.background_executor.run_until_parked(); +// let external_worktree_id = cx.read(|cx| { +// let worktrees = workspace.read(cx).worktrees(cx).collect::>(); +// assert_eq!( +// worktrees.len(), +// 2, +// "External file should get opened in a new worktree" +// ); + +// WorktreeId::from_usize( +// worktrees +// .into_iter() +// .find(|worktree| worktree.entity_id() != worktree_id.to_usize()) +// .expect("New worktree should have a different id") +// .id(), +// ) +// }); +// close_active_item(&workspace, cx).await; + +// let initial_history_items = +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// assert_eq!( +// initial_history_items, +// vec![FoundPath::new( +// ProjectPath { +// worktree_id: external_worktree_id, +// path: Arc::from(Path::new("")), +// }, +// Some(PathBuf::from("/external-src/test/third.rs")) +// )], +// "Should show external file with its full path in the history after it was open" +// ); + +// let updated_history_items = +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// assert_eq!( +// updated_history_items, +// vec![ +// FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/second.rs")), +// }, +// Some(PathBuf::from("/src/test/second.rs")) +// ), +// FoundPath::new( +// ProjectPath { +// worktree_id: external_worktree_id, +// path: Arc::from(Path::new("")), +// }, +// Some(PathBuf::from("/external-src/test/third.rs")) +// ), +// ], +// "Should keep external file with history updates", +// ); +// } + +// #[gpui::test] +// async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// "first.rs": "// First Rust file", +// "second.rs": "// Second Rust file", +// "third.rs": "// Third Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; + +// // generate some history to select from +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// cx.executor().run_until_parked(); +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; + +// for expected_selected_index in 0..current_history.len() { +// cx.dispatch_action(Toggle); +// let selected_index = workspace.update(cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// .read(cx) +// .delegate +// .selected_index() +// }); +// assert_eq!( +// selected_index, expected_selected_index, +// "Should select the next item in the history" +// ); +// } + +// cx.dispatch_action(Toggle); +// let selected_index = workspace.update(cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// .read(cx) +// .delegate +// .selected_index() +// }); +// assert_eq!( +// selected_index, 0, +// "Should wrap around the history and start all over" +// ); +// } + +// #[gpui::test] +// async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// "first.rs": "// First Rust file", +// "second.rs": "// Second Rust file", +// "third.rs": "// Third Rust file", +// "fourth.rs": "// Fourth Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; +// let worktree_id = cx.read(|cx| { +// let worktrees = workspace.read(cx).worktrees(cx).collect::>(); +// assert_eq!(worktrees.len(), 1,); + +// WorktreeId::from_usize(worktrees[0].entity_id()) +// }); + +// // generate some history to select from +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; + +// cx.dispatch_action(Toggle); +// let first_query = "f"; +// let finder = cx.read(|cx| workspace.read(cx).modal::().unwrap()); +// finder +// .update(cx, |finder, cx| { +// finder.delegate.update_matches(first_query.to_string(), cx) +// }) +// .await; +// finder.read_with(cx, |finder, _| { +// let delegate = &finder.delegate; +// assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out"); +// let history_match = delegate.matches.history.first().unwrap(); +// assert!(history_match.1.is_some(), "Should have path matches for history items after querying"); +// assert_eq!(history_match.0, FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// )); +// assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present"); +// assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs")); +// }); + +// let second_query = "fsdasdsa"; +// let finder = workspace.update(cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// }); +// finder +// .update(cx, |finder, cx| { +// finder.delegate.update_matches(second_query.to_string(), cx) +// }) +// .await; +// finder.update(cx, |finder, _| { +// let delegate = &finder.delegate; +// assert!( +// delegate.matches.history.is_empty(), +// "No history entries should match {second_query}" +// ); +// assert!( +// delegate.matches.search.is_empty(), +// "No search entries should match {second_query}" +// ); +// }); + +// let first_query_again = first_query; + +// let finder = workspace.update(cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// }); +// finder +// .update(cx, |finder, cx| { +// finder +// .delegate +// .update_matches(first_query_again.to_string(), cx) +// }) +// .await; +// finder.read_with(cx, |finder, _| { +// let delegate = &finder.delegate; +// assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query_again}, it should be present and others should be filtered out, even after non-matching query"); +// let history_match = delegate.matches.history.first().unwrap(); +// assert!(history_match.1.is_some(), "Should have path matches for history items after querying"); +// assert_eq!(history_match.0, FoundPath::new( +// ProjectPath { +// worktree_id, +// path: Arc::from(Path::new("test/first.rs")), +// }, +// Some(PathBuf::from("/src/test/first.rs")) +// )); +// assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query"); +// assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs")); +// }); +// } + +// #[gpui::test] +// async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "collab_ui": { +// "first.rs": "// First Rust file", +// "second.rs": "// Second Rust file", +// "third.rs": "// Third Rust file", +// "collab_ui.rs": "// Fourth Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; +// // generate some history to select from +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; + +// cx.dispatch_action(Toggle); +// let query = "collab_ui"; +// let finder = cx.read(|cx| workspace.read(cx).modal::().unwrap()); +// finder +// .update(cx, |finder, cx| { +// finder.delegate.update_matches(query.to_string(), cx) +// }) +// .await; +// finder.read_with(cx, |finder, _| { +// let delegate = &finder.delegate; +// assert!( +// delegate.matches.history.is_empty(), +// "History items should not math query {query}, they should be matched by name only" +// ); + +// let search_entries = delegate +// .matches +// .search +// .iter() +// .map(|path_match| path_match.path.to_path_buf()) +// .collect::>(); +// assert_eq!( +// search_entries, +// vec![ +// PathBuf::from("collab_ui/collab_ui.rs"), +// PathBuf::from("collab_ui/third.rs"), +// PathBuf::from("collab_ui/first.rs"), +// PathBuf::from("collab_ui/second.rs"), +// ], +// "Despite all search results having the same directory name, the most matching one should be on top" +// ); +// }); +// } + +// #[gpui::test] +// async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) { +// let app_state = init_test(cx); + +// app_state +// .fs +// .as_fake() +// .insert_tree( +// "/src", +// json!({ +// "test": { +// "first.rs": "// First Rust file", +// "nonexistent.rs": "// Second Rust file", +// "third.rs": "// Third Rust file", +// } +// }), +// ) +// .await; + +// let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// let cx = &mut cx; +// // generate some history to select from +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; +// open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await; +// open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; +// open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; + +// cx.dispatch_action(Toggle); +// let query = "rs"; +// let finder = cx.read(|cx| workspace.read(cx).current_modal::().unwrap()); +// finder +// .update(cx, |finder, cx| { +// finder.picker.update(cx, |picker, cx| { +// picker.delegate.update_matches(query.to_string(), cx) +// }) +// }) +// .await; +// finder.update(cx, |finder, _| { +// let history_entries = finder.delegate +// .matches +// .history +// .iter() +// .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf()) +// .collect::>(); +// assert_eq!( +// history_entries, +// vec![ +// PathBuf::from("test/first.rs"), +// PathBuf::from("test/third.rs"), +// ], +// "Should have all opened files in the history, except the ones that do not exist on disk" +// ); +// }); +// } + +// async fn open_close_queried_buffer( +// input: &str, +// expected_matches: usize, +// expected_editor_title: &str, +// workspace: &View, +// cx: &mut gpui::VisualTestContext<'_>, +// ) -> Vec { +// cx.dispatch_action(Toggle); +// let picker = workspace.update(cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// .clone() +// }); +// picker +// .update(cx, |finder, cx| { +// finder.delegate.update_matches(input.to_string(), cx) +// }) +// .await; +// let history_items = picker.update(cx, |finder, _| { +// assert_eq!( +// finder.delegate.matches.len(), +// expected_matches, +// "Unexpected number of matches found for query {input}" +// ); +// finder.delegate.history_items.clone() +// }); + +// let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); +// cx.dispatch_action(SelectNext); +// cx.dispatch_action(Confirm); +// cx.background_executor.run_until_parked(); +// active_pane +// .condition(cx, |pane, _| pane.active_item().is_some()) +// .await; +// cx.read(|cx| { +// let active_item = active_pane.read(cx).active_item().unwrap(); +// let active_editor_title = active_item +// .to_any() +// .downcast::() +// .unwrap() +// .read(cx) +// .title(cx); +// assert_eq!( +// expected_editor_title, active_editor_title, +// "Unexpected editor title for query {input}" +// ); +// }); + +// close_active_item(workspace, cx).await; + +// history_items +// } + +// async fn close_active_item(workspace: &View, cx: &mut VisualTestContext<'_>) { +// let mut original_items = HashMap::new(); +// cx.read(|cx| { +// for pane in workspace.read(cx).panes() { +// let pane_id = pane.entity_id(); +// let pane = pane.read(cx); +// let insertion_result = original_items.insert(pane_id, pane.items().count()); +// assert!(insertion_result.is_none(), "Pane id {pane_id} collision"); +// } +// }); + +// let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); +// active_pane +// .update(cx, |pane, cx| { +// pane.close_active_item(&workspace::CloseActiveItem { save_intent: None }, cx) +// .unwrap() +// }) +// .await +// .unwrap(); +// cx.background_executor.run_until_parked(); +// cx.read(|cx| { +// for pane in workspace.read(cx).panes() { +// let pane_id = pane.entity_id(); +// let pane = pane.read(cx); +// match original_items.remove(&pane_id) { +// Some(original_items) => { +// assert_eq!( +// pane.items().count(), +// original_items.saturating_sub(1), +// "Pane id {pane_id} should have item closed" +// ); +// } +// None => panic!("Pane id {pane_id} not found in original items"), +// } +// } +// }); +// assert!( +// original_items.len() <= 1, +// "At most one panel should got closed" +// ); +// } + +// fn init_test(cx: &mut TestAppContext) -> Arc { +// cx.update(|cx| { +// let state = AppState::test(cx); +// theme::init(cx); +// language::init(cx); +// super::init(cx); +// editor::init(cx); +// workspace::init_settings(cx); +// Project::init_settings(cx); +// state +// }) +// } + +// fn test_path_like(test_str: &str) -> PathLikeWithPosition { +// PathLikeWithPosition::parse_str(test_str, |path_like_str| { +// Ok::<_, std::convert::Infallible>(FileSearchQuery { +// raw_query: test_str.to_owned(), +// file_query_end: if path_like_str == test_str { +// None +// } else { +// Some(path_like_str.len()) +// }, +// }) +// }) +// .unwrap() +// } + +// fn dummy_found_path(project_path: ProjectPath) -> FoundPath { +// FoundPath { +// project: project_path, +// absolute: None, +// } +// } + +// fn build_find_picker( +// project: Model, +// cx: &mut TestAppContext, +// ) -> ( +// View>, +// View, +// VisualTestContext, +// ) { +// let (workspace, mut cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); +// cx.dispatch_action(Toggle); +// let picker = workspace.update(&mut cx, |workspace, cx| { +// workspace +// .current_modal::(cx) +// .unwrap() +// .read(cx) +// .picker +// .clone() +// }); +// (picker, workspace, cx) +// } +// } diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index 0040469e5f..c76b62b510 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -234,10 +234,10 @@ impl AppContext { app_version: platform.app_version().ok(), }; - Rc::new_cyclic(|this| AppCell { + let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(AppContext { this: this.clone(), - platform, + platform: platform.clone(), app_metadata, text_system, flushing_effects: false, @@ -269,12 +269,21 @@ impl AppContext { layout_id_buffer: Default::default(), propagate_event: true, }), - }) + }); + + platform.on_quit(Box::new({ + let cx = app.clone(); + move || { + cx.borrow_mut().shutdown(); + } + })); + + app } /// Quit the application gracefully. Handlers registered with `ModelContext::on_app_quit` /// will be given 100ms to complete before exiting. - pub fn quit(&mut self) { + pub fn shutdown(&mut self) { let mut futures = Vec::new(); for observer in self.quit_observers.remove(&()) { @@ -292,8 +301,10 @@ impl AppContext { { log::error!("timed out waiting on app_will_quit"); } + } - self.globals_by_type.clear(); + pub fn quit(&mut self) { + self.platform.quit(); } pub fn app_metadata(&self) -> AppMetadata { diff --git a/crates/gpui2/src/app/entity_map.rs b/crates/gpui2/src/app/entity_map.rs index 1ae9aec9b5..1e01921cd4 100644 --- a/crates/gpui2/src/app/entity_map.rs +++ b/crates/gpui2/src/app/entity_map.rs @@ -26,7 +26,7 @@ impl EntityId { impl Display for EntityId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) + write!(f, "{}", self.as_u64()) } } diff --git a/crates/gpui2/src/app/test_context.rs b/crates/gpui2/src/app/test_context.rs index 44c31bbd69..cc59b7a16a 100644 --- a/crates/gpui2/src/app/test_context.rs +++ b/crates/gpui2/src/app/test_context.rs @@ -1,8 +1,8 @@ use crate::{ - div, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, BackgroundExecutor, - Context, Div, EventEmitter, ForegroundExecutor, InputEvent, KeyDownEvent, Keystroke, Model, - ModelContext, Render, Result, Task, TestDispatcher, TestPlatform, View, ViewContext, - VisualContext, WindowContext, WindowHandle, WindowOptions, + div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, + BackgroundExecutor, Context, Div, EventEmitter, ForegroundExecutor, InputEvent, KeyDownEvent, + Keystroke, Model, ModelContext, Render, Result, Task, TestDispatcher, TestPlatform, View, + ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt}; @@ -14,6 +14,7 @@ pub struct TestAppContext { pub background_executor: BackgroundExecutor, pub foreground_executor: ForegroundExecutor, pub dispatcher: TestDispatcher, + pub test_platform: Rc, } impl Context for TestAppContext { @@ -77,17 +78,16 @@ impl TestAppContext { let arc_dispatcher = Arc::new(dispatcher.clone()); let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); - let platform = Rc::new(TestPlatform::new( - background_executor.clone(), - foreground_executor.clone(), - )); + let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); let asset_source = Arc::new(()); let http_client = util::http::FakeHttpClient::with_404_response(); + Self { - app: AppContext::new(platform, asset_source, http_client), + app: AppContext::new(platform.clone(), asset_source, http_client), background_executor, foreground_executor, dispatcher: dispatcher.clone(), + test_platform: platform, } } @@ -96,7 +96,7 @@ impl TestAppContext { } pub fn quit(&self) { - self.app.borrow_mut().quit(); + self.app.borrow_mut().shutdown(); } pub fn refresh(&mut self) -> Result<()> { @@ -152,6 +152,21 @@ impl TestAppContext { (view, VisualTestContext::from_window(*window.deref(), self)) } + pub fn simulate_new_path_selection( + &self, + select_path: impl FnOnce(&std::path::Path) -> Option, + ) { + self.test_platform.simulate_new_path_selection(select_path); + } + + pub fn simulate_prompt_answer(&self, button_ix: usize) { + self.test_platform.simulate_prompt_answer(button_ix); + } + + pub fn has_pending_prompt(&self) -> bool { + self.test_platform.has_pending_prompt() + } + pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -199,6 +214,15 @@ impl TestAppContext { } } + pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: A) + where + A: Action, + { + window + .update(self, |_, cx| cx.dispatch_action(action.boxed_clone())) + .unwrap() + } + pub fn dispatch_keystroke( &mut self, window: AnyWindowHandle, @@ -376,6 +400,13 @@ impl<'a> VisualTestContext<'a> { pub fn from_window(window: AnyWindowHandle, cx: &'a mut TestAppContext) -> Self { Self { cx, window } } + + pub fn dispatch_action(&mut self, action: A) + where + A: Action, + { + self.cx.dispatch_action(self.window, action) + } } impl<'a> Context for VisualTestContext<'a> { diff --git a/crates/gpui2/src/elements/uniform_list.rs b/crates/gpui2/src/elements/uniform_list.rs index 28292a3d00..84cd216275 100644 --- a/crates/gpui2/src/elements/uniform_list.rs +++ b/crates/gpui2/src/elements/uniform_list.rs @@ -14,7 +14,7 @@ use taffy::style::Overflow; pub fn uniform_list( id: I, item_count: usize, - f: impl 'static + Fn(&mut V, Range, &mut ViewContext) -> SmallVec<[C; 64]>, + f: impl 'static + Fn(&mut V, Range, &mut ViewContext) -> Vec, ) -> UniformList where I: Into, diff --git a/crates/gpui2/src/platform/test/platform.rs b/crates/gpui2/src/platform/test/platform.rs index 4afcc4fc1a..df1ed9b2a6 100644 --- a/crates/gpui2/src/platform/test/platform.rs +++ b/crates/gpui2/src/platform/test/platform.rs @@ -3,8 +3,15 @@ use crate::{ PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, }; use anyhow::{anyhow, Result}; +use collections::VecDeque; +use futures::channel::oneshot; use parking_lot::Mutex; -use std::{rc::Rc, sync::Arc}; +use std::{ + cell::RefCell, + path::PathBuf, + rc::{Rc, Weak}, + sync::Arc, +}; pub struct TestPlatform { background_executor: BackgroundExecutor, @@ -13,18 +20,60 @@ pub struct TestPlatform { active_window: Arc>>, active_display: Rc, active_cursor: Mutex, + pub(crate) prompts: RefCell, + weak: Weak, +} + +#[derive(Default)] +pub(crate) struct TestPrompts { + multiple_choice: VecDeque>, + new_path: VecDeque<(PathBuf, oneshot::Sender>)>, } impl TestPlatform { - pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Self { - TestPlatform { + pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { + Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, - + prompts: Default::default(), active_cursor: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), - } + weak: weak.clone(), + }) + } + + pub(crate) fn simulate_new_path_selection( + &self, + select_path: impl FnOnce(&std::path::Path) -> Option, + ) { + let (path, tx) = self + .prompts + .borrow_mut() + .new_path + .pop_front() + .expect("no pending new path prompt"); + tx.send(select_path(&path)).ok(); + } + + pub(crate) fn simulate_prompt_answer(&self, response_ix: usize) { + let tx = self + .prompts + .borrow_mut() + .multiple_choice + .pop_front() + .expect("no pending multiple choice prompt"); + tx.send(response_ix).ok(); + } + + pub(crate) fn has_pending_prompt(&self) -> bool { + !self.prompts.borrow().multiple_choice.is_empty() + } + + pub(crate) fn prompt(&self) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.prompts.borrow_mut().multiple_choice.push_back(tx); + rx } } @@ -46,9 +95,7 @@ impl Platform for TestPlatform { unimplemented!() } - fn quit(&self) { - unimplemented!() - } + fn quit(&self) {} fn restart(&self) { unimplemented!() @@ -88,7 +135,11 @@ impl Platform for TestPlatform { options: WindowOptions, ) -> Box { *self.active_window.lock() = Some(handle); - Box::new(TestWindow::new(options, self.active_display.clone())) + Box::new(TestWindow::new( + options, + self.weak.clone(), + self.active_display.clone(), + )) } fn set_display_link_output_callback( @@ -118,15 +169,20 @@ impl Platform for TestPlatform { fn prompt_for_paths( &self, _options: crate::PathPromptOptions, - ) -> futures::channel::oneshot::Receiver>> { + ) -> oneshot::Receiver>> { unimplemented!() } fn prompt_for_new_path( &self, - _directory: &std::path::Path, - ) -> futures::channel::oneshot::Receiver> { - unimplemented!() + directory: &std::path::Path, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + self.prompts + .borrow_mut() + .new_path + .push_back((directory.to_path_buf(), tx)); + rx } fn reveal_path(&self, _path: &std::path::Path) { @@ -141,9 +197,7 @@ impl Platform for TestPlatform { unimplemented!() } - fn on_quit(&self, _callback: Box) { - unimplemented!() - } + fn on_quit(&self, _callback: Box) {} fn on_reopen(&self, _callback: Box) { unimplemented!() diff --git a/crates/gpui2/src/platform/test/window.rs b/crates/gpui2/src/platform/test/window.rs index cf9143162e..adb15c4266 100644 --- a/crates/gpui2/src/platform/test/window.rs +++ b/crates/gpui2/src/platform/test/window.rs @@ -1,15 +1,13 @@ -use std::{ - rc::Rc, - sync::{self, Arc}, -}; - -use collections::HashMap; -use parking_lot::Mutex; - use crate::{ px, AtlasKey, AtlasTextureId, AtlasTile, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInputHandler, PlatformWindow, Point, Scene, Size, TileId, WindowAppearance, - WindowBounds, WindowOptions, + PlatformInputHandler, PlatformWindow, Point, Scene, Size, TestPlatform, TileId, + WindowAppearance, WindowBounds, WindowOptions, +}; +use collections::HashMap; +use parking_lot::Mutex; +use std::{ + rc::{Rc, Weak}, + sync::{self, Arc}, }; #[derive(Default)] @@ -25,16 +23,22 @@ pub struct TestWindow { current_scene: Mutex>, display: Rc, input_handler: Option>, - handlers: Mutex, + platform: Weak, sprite_atlas: Arc, } + impl TestWindow { - pub fn new(options: WindowOptions, display: Rc) -> Self { + pub fn new( + options: WindowOptions, + platform: Weak, + display: Rc, + ) -> Self { Self { bounds: options.bounds, current_scene: Default::default(), display, + platform, input_handler: None, sprite_atlas: Arc::new(TestAtlas::new()), handlers: Default::default(), @@ -89,7 +93,7 @@ impl PlatformWindow for TestWindow { _msg: &str, _answers: &[&str], ) -> futures::channel::oneshot::Receiver { - todo!() + self.platform.upgrade().expect("platform dropped").prompt() } fn activate(&self) { diff --git a/crates/live_kit_client2/examples/test_app.rs b/crates/live_kit_client2/examples/test_app.rs index 98302eb35c..0b9e54f9b0 100644 --- a/crates/live_kit_client2/examples/test_app.rs +++ b/crates/live_kit_client2/examples/test_app.rs @@ -167,7 +167,7 @@ fn main() { panic!("unexpected message"); } - cx.update(|cx| cx.quit()).ok(); + cx.update(|cx| cx.shutdown()).ok(); }) .detach(); }); diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index a099a025e6..2621c58120 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, bail, Context, Result}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use serde::Deserialize; -use smol::{fs, io::BufReader, process::Command}; +use smol::{fs, io::BufReader, lock::Mutex, process::Command}; use std::process::{Output, Stdio}; use std::{ env::consts, @@ -45,14 +45,19 @@ pub trait NodeRuntime: Send + Sync { pub struct RealNodeRuntime { http: Arc, + installation_lock: Mutex<()>, } impl RealNodeRuntime { pub fn new(http: Arc) -> Arc { - Arc::new(RealNodeRuntime { http }) + Arc::new(RealNodeRuntime { + http, + installation_lock: Mutex::new(()), + }) } async fn install_if_needed(&self) -> Result { + let _lock = self.installation_lock.lock().await; log::info!("Node runtime install_if_needed"); let arch = match consts::ARCH { @@ -73,6 +78,9 @@ impl RealNodeRuntime { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) + .args(["--cache".into(), node_dir.join("cache")]) + .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")]) + .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")]) .status() .await; let valid = matches!(result, Ok(status) if status.success()); @@ -96,6 +104,11 @@ impl RealNodeRuntime { archive.unpack(&node_containing_dir).await?; } + // Note: Not in the `if !valid {}` so we can populate these for existing installations + _ = fs::create_dir(node_dir.join("cache")).await; + _ = fs::write(node_dir.join("blank_user_npmrc"), []).await; + _ = fs::write(node_dir.join("blank_global_npmrc"), []).await; + anyhow::Ok(node_dir) } } @@ -137,7 +150,17 @@ impl NodeRuntime for RealNodeRuntime { let mut command = Command::new(node_binary); command.env("PATH", env_path); - command.arg(npm_file).arg(subcommand).args(args); + command.arg(npm_file).arg(subcommand); + command.args(["--cache".into(), installation_path.join("cache")]); + command.args([ + "--userconfig".into(), + installation_path.join("blank_user_npmrc"), + ]); + command.args([ + "--globalconfig".into(), + installation_path.join("blank_global_npmrc"), + ]); + command.args(args); if let Some(directory) = directory { command.current_dir(directory); diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index 089e6097e4..72a2f812e9 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -58,7 +58,7 @@ impl Picker { self.editor.update(cx, |editor, cx| editor.focus(cx)); } - fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { + pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); @@ -98,6 +98,15 @@ impl Picker { } } + pub fn cycle_selection(&mut self, cx: &mut ViewContext) { + let count = self.delegate.match_count(); + let index = self.delegate.selected_index(); + let new_index = if index + 1 == count { 0 } else { index + 1 }; + self.delegate.set_selected_index(new_index, cx); + self.scroll_handle.scroll_to_item(new_index); + cx.notify(); + } + fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { self.delegate.dismissed(cx); } @@ -137,6 +146,11 @@ impl Picker { } } + pub fn refresh(&mut self, cx: &mut ViewContext) { + let query = self.editor.read(cx).text(cx); + self.update_matches(query, cx); + } + pub fn update_matches(&mut self, query: String, cx: &mut ViewContext) { let update = self.delegate.update_matches(query, cx); self.matches_updated(cx); diff --git a/crates/project_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index 41778c0cb5..79a0f344ae 100644 --- a/crates/project_panel2/src/project_panel.rs +++ b/crates/project_panel2/src/project_panel.rs @@ -8,7 +8,7 @@ use file_associations::FileAssociations; use anyhow::{anyhow, Result}; use gpui::{ - actions, div, px, rems, svg, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext, + actions, div, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext, ClipboardItem, Component, Div, EventEmitter, FocusHandle, Focusable, InteractiveComponent, Model, MouseButton, ParentComponent, Pixels, Point, PromptLevel, Render, Stateful, StatefulInteractiveComponent, Styled, Task, UniformListScrollHandle, View, ViewContext, @@ -21,7 +21,6 @@ use project::{ }; use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings}; use serde::{Deserialize, Serialize}; -use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{hash_map, HashMap}, @@ -31,7 +30,7 @@ use std::{ sync::Arc, }; use theme::ActiveTheme as _; -use ui::{h_stack, v_stack, Label}; +use ui::{h_stack, v_stack, IconElement, Label}; use unicase::UniCase; use util::{maybe, TryFutureExt}; use workspace::{ @@ -197,23 +196,20 @@ impl ProjectPanel { editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => { this.autoscroll(cx); } + editor::Event::Blurred => { + if this + .edit_state + .as_ref() + .map_or(false, |state| state.processing_filename.is_none()) + { + this.edit_state = None; + this.update_visible_entries(None, cx); + } + } _ => {} }) .detach(); - // cx.observe_focus(&filename_editor, |this, _, is_focused, cx| { - // if !is_focused - // && this - // .edit_state - // .as_ref() - // .map_or(false, |state| state.processing_filename.is_none()) - // { - // this.edit_state = None; - // this.update_visible_entries(None, cx); - // } - // }) - // .detach(); - // cx.observe_global::(|_, cx| { // cx.notify(); // }) @@ -1353,14 +1349,7 @@ impl ProjectPanel { h_stack() .child(if let Some(icon) = &details.icon { - div().child( - // todo!() Marshall: Can we use our `IconElement` component here? - svg() - .size(rems(0.9375)) - .flex_none() - .path(icon.to_string()) - .text_color(cx.theme().colors().icon), - ) + div().child(IconElement::from_path(icon.to_string())) } else { div() }) @@ -1468,7 +1457,7 @@ impl Render for ProjectPanel { .map(|(_, worktree_entries)| worktree_entries.len()) .sum(), |this: &mut Self, range, cx| { - let mut items = SmallVec::new(); + let mut items = Vec::new(); this.for_each_visible_entry(range, cx, |id, details, cx| { items.push(this.render_entry(id, details, cx)); }); @@ -1577,1296 +1566,1315 @@ impl ClipboardEntry { } } -// todo!() -// #[cfg(test)] -// mod tests { -// use super::*; -// use gpui::{AnyWindowHandle, TestAppContext, View, WindowHandle}; -// use pretty_assertions::assert_eq; -// use project::FakeFs; -// use serde_json::json; -// use settings::SettingsStore; -// use std::{ -// collections::HashSet, -// path::{Path, PathBuf}, -// sync::atomic::{self, AtomicUsize}, -// }; -// use workspace::{pane, AppState}; - -// #[gpui::test] -// async fn test_visible_list(cx: &mut gpui::TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(cx.executor().clone()); -// fs.insert_tree( -// "/root1", -// json!({ -// ".dockerignore": "", -// ".git": { -// "HEAD": "", -// }, -// "a": { -// "0": { "q": "", "r": "", "s": "" }, -// "1": { "t": "", "u": "" }, -// "2": { "v": "", "w": "", "x": "", "y": "" }, -// }, -// "b": { -// "3": { "Q": "" }, -// "4": { "R": "", "S": "", "T": "", "U": "" }, -// }, -// "C": { -// "5": {}, -// "6": { "V": "", "W": "" }, -// "7": { "X": "" }, -// "8": { "Y": {}, "Z": "" } -// } -// }), -// ) -// .await; -// fs.insert_tree( -// "/root2", -// json!({ -// "d": { -// "9": "" -// }, -// "e": {} -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..50, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// toggle_expand_dir(&panel, "root1/b", cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..50, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b <== selected", -// " > 3", -// " > 4", -// " > C", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// assert_eq!( -// visible_entries_as_strings(&panel, 6..9, cx), -// &[ -// // -// " > C", -// " .dockerignore", -// "v root2", -// ] -// ); -// } - -// #[gpui::test(iterations = 30)] -// async fn test_editing_files(cx: &mut gpui::TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root1", -// json!({ -// ".dockerignore": "", -// ".git": { -// "HEAD": "", -// }, -// "a": { -// "0": { "q": "", "r": "", "s": "" }, -// "1": { "t": "", "u": "" }, -// "2": { "v": "", "w": "", "x": "", "y": "" }, -// }, -// "b": { -// "3": { "Q": "" }, -// "4": { "R": "", "S": "", "T": "", "U": "" }, -// }, -// "C": { -// "5": {}, -// "6": { "V": "", "W": "" }, -// "7": { "X": "" }, -// "8": { "Y": {}, "Z": "" } -// } -// }), -// ) -// .await; -// fs.insert_tree( -// "/root2", -// json!({ -// "d": { -// "9": "" -// }, -// "e": {} -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); -// let workspace = window.root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// select_path(&panel, "root1", cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1 <== selected", -// " > .git", -// " > a", -// " > b", -// " > C", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// // Add a file with the root folder selected. The filename editor is placed -// // before the first file in the root folder. -// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); -// window.read_with(cx, |cx| { -// let panel = panel.read(cx); -// assert!(panel.filename_editor.is_focused(cx)); -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " [EDITOR: ''] <== selected", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// let confirm = panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("the-new-filename", cx)); -// panel.confirm(&Confirm, cx).unwrap() -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " [PROCESSING: 'the-new-filename'] <== selected", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// confirm.await.unwrap(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " .dockerignore", -// " the-new-filename <== selected", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// select_path(&panel, "root1/b", cx); -// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " [EDITOR: ''] <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// panel -// .update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx)); -// panel.confirm(&Confirm, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " another-filename.txt <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// select_path(&panel, "root1/b/another-filename.txt", cx); -// panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " [EDITOR: 'another-filename.txt'] <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// let confirm = panel.update(cx, |panel, cx| { -// panel.filename_editor.update(cx, |editor, cx| { -// let file_name_selections = editor.selections.all::(cx); -// assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); -// let file_name_selection = &file_name_selections[0]; -// assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); -// assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension"); - -// editor.set_text("a-different-filename.tar.gz", cx) -// }); -// panel.confirm(&Confirm, cx).unwrap() -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " [PROCESSING: 'a-different-filename.tar.gz'] <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// confirm.await.unwrap(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " a-different-filename.tar.gz <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3", -// " > 4", -// " [EDITOR: 'a-different-filename.tar.gz'] <== selected", -// " > C", -// " .dockerignore", -// " the-new-filename", -// ] -// ); - -// panel.update(cx, |panel, cx| { -// panel.filename_editor.update(cx, |editor, cx| { -// let file_name_selections = editor.selections.all::(cx); -// assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); -// let file_name_selection = &file_name_selections[0]; -// assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); -// assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot"); - -// }); -// panel.cancel(&Cancel, cx) -// }); - -// panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > [EDITOR: ''] <== selected", -// " > 3", -// " > 4", -// " a-different-filename.tar.gz", -// " > C", -// " .dockerignore", -// ] -// ); - -// let confirm = panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("new-dir", cx)); -// panel.confirm(&Confirm, cx).unwrap() -// }); -// panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > [PROCESSING: 'new-dir']", -// " > 3 <== selected", -// " > 4", -// " a-different-filename.tar.gz", -// " > C", -// " .dockerignore", -// ] -// ); - -// confirm.await.unwrap(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3 <== selected", -// " > 4", -// " > new-dir", -// " a-different-filename.tar.gz", -// " > C", -// " .dockerignore", -// ] -// ); - -// panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx)); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > [EDITOR: '3'] <== selected", -// " > 4", -// " > new-dir", -// " a-different-filename.tar.gz", -// " > C", -// " .dockerignore", -// ] -// ); - -// // Dismiss the rename editor when it loses focus. -// workspace.update(cx, |_, cx| cx.focus_self()); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " v b", -// " > 3 <== selected", -// " > 4", -// " > new-dir", -// " a-different-filename.tar.gz", -// " > C", -// " .dockerignore", -// ] -// ); -// } - -// #[gpui::test(iterations = 30)] -// async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root1", -// json!({ -// ".dockerignore": "", -// ".git": { -// "HEAD": "", -// }, -// "a": { -// "0": { "q": "", "r": "", "s": "" }, -// "1": { "t": "", "u": "" }, -// "2": { "v": "", "w": "", "x": "", "y": "" }, -// }, -// "b": { -// "3": { "Q": "" }, -// "4": { "R": "", "S": "", "T": "", "U": "" }, -// }, -// "C": { -// "5": {}, -// "6": { "V": "", "W": "" }, -// "7": { "X": "" }, -// "8": { "Y": {}, "Z": "" } -// } -// }), -// ) -// .await; -// fs.insert_tree( -// "/root2", -// json!({ -// "d": { -// "9": "" -// }, -// "e": {} -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); -// let workspace = window.root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// select_path(&panel, "root1", cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1 <== selected", -// " > .git", -// " > a", -// " > b", -// " > C", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// // Add a file with the root folder selected. The filename editor is placed -// // before the first file in the root folder. -// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); -// window.read_with(cx, |cx| { -// let panel = panel.read(cx); -// assert!(panel.filename_editor.is_focused(cx)); -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " [EDITOR: ''] <== selected", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// let confirm = panel.update(cx, |panel, cx| { -// panel.filename_editor.update(cx, |editor, cx| { -// editor.set_text("/bdir1/dir2/the-new-filename", cx) -// }); -// panel.confirm(&Confirm, cx).unwrap() -// }); - -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " > C", -// " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); - -// confirm.await.unwrap(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..13, cx), -// &[ -// "v root1", -// " > .git", -// " > a", -// " > b", -// " v bdir1", -// " v dir2", -// " the-new-filename <== selected", -// " > C", -// " .dockerignore", -// "v root2", -// " > d", -// " > e", -// ] -// ); -// } - -// #[gpui::test] -// async fn test_copy_paste(cx: &mut gpui::TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root1", -// json!({ -// "one.two.txt": "", -// "one.txt": "" -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// panel.update(cx, |panel, cx| { -// panel.select_next(&Default::default(), cx); -// panel.select_next(&Default::default(), cx); -// }); - -// assert_eq!( -// visible_entries_as_strings(&panel, 0..50, cx), -// &[ -// // -// "v root1", -// " one.two.txt <== selected", -// " one.txt", -// ] -// ); - -// // Regression test - file name is created correctly when -// // the copied file's name contains multiple dots. -// panel.update(cx, |panel, cx| { -// panel.copy(&Default::default(), cx); -// panel.paste(&Default::default(), cx); -// }); -// cx.foreground().run_until_parked(); - -// assert_eq!( -// visible_entries_as_strings(&panel, 0..50, cx), -// &[ -// // -// "v root1", -// " one.two copy.txt", -// " one.two.txt <== selected", -// " one.txt", -// ] -// ); - -// panel.update(cx, |panel, cx| { -// panel.paste(&Default::default(), cx); -// }); -// cx.foreground().run_until_parked(); - -// assert_eq!( -// visible_entries_as_strings(&panel, 0..50, cx), -// &[ -// // -// "v root1", -// " one.two copy 1.txt", -// " one.two copy.txt", -// " one.two.txt <== selected", -// " one.txt", -// ] -// ); -// } - -// #[gpui::test] -// async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { -// init_test_with_editor(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/src", -// json!({ -// "test": { -// "first.rs": "// First Rust file", -// "second.rs": "// Second Rust file", -// "third.rs": "// Third Rust file", -// } -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); -// let workspace = window.root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// toggle_expand_dir(&panel, "src/test", cx); -// select_path(&panel, "src/test/first.rs", cx); -// panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " first.rs <== selected", -// " second.rs", -// " third.rs" -// ] -// ); -// ensure_single_file_is_opened(window, "test/first.rs", cx); - -// submit_deletion(window.into(), &panel, cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " second.rs", -// " third.rs" -// ], -// "Project panel should have no deleted file, no other file is selected in it" -// ); -// ensure_no_open_items_and_panes(window.into(), &workspace, cx); - -// select_path(&panel, "src/test/second.rs", cx); -// panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " second.rs <== selected", -// " third.rs" -// ] -// ); -// ensure_single_file_is_opened(window, "test/second.rs", cx); - -// window.update(cx, |cx| { -// let active_items = workspace -// .read(cx) -// .panes() -// .iter() -// .filter_map(|pane| pane.read(cx).active_item()) -// .collect::>(); -// assert_eq!(active_items.len(), 1); -// let open_editor = active_items -// .into_iter() -// .next() -// .unwrap() -// .downcast::() -// .expect("Open item should be an editor"); -// open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx)); -// }); -// submit_deletion(window.into(), &panel, cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v src", " v test", " third.rs"], -// "Project panel should have no deleted file, with one last file remaining" -// ); -// ensure_no_open_items_and_panes(window.into(), &workspace, cx); -// } - -// #[gpui::test] -// async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) { -// init_test_with_editor(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/src", -// json!({ -// "test": { -// "first.rs": "// First Rust file", -// "second.rs": "// Second Rust file", -// "third.rs": "// Third Rust file", -// } -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); -// let workspace = window.root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// select_path(&panel, "src/", cx); -// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v src <== selected", " > test"] -// ); -// panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx)); -// window.read_with(cx, |cx| { -// let panel = panel.read(cx); -// assert!(panel.filename_editor.is_focused(cx)); -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v src", " > [EDITOR: ''] <== selected", " > test"] -// ); -// panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("test", cx)); -// assert!( -// panel.confirm(&Confirm, cx).is_none(), -// "Should not allow to confirm on conflicting new directory name" -// ) -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v src", " > test"], -// "File list should be unchanged after failed folder create confirmation" -// ); - -// select_path(&panel, "src/test/", cx); -// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v src", " > test <== selected"] -// ); -// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); -// window.read_with(cx, |cx| { -// let panel = panel.read(cx); -// assert!(panel.filename_editor.is_focused(cx)); -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " [EDITOR: ''] <== selected", -// " first.rs", -// " second.rs", -// " third.rs" -// ] -// ); -// panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("first.rs", cx)); -// assert!( -// panel.confirm(&Confirm, cx).is_none(), -// "Should not allow to confirm on conflicting new file name" -// ) -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " first.rs", -// " second.rs", -// " third.rs" -// ], -// "File list should be unchanged after failed file create confirmation" -// ); - -// select_path(&panel, "src/test/first.rs", cx); -// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " first.rs <== selected", -// " second.rs", -// " third.rs" -// ], -// ); -// panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); -// window.read_with(cx, |cx| { -// let panel = panel.read(cx); -// assert!(panel.filename_editor.is_focused(cx)); -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " [EDITOR: 'first.rs'] <== selected", -// " second.rs", -// " third.rs" -// ] -// ); -// panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("second.rs", cx)); -// assert!( -// panel.confirm(&Confirm, cx).is_none(), -// "Should not allow to confirm on conflicting file rename" -// ) -// }); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " first.rs <== selected", -// " second.rs", -// " third.rs" -// ], -// "File list should be unchanged after failed rename confirmation" -// ); -// } - -// #[gpui::test] -// async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) { -// init_test_with_editor(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/src", -// json!({ -// "test": { -// "first.rs": "// First Rust file", -// "second.rs": "// Second Rust file", -// "third.rs": "// Third Rust file", -// } -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// let new_search_events_count = Arc::new(AtomicUsize::new(0)); -// let _subscription = panel.update(cx, |_, cx| { -// let subcription_count = Arc::clone(&new_search_events_count); -// cx.subscribe(&cx.handle(), move |_, _, event, _| { -// if matches!(event, Event::NewSearchInDirectory { .. }) { -// subcription_count.fetch_add(1, atomic::Ordering::SeqCst); -// } -// }) -// }); - -// toggle_expand_dir(&panel, "src/test", cx); -// select_path(&panel, "src/test/first.rs", cx); -// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test", -// " first.rs <== selected", -// " second.rs", -// " third.rs" -// ] -// ); -// panel.update(cx, |panel, cx| { -// panel.new_search_in_directory(&NewSearchInDirectory, cx) -// }); -// assert_eq!( -// new_search_events_count.load(atomic::Ordering::SeqCst), -// 0, -// "Should not trigger new search in directory when called on a file" -// ); - -// select_path(&panel, "src/test", cx); -// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v src", -// " v test <== selected", -// " first.rs", -// " second.rs", -// " third.rs" -// ] -// ); -// panel.update(cx, |panel, cx| { -// panel.new_search_in_directory(&NewSearchInDirectory, cx) -// }); -// assert_eq!( -// new_search_events_count.load(atomic::Ordering::SeqCst), -// 1, -// "Should trigger new search in directory when called on a directory" -// ); -// } - -// #[gpui::test] -// async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { -// init_test_with_editor(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/project_root", -// json!({ -// "dir_1": { -// "nested_dir": { -// "file_a.py": "# File contents", -// "file_b.py": "# File contents", -// "file_c.py": "# File contents", -// }, -// "file_1.py": "# File contents", -// "file_2.py": "# File contents", -// "file_3.py": "# File contents", -// }, -// "dir_2": { -// "file_1.py": "# File contents", -// "file_2.py": "# File contents", -// "file_3.py": "# File contents", -// } -// }), -// ) -// .await; - -// let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// panel.update(cx, |panel, cx| { -// panel.collapse_all_entries(&CollapseAllEntries, cx) -// }); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v project_root", " > dir_1", " > dir_2",] -// ); - -// // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries -// toggle_expand_dir(&panel, "project_root/dir_1", cx); -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &[ -// "v project_root", -// " v dir_1 <== selected", -// " > nested_dir", -// " file_1.py", -// " file_2.py", -// " file_3.py", -// " > dir_2", -// ] -// ); -// } - -// #[gpui::test] -// async fn test_new_file_move(cx: &mut gpui::TestAppContext) { -// init_test(cx); - -// let fs = FakeFs::new(cx.background()); -// fs.as_fake().insert_tree("/root", json!({})).await; -// let project = Project::test(fs, ["/root".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); - -// // Make a new buffer with no backing file -// workspace.update(cx, |workspace, cx| { -// Editor::new_file(workspace, &Default::default(), cx) -// }); - -// // "Save as"" the buffer, creating a new backing file for it -// let task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(workspace::SaveIntent::Save, cx) -// }); - -// cx.foreground().run_until_parked(); -// cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new"))); -// task.await.unwrap(); - -// // Rename the file -// select_path(&panel, "root/new", cx); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v root", " new <== selected"] -// ); -// panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); -// panel.update(cx, |panel, cx| { -// panel -// .filename_editor -// .update(cx, |editor, cx| editor.set_text("newer", cx)); -// }); -// panel -// .update(cx, |panel, cx| panel.confirm(&Confirm, cx)) -// .unwrap() -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v root", " newer <== selected"] -// ); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.save_active_item(workspace::SaveIntent::Save, cx) -// }) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); -// // assert that saving the file doesn't restore "new" -// assert_eq!( -// visible_entries_as_strings(&panel, 0..10, cx), -// &["v root", " newer <== selected"] -// ); -// } - -// fn toggle_expand_dir( -// panel: &View, -// path: impl AsRef, -// cx: &mut TestAppContext, -// ) { -// let path = path.as_ref(); -// panel.update(cx, |panel, cx| { -// for worktree in panel.project.read(cx).worktrees().collect::>() { -// let worktree = worktree.read(cx); -// if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { -// let entry_id = worktree.entry_for_path(relative_path).unwrap().id; -// panel.toggle_expanded(entry_id, cx); -// return; -// } -// } -// panic!("no worktree for path {:?}", path); -// }); -// } - -// fn select_path(panel: &View, path: impl AsRef, cx: &mut TestAppContext) { -// let path = path.as_ref(); -// panel.update(cx, |panel, cx| { -// for worktree in panel.project.read(cx).worktrees().collect::>() { -// let worktree = worktree.read(cx); -// if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { -// let entry_id = worktree.entry_for_path(relative_path).unwrap().id; -// panel.selection = Some(Selection { -// worktree_id: worktree.id(), -// entry_id, -// }); -// return; -// } -// } -// panic!("no worktree for path {:?}", path); -// }); -// } - -// fn visible_entries_as_strings( -// panel: &View, -// range: Range, -// cx: &mut TestAppContext, -// ) -> Vec { -// let mut result = Vec::new(); -// let mut project_entries = HashSet::new(); -// let mut has_editor = false; - -// panel.update(cx, |panel, cx| { -// panel.for_each_visible_entry(range, cx, |project_entry, details, _| { -// if details.is_editing { -// assert!(!has_editor, "duplicate editor entry"); -// has_editor = true; -// } else { -// assert!( -// project_entries.insert(project_entry), -// "duplicate project entry {:?} {:?}", -// project_entry, -// details -// ); -// } - -// let indent = " ".repeat(details.depth); -// let icon = if details.kind.is_dir() { -// if details.is_expanded { -// "v " -// } else { -// "> " -// } -// } else { -// " " -// }; -// let name = if details.is_editing { -// format!("[EDITOR: '{}']", details.filename) -// } else if details.is_processing { -// format!("[PROCESSING: '{}']", details.filename) -// } else { -// details.filename.clone() -// }; -// let selected = if details.is_selected { -// " <== selected" -// } else { -// "" -// }; -// result.push(format!("{indent}{icon}{name}{selected}")); -// }); -// }); - -// result -// } - -// fn init_test(cx: &mut TestAppContext) { -// cx.foreground().forbid_parking(); -// cx.update(|cx| { -// cx.set_global(SettingsStore::test(cx)); -// init_settings(cx); -// theme::init(cx); -// language::init(cx); -// editor::init_settings(cx); -// crate::init((), cx); -// workspace::init_settings(cx); -// client::init_settings(cx); -// Project::init_settings(cx); -// }); -// } - -// fn init_test_with_editor(cx: &mut TestAppContext) { -// cx.foreground().forbid_parking(); -// cx.update(|cx| { -// let app_state = AppState::test(cx); -// theme::init(cx); -// init_settings(cx); -// language::init(cx); -// editor::init(cx); -// pane::init(cx); -// crate::init((), cx); -// workspace::init(app_state.clone(), cx); -// Project::init_settings(cx); -// }); -// } - -// fn ensure_single_file_is_opened( -// window: WindowHandle, -// expected_path: &str, -// cx: &mut TestAppContext, -// ) { -// window.update_root(cx, |workspace, cx| { -// let worktrees = workspace.worktrees(cx).collect::>(); -// assert_eq!(worktrees.len(), 1); -// let worktree_id = WorktreeId::from_usize(worktrees[0].id()); - -// let open_project_paths = workspace -// .panes() -// .iter() -// .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) -// .collect::>(); -// assert_eq!( -// open_project_paths, -// vec![ProjectPath { -// worktree_id, -// path: Arc::from(Path::new(expected_path)) -// }], -// "Should have opened file, selected in project panel" -// ); -// }); -// } - -// fn submit_deletion( -// window: AnyWindowHandle, -// panel: &View, -// cx: &mut TestAppContext, -// ) { -// assert!( -// !window.has_pending_prompt(cx), -// "Should have no prompts before the deletion" -// ); -// panel.update(cx, |panel, cx| { -// panel -// .delete(&Delete, cx) -// .expect("Deletion start") -// .detach_and_log_err(cx); -// }); -// assert!( -// window.has_pending_prompt(cx), -// "Should have a prompt after the deletion" -// ); -// window.simulate_prompt_answer(0, cx); -// assert!( -// !window.has_pending_prompt(cx), -// "Should have no prompts after prompt was replied to" -// ); -// cx.foreground().run_until_parked(); -// } - -// fn ensure_no_open_items_and_panes( -// window: AnyWindowHandle, -// workspace: &View, -// cx: &mut TestAppContext, -// ) { -// assert!( -// !window.has_pending_prompt(cx), -// "Should have no prompts after deletion operation closes the file" -// ); -// window.read_with(cx, |cx| { -// let open_project_paths = workspace -// .read(cx) -// .panes() -// .iter() -// .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) -// .collect::>(); -// assert!( -// open_project_paths.is_empty(), -// "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" -// ); -// }); -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, View, VisualTestContext, WindowHandle}; + use pretty_assertions::assert_eq; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::atomic::{self, AtomicUsize}, + }; + use workspace::{pane, AppState}; + + #[gpui::test] + async fn test_visible_list(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root1/b", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > .git", + " > a", + " v b <== selected", + " > 3", + " > 4", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + assert_eq!( + visible_entries_as_strings(&panel, 6..9, cx), + &[ + // + " > C", + " .dockerignore", + "v root2", + ] + ); + } + + #[gpui::test(iterations = 30)] + async fn test_editing_files(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1 <== selected", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + // Add a file with the root folder selected. The filename editor is placed + // before the first file in the root folder. + panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); + panel.update(cx, |panel, cx| { + assert!(panel.filename_editor.read(cx).is_focused(cx)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [EDITOR: ''] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + let confirm = panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("the-new-filename", cx)); + panel.confirm_edit(cx).unwrap() + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [PROCESSING: 'the-new-filename'] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + " the-new-filename <== selected", + "v root2", + " > d", + " > e", + ] + ); + + select_path(&panel, "root1/b", cx); + panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: ''] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel + .update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx)); + panel.confirm_edit(cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " another-filename.txt <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + select_path(&panel, "root1/b/another-filename.txt", cx); + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: 'another-filename.txt'] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + let confirm = panel.update(cx, |panel, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); + let file_name_selection = &file_name_selections[0]; + assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); + assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension"); + + editor.set_text("a-different-filename.tar.gz", cx) + }); + panel.confirm_edit(cx).unwrap() + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [PROCESSING: 'a-different-filename.tar.gz'] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " a-different-filename.tar.gz <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: 'a-different-filename.tar.gz'] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel.update(cx, |panel, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); + let file_name_selection = &file_name_selections[0]; + assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); + assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot.."); + + }); + panel.cancel(&Cancel, cx) + }); + + panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > [EDITOR: ''] <== selected", + " > 3", + " > 4", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + + let confirm = panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new-dir", cx)); + panel.confirm_edit(cx).unwrap() + }); + panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > [PROCESSING: 'new-dir']", + " > 3 <== selected", + " > 4", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3 <== selected", + " > 4", + " > new-dir", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + + panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > [EDITOR: '3'] <== selected", + " > 4", + " > new-dir", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + + // Dismiss the rename editor when it loses focus. + workspace.update(cx, |_, cx| cx.blur()).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3 <== selected", + " > 4", + " > new-dir", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + } + + #[gpui::test(iterations = 10)] + async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1 <== selected", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + // Add a file with the root folder selected. The filename editor is placed + // before the first file in the root folder. + panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); + panel.update(cx, |panel, cx| { + assert!(panel.filename_editor.read(cx).is_focused(cx)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [EDITOR: ''] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + let confirm = panel.update(cx, |panel, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("/bdir1/dir2/the-new-filename", cx) + }); + panel.confirm_edit(cx).unwrap() + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..13, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " v bdir1", + " v dir2", + " the-new-filename <== selected", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + } + + #[gpui::test] + async fn test_copy_paste(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "one.two.txt": "", + "one.txt": "" + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + panel.update(cx, |panel, cx| { + panel.select_next(&Default::default(), cx); + panel.select_next(&Default::default(), cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.two.txt <== selected", + " one.txt", + ] + ); + + // Regression test - file name is created correctly when + // the copied file's name contains multiple dots. + panel.update(cx, |panel, cx| { + panel.copy(&Default::default(), cx); + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.two copy.txt", + " one.two.txt <== selected", + " one.txt", + ] + ); + + panel.update(cx, |panel, cx| { + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.two copy 1.txt", + " one.two copy.txt", + " one.two.txt <== selected", + " one.txt", + ] + ); + } + + #[gpui::test] + async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/first.rs", cx); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " second.rs", + " third.rs" + ], + "Project panel should have no deleted file, no other file is selected in it" + ); + ensure_no_open_items_and_panes(&workspace, cx); + + select_path(&panel, "src/test/second.rs", cx); + panel.update(cx, |panel, cx| panel.open_file(&Open, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " second.rs <== selected", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/second.rs", cx); + + workspace + .update(cx, |workspace, cx| { + let active_items = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()) + .collect::>(); + assert_eq!(active_items.len(), 1); + let open_editor = active_items + .into_iter() + .next() + .unwrap() + .downcast::() + .expect("Open item should be an editor"); + open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx)); + }) + .unwrap(); + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v src", " v test", " third.rs"], + "Project panel should have no deleted file, with one last file remaining" + ); + ensure_no_open_items_and_panes(&workspace, cx); + } + + #[gpui::test] + async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + select_path(&panel, "src/", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src <== selected", + " > test" + ] + ); + panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx)); + panel.update(cx, |panel, cx| { + assert!(panel.filename_editor.read(cx).is_focused(cx)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > [EDITOR: ''] <== selected", + " > test" + ] + ); + panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("test", cx)); + assert!( + panel.confirm_edit(cx).is_none(), + "Should not allow to confirm on conflicting new directory name" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > test" + ], + "File list should be unchanged after failed folder create confirmation" + ); + + select_path(&panel, "src/test/", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > test <== selected" + ] + ); + panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx)); + panel.update(cx, |panel, cx| { + assert!(panel.filename_editor.read(cx).is_focused(cx)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " [EDITOR: ''] <== selected", + " first.rs", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("first.rs", cx)); + assert!( + panel.confirm_edit(cx).is_none(), + "Should not allow to confirm on conflicting new file name" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs", + " second.rs", + " third.rs" + ], + "File list should be unchanged after failed file create confirmation" + ); + + select_path(&panel, "src/test/first.rs", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ], + ); + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + panel.update(cx, |panel, cx| { + assert!(panel.filename_editor.read(cx).is_focused(cx)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " [EDITOR: 'first.rs'] <== selected", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("second.rs", cx)); + assert!( + panel.confirm_edit(cx).is_none(), + "Should not allow to confirm on conflicting file rename" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ], + "File list should be unchanged after failed rename confirmation" + ); + } + + #[gpui::test] + async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + let new_search_events_count = Arc::new(AtomicUsize::new(0)); + let _subscription = panel.update(cx, |_, cx| { + let subcription_count = Arc::clone(&new_search_events_count); + let view = cx.view().clone(); + cx.subscribe(&view, move |_, _, event, _| { + if matches!(event, Event::NewSearchInDirectory { .. }) { + subcription_count.fetch_add(1, atomic::Ordering::SeqCst); + } + }) + }); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel.new_search_in_directory(&NewSearchInDirectory, cx) + }); + assert_eq!( + new_search_events_count.load(atomic::Ordering::SeqCst), + 0, + "Should not trigger new search in directory when called on a file" + ); + + select_path(&panel, "src/test", cx); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test <== selected", + " first.rs", + " second.rs", + " third.rs" + ] + ); + panel.update(cx, |panel, cx| { + panel.new_search_in_directory(&NewSearchInDirectory, cx) + }); + assert_eq!( + new_search_events_count.load(atomic::Ordering::SeqCst), + 1, + "Should trigger new search in directory when called on a directory" + ); + } + + #[gpui::test] + async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", + }, + "dir_2": { + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + panel.update(cx, |panel, cx| { + panel.collapse_all_entries(&CollapseAllEntries, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v project_root", " > dir_1", " > dir_2",] + ); + + // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries + toggle_expand_dir(&panel, "project_root/dir_1", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1 <== selected", + " > nested_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + ] + ); + } + + #[gpui::test] + async fn test_new_file_move(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.as_fake().insert_tree("/root", json!({})).await; + let project = Project::test(fs, ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + // Make a new buffer with no backing file + workspace + .update(cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + .unwrap(); + + // "Save as"" the buffer, creating a new backing file for it + let save_task = workspace + .update(cx, |workspace, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, cx) + }) + .unwrap(); + + cx.executor().run_until_parked(); + cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new"))); + save_task.await.unwrap(); + + // Rename the file + select_path(&panel, "root/new", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " new <== selected"] + ); + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("newer", cx)); + }); + panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx)); + + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); + + workspace + .update(cx, |workspace, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, cx) + }) + .unwrap() + .await + .unwrap(); + + cx.executor().run_until_parked(); + // assert that saving the file doesn't restore "new" + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); + } + + fn toggle_expand_dir( + panel: &View, + path: impl AsRef, + cx: &mut VisualTestContext, + ) { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees().collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + let entry_id = worktree.entry_for_path(relative_path).unwrap().id; + panel.toggle_expanded(entry_id, cx); + return; + } + } + panic!("no worktree for path {:?}", path); + }); + } + + fn select_path(panel: &View, path: impl AsRef, cx: &mut VisualTestContext) { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees().collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + let entry_id = worktree.entry_for_path(relative_path).unwrap().id; + panel.selection = Some(Selection { + worktree_id: worktree.id(), + entry_id, + }); + return; + } + } + panic!("no worktree for path {:?}", path); + }); + } + + fn visible_entries_as_strings( + panel: &View, + range: Range, + cx: &mut VisualTestContext, + ) -> Vec { + let mut result = Vec::new(); + let mut project_entries = HashSet::new(); + let mut has_editor = false; + + panel.update(cx, |panel, cx| { + panel.for_each_visible_entry(range, cx, |project_entry, details, _| { + if details.is_editing { + assert!(!has_editor, "duplicate editor entry"); + has_editor = true; + } else { + assert!( + project_entries.insert(project_entry), + "duplicate project entry {:?} {:?}", + project_entry, + details + ); + } + + let indent = " ".repeat(details.depth); + let icon = if details.kind.is_dir() { + if details.is_expanded { + "v " + } else { + "> " + } + } else { + " " + }; + let name = if details.is_editing { + format!("[EDITOR: '{}']", details.filename) + } else if details.is_processing { + format!("[PROCESSING: '{}']", details.filename) + } else { + details.filename.clone() + }; + let selected = if details.is_selected { + " <== selected" + } else { + "" + }; + result.push(format!("{indent}{icon}{name}{selected}")); + }); + }); + + result + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init_settings(cx); + theme::init(cx); + language::init(cx); + editor::init_settings(cx); + crate::init((), cx); + workspace::init_settings(cx); + client::init_settings(cx); + Project::init_settings(cx); + }); + } + + fn init_test_with_editor(cx: &mut TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + theme::init(cx); + init_settings(cx); + language::init(cx); + editor::init(cx); + pane::init(cx); + crate::init((), cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + } + + fn ensure_single_file_is_opened( + window: &WindowHandle, + expected_path: &str, + cx: &mut TestAppContext, + ) { + window + .update(cx, |workspace, cx| { + let worktrees = workspace.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + let worktree_id = worktrees[0].read(cx).id(); + + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert_eq!( + open_project_paths, + vec![ProjectPath { + worktree_id, + path: Arc::from(Path::new(expected_path)) + }], + "Should have opened file, selected in project panel" + ); + }) + .unwrap(); + } + + fn submit_deletion(panel: &View, cx: &mut VisualTestContext) { + assert!( + !cx.has_pending_prompt(), + "Should have no prompts before the deletion" + ); + panel.update(cx, |panel, cx| panel.delete(&Delete, cx)); + assert!( + cx.has_pending_prompt(), + "Should have a prompt after the deletion" + ); + cx.simulate_prompt_answer(0); + assert!( + !cx.has_pending_prompt(), + "Should have no prompts after prompt was replied to" + ); + cx.executor().run_until_parked(); + } + + fn ensure_no_open_items_and_panes( + workspace: &WindowHandle, + cx: &mut VisualTestContext, + ) { + assert!( + !cx.has_pending_prompt(), + "Should have no prompts after deletion operation closes the file" + ); + workspace + .read_with(cx, |workspace, cx| { + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert!( + open_project_paths.is_empty(), + "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" + ); + }) + .unwrap(); + } +} diff --git a/crates/theme2/src/one_themes.rs b/crates/theme2/src/one_themes.rs index 6e32eace73..733cd6c40b 100644 --- a/crates/theme2/src/one_themes.rs +++ b/crates/theme2/src/one_themes.rs @@ -35,6 +35,7 @@ pub(crate) fn one_dark() -> Theme { id: "one_dark".to_string(), name: "One Dark".into(), appearance: Appearance::Dark, + styles: ThemeStyles { system: SystemColors::default(), colors: ThemeColors { diff --git a/crates/theme2/src/settings.rs b/crates/theme2/src/settings.rs index 8a15b52641..5e3329ffa1 100644 --- a/crates/theme2/src/settings.rs +++ b/crates/theme2/src/settings.rs @@ -19,6 +19,7 @@ const MIN_LINE_HEIGHT: f32 = 1.0; #[derive(Clone)] pub struct ThemeSettings { pub ui_font_size: Pixels, + pub ui_font: Font, pub buffer_font: Font, pub buffer_font_size: Pixels, pub buffer_line_height: BufferLineHeight, @@ -120,6 +121,12 @@ impl settings::Settings for ThemeSettings { let mut this = Self { ui_font_size: defaults.ui_font_size.unwrap_or(16.).into(), + ui_font: Font { + family: "Helvetica".into(), + features: Default::default(), + weight: Default::default(), + style: Default::default(), + }, buffer_font: Font { family: defaults.buffer_font_family.clone().unwrap().into(), features: defaults.buffer_font_features.clone().unwrap(), diff --git a/crates/ui2/src/components/icon.rs b/crates/ui2/src/components/icon.rs index 5b60421205..a0ef496d18 100644 --- a/crates/ui2/src/components/icon.rs +++ b/crates/ui2/src/components/icon.rs @@ -129,7 +129,7 @@ impl Icon { #[derive(Component)] pub struct IconElement { - icon: Icon, + path: SharedString, color: TextColor, size: IconSize, } @@ -137,7 +137,15 @@ pub struct IconElement { impl IconElement { pub fn new(icon: Icon) -> Self { Self { - icon, + path: icon.path().into(), + color: TextColor::default(), + size: IconSize::default(), + } + } + + pub fn from_path(path: impl Into) -> Self { + Self { + path: path.into(), color: TextColor::default(), size: IconSize::default(), } @@ -162,7 +170,7 @@ impl IconElement { svg() .size(svg_size) .flex_none() - .path(self.icon.path()) + .path(self.path) .text_color(self.color.color(cx)) } } diff --git a/crates/ui2/src/components/tooltip.rs b/crates/ui2/src/components/tooltip.rs index ca9e6d3eac..8463ed7ba4 100644 --- a/crates/ui2/src/components/tooltip.rs +++ b/crates/ui2/src/components/tooltip.rs @@ -1,5 +1,6 @@ -use gpui::{Div, ParentComponent, Render, SharedString, Styled, ViewContext}; -use theme2::ActiveTheme; +use gpui::{Div, Render}; +use settings2::Settings; +use theme2::{ActiveTheme, ThemeSettings}; use crate::prelude::*; use crate::{h_stack, v_stack, KeyBinding, Label, LabelSize, StyledExt, TextColor}; @@ -34,9 +35,10 @@ impl Render for TextTooltip { type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); v_stack() .elevation_2(cx) - .font("Zed Sans") + .font(ui_font) .text_ui_sm() .text_color(cx.theme().colors().text) .py_1() diff --git a/crates/ui2/src/to_extract/workspace.rs b/crates/ui2/src/to_extract/workspace.rs index d6de8a8288..0451a9d032 100644 --- a/crates/ui2/src/to_extract/workspace.rs +++ b/crates/ui2/src/to_extract/workspace.rs @@ -206,13 +206,14 @@ impl Render for Workspace { .child(self.editor_1.clone())], SplitDirection::Horizontal, ); + let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); div() .relative() .size_full() .flex() .flex_col() - .font("Zed Sans") + .font(ui_font) .gap_0() .justify_start() .items_start() diff --git a/crates/workspace2/src/dock.rs b/crates/workspace2/src/dock.rs index 7c732e8f48..455148391b 100644 --- a/crates/workspace2/src/dock.rs +++ b/crates/workspace2/src/dock.rs @@ -1,7 +1,8 @@ use crate::{status_bar::StatusItemView, Axis, Workspace}; use gpui::{ - div, Action, AnyView, AppContext, Div, Entity, EntityId, EventEmitter, FocusHandle, - ParentComponent, Render, Styled, Subscription, View, ViewContext, WeakView, WindowContext, + div, px, Action, AnyView, AppContext, Component, Div, Entity, EntityId, EventEmitter, + FocusHandle, ParentComponent, Render, Styled, Subscription, View, ViewContext, WeakView, + WindowContext, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -429,7 +430,14 @@ impl Render for Dock { fn render(&mut self, cx: &mut ViewContext) -> Self::Element { if let Some(entry) = self.visible_entry() { - div().size_full().child(entry.panel.to_any()) + let size = entry.panel.size(cx); + + div() + .map(|this| match self.position().axis() { + Axis::Horizontal => this.w(px(size)).h_full(), + Axis::Vertical => this.h(px(size)).w_full(), + }) + .child(entry.panel.to_any()) } else { div() } diff --git a/crates/workspace2/src/modal_layer.rs b/crates/workspace2/src/modal_layer.rs index c9dddfdace..c91df732c7 100644 --- a/crates/workspace2/src/modal_layer.rs +++ b/crates/workspace2/src/modal_layer.rs @@ -71,6 +71,14 @@ impl ModalLayer { cx.notify(); } + + pub fn current_modal(&self) -> Option> + where + V: 'static, + { + let active_modal = self.active_modal.as_ref()?; + active_modal.modal.clone().downcast::().ok() + } } impl Render for ModalLayer { diff --git a/crates/workspace2/src/pane/dragged_item_receiver.rs b/crates/workspace2/src/pane/dragged_item_receiver.rs index d8e967dd75..3e1f6393a6 100644 --- a/crates/workspace2/src/pane/dragged_item_receiver.rs +++ b/crates/workspace2/src/pane/dragged_item_receiver.rs @@ -2,7 +2,7 @@ use super::DraggedItem; use crate::{Pane, SplitDirection, Workspace}; use gpui::{ color::Color, - elements::{Canvas, MouseEventHandler, ParentElement, Stack}, + elements::{Canvas, MouseEventHandler, ParentComponent, Stack}, geometry::{rect::RectF, vector::Vector2F}, platform::MouseButton, scene::MouseUp, diff --git a/crates/workspace2/src/workspace2.rs b/crates/workspace2/src/workspace2.rs index f0b40ab883..13997e7588 100644 --- a/crates/workspace2/src/workspace2.rs +++ b/crates/workspace2/src/workspace2.rs @@ -66,9 +66,10 @@ use std::{ sync::{atomic::AtomicUsize, Arc}, time::Duration, }; -use theme2::ActiveTheme; +use theme2::{ActiveTheme, ThemeSettings}; pub use toolbar::{ToolbarItemLocation, ToolbarItemView}; -use ui::{h_stack, Button, ButtonVariant, KeyBinding, Label, TextColor, TextTooltip}; +use ui::TextColor; +use ui::{h_stack, Button, ButtonVariant, KeyBinding, Label, TextTooltip}; use util::ResultExt; use uuid::Uuid; pub use workspace_settings::{AutosaveSetting, WorkspaceSettings}; @@ -1765,50 +1766,50 @@ impl Workspace { }) } - // pub fn open_abs_path( - // &mut self, - // abs_path: PathBuf, - // visible: bool, - // cx: &mut ViewContext, - // ) -> Task>> { - // cx.spawn(|workspace, mut cx| async move { - // let open_paths_task_result = workspace - // .update(&mut cx, |workspace, cx| { - // workspace.open_paths(vec![abs_path.clone()], visible, cx) - // }) - // .with_context(|| format!("open abs path {abs_path:?} task spawn"))? - // .await; - // anyhow::ensure!( - // open_paths_task_result.len() == 1, - // "open abs path {abs_path:?} task returned incorrect number of results" - // ); - // match open_paths_task_result - // .into_iter() - // .next() - // .expect("ensured single task result") - // { - // Some(open_result) => { - // open_result.with_context(|| format!("open abs path {abs_path:?} task join")) - // } - // None => anyhow::bail!("open abs path {abs_path:?} task returned None"), - // } - // }) - // } + pub fn open_abs_path( + &mut self, + abs_path: PathBuf, + visible: bool, + cx: &mut ViewContext, + ) -> Task>> { + cx.spawn(|workspace, mut cx| async move { + let open_paths_task_result = workspace + .update(&mut cx, |workspace, cx| { + workspace.open_paths(vec![abs_path.clone()], visible, cx) + }) + .with_context(|| format!("open abs path {abs_path:?} task spawn"))? + .await; + anyhow::ensure!( + open_paths_task_result.len() == 1, + "open abs path {abs_path:?} task returned incorrect number of results" + ); + match open_paths_task_result + .into_iter() + .next() + .expect("ensured single task result") + { + Some(open_result) => { + open_result.with_context(|| format!("open abs path {abs_path:?} task join")) + } + None => anyhow::bail!("open abs path {abs_path:?} task returned None"), + } + }) + } - // pub fn split_abs_path( - // &mut self, - // abs_path: PathBuf, - // visible: bool, - // cx: &mut ViewContext, - // ) -> Task>> { - // let project_path_task = - // Workspace::project_path_for_path(self.project.clone(), &abs_path, visible, cx); - // cx.spawn(|this, mut cx| async move { - // let (_, path) = project_path_task.await?; - // this.update(&mut cx, |this, cx| this.split_path(path, cx))? - // .await - // }) - // } + pub fn split_abs_path( + &mut self, + abs_path: PathBuf, + visible: bool, + cx: &mut ViewContext, + ) -> Task>> { + let project_path_task = + Workspace::project_path_for_path(self.project.clone(), &abs_path, visible, cx); + cx.spawn(|this, mut cx| async move { + let (_, path) = project_path_task.await?; + this.update(&mut cx, |this, cx| this.split_path(path, cx))? + .await + }) + } pub fn open_path( &mut self, @@ -1835,37 +1836,37 @@ impl Workspace { }) } - // pub fn split_path( - // &mut self, - // path: impl Into, - // cx: &mut ViewContext, - // ) -> Task, anyhow::Error>> { - // let pane = self.last_active_center_pane.clone().unwrap_or_else(|| { - // self.panes - // .first() - // .expect("There must be an active pane") - // .downgrade() - // }); + pub fn split_path( + &mut self, + path: impl Into, + cx: &mut ViewContext, + ) -> Task, anyhow::Error>> { + let pane = self.last_active_center_pane.clone().unwrap_or_else(|| { + self.panes + .first() + .expect("There must be an active pane") + .downgrade() + }); - // if let Member::Pane(center_pane) = &self.center.root { - // if center_pane.read(cx).items_len() == 0 { - // return self.open_path(path, Some(pane), true, cx); - // } - // } + if let Member::Pane(center_pane) = &self.center.root { + if center_pane.read(cx).items_len() == 0 { + return self.open_path(path, Some(pane), true, cx); + } + } - // let task = self.load_path(path.into(), cx); - // cx.spawn(|this, mut cx| async move { - // let (project_entry_id, build_item) = task.await?; - // this.update(&mut cx, move |this, cx| -> Option<_> { - // let pane = pane.upgrade(cx)?; - // let new_pane = this.split_pane(pane, SplitDirection::Right, cx); - // new_pane.update(cx, |new_pane, cx| { - // Some(new_pane.open_item(project_entry_id, true, cx, build_item)) - // }) - // }) - // .map(|option| option.ok_or_else(|| anyhow!("pane was dropped")))? - // }) - // } + let task = self.load_path(path.into(), cx); + cx.spawn(|this, mut cx| async move { + let (project_entry_id, build_item) = task.await?; + this.update(&mut cx, move |this, cx| -> Option<_> { + let pane = pane.upgrade()?; + let new_pane = this.split_pane(pane, SplitDirection::Right, cx); + new_pane.update(cx, |new_pane, cx| { + Some(new_pane.open_item(project_entry_id, true, cx, build_item)) + }) + }) + .map(|option| option.ok_or_else(|| anyhow!("pane was dropped")))? + }) + } pub(crate) fn load_path( &mut self, @@ -3029,10 +3030,10 @@ impl Workspace { fn force_remove_pane(&mut self, pane: &View, cx: &mut ViewContext) { self.panes.retain(|p| p != pane); - if true { - todo!() - // cx.focus(self.panes.last().unwrap()); - } + self.panes + .last() + .unwrap() + .update(cx, |pane, cx| pane.focus(cx)); if self.last_active_center_pane == Some(pane.downgrade()) { self.last_active_center_pane = None; } @@ -3401,10 +3402,6 @@ impl Workspace { // }); } - // todo!() - // #[cfg(any(test, feature = "test-support"))] - // pub fn test_new(project: ModelHandle, cx: &mut ViewContext) -> Self { - // use node_runtime::FakeNodeRuntime; #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: Model, cx: &mut ViewContext) -> Self { use node_runtime::FakeNodeRuntime; @@ -3423,7 +3420,9 @@ impl Workspace { initialize_workspace: |_, _, _, _| Task::ready(Ok(())), node_runtime: FakeNodeRuntime::new(), }); - Self::new(0, project, app_state, cx) + let workspace = Self::new(0, project, app_state, cx); + workspace.active_pane.update(cx, |pane, cx| pane.focus(cx)); + workspace } // fn render_dock(&self, position: DockPosition, cx: &WindowContext) -> Option> { @@ -3476,6 +3475,10 @@ impl Workspace { div } + pub fn current_modal(&mut self, cx: &ViewContext) -> Option> { + self.modal_layer.read(cx).current_modal() + } + pub fn toggle_modal(&mut self, cx: &mut ViewContext, build: B) where B: FnOnce(&mut ViewContext) -> V, @@ -3699,6 +3702,7 @@ impl Render for Workspace { fn render(&mut self, cx: &mut ViewContext) -> Self::Element { let mut context = KeyContext::default(); context.add("Workspace"); + let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); self.add_workspace_actions_listeners(div()) .key_context(context) @@ -3706,7 +3710,7 @@ impl Render for Workspace { .size_full() .flex() .flex_col() - .font("Zed Sans") + .font(ui_font) .gap_0() .justify_start() .items_start() @@ -3732,7 +3736,15 @@ impl Render for Workspace { .flex_row() .flex_1() .h_full() - .child(div().flex().flex_1().child(self.left_dock.clone())) + // Left Dock + .child( + div() + .flex() + .flex_none() + .overflow_hidden() + .child(self.left_dock.clone()), + ) + // Panes .child( div() .flex() @@ -3749,7 +3761,14 @@ impl Render for Workspace { )) .child(div().flex().flex_1().child(self.bottom_dock.clone())), ) - .child(div().flex().flex_1().child(self.right_dock.clone())), + // Right Dock + .child( + div() + .flex() + .flex_none() + .overflow_hidden() + .child(self.right_dock.clone()), + ), ), ) .child(self.status_bar.clone()) diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index a21d113cad..dedb12c08c 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -36,7 +36,7 @@ copilot = { package = "copilot2", path = "../copilot2" } db = { package = "db2", path = "../db2" } editor = { package="editor2", path = "../editor2" } # feedback = { path = "../feedback" } -# file_finder = { path = "../file_finder" } +file_finder = { package="file_finder2", path = "../file_finder2" } # search = { path = "../search" } fs = { package = "fs2", path = "../fs2" } fsevent = { path = "../fsevent" } diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index 20fc18e6ed..2a3d4d1195 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -190,7 +190,7 @@ fn main() { // recent_projects::init(cx); go_to_line::init(cx); - // file_finder::init(cx); + file_finder::init(cx); // outline::init(cx); // project_symbols::init(cx); project_panel::init(Assets, cx); diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index 73faeaaaf4..2f7a38b041 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -502,7 +502,6 @@ fn quit(_: &mut Workspace, _: &Quit, cx: &mut gpui::ViewContext) { cx.update(|_, cx| { cx.quit(); })?; - anyhow::Ok(()) }) .detach_and_log_err(cx);