diff --git a/gpui/src/app.rs b/gpui/src/app.rs index ebe6c89a8ace2a965138658a8faaccab66b2f1ef..5536b78bf583a5483ae9052587ec88e179ed9960 100644 --- a/gpui/src/app.rs +++ b/gpui/src/app.rs @@ -312,6 +312,7 @@ impl App { let mut state = self.0.borrow_mut(); state.pending_flushes += 1; let result = callback(&mut *state); + state.pending_notifications.clear(); state.flush_effects(); result } @@ -668,6 +669,7 @@ pub struct MutableAppContext { debug_elements_callbacks: HashMap crate::json::Value>>, foreground: Rc, pending_effects: VecDeque, + pending_notifications: HashSet, pending_flushes: usize, flushing_effects: bool, next_cursor_style_handle_id: Arc, @@ -708,6 +710,7 @@ impl MutableAppContext { debug_elements_callbacks: HashMap::new(), foreground, pending_effects: VecDeque::new(), + pending_notifications: HashSet::new(), pending_flushes: 0, flushing_effects: false, next_cursor_style_handle_id: Default::default(), @@ -1015,10 +1018,18 @@ impl MutableAppContext { observations: Some(Arc::downgrade(&self.observations)), } } + pub(crate) fn notify_model(&mut self, model_id: usize) { + if self.pending_notifications.insert(model_id) { + self.pending_effects + .push_back(Effect::ModelNotification { model_id }); + } + } pub(crate) fn notify_view(&mut self, window_id: usize, view_id: usize) { - self.pending_effects - .push_back(Effect::ViewNotification { window_id, view_id }); + if self.pending_notifications.insert(view_id) { + self.pending_effects + .push_back(Effect::ViewNotification { window_id, view_id }); + } } pub fn dispatch_action( @@ -1400,6 +1411,7 @@ impl MutableAppContext { refreshing = true; } } + self.pending_notifications.clear(); self.remove_dropped_entities(); } else { self.remove_dropped_entities(); @@ -1411,6 +1423,7 @@ impl MutableAppContext { if self.pending_effects.is_empty() { self.flushing_effects = false; + self.pending_notifications.clear(); break; } else { refreshing = false; @@ -1983,11 +1996,7 @@ impl<'a, T: Entity> ModelContext<'a, T> { } pub fn notify(&mut self) { - self.app - .pending_effects - .push_back(Effect::ModelNotification { - model_id: self.model_id, - }); + self.app.notify_model(self.model_id); } pub fn subscribe( @@ -2891,6 +2900,11 @@ impl AnyViewHandle { TypeId::of::() == self.view_type } + pub fn is_focused(&self, cx: &AppContext) -> bool { + cx.focused_view_id(self.window_id) + .map_or(false, |focused_id| focused_id == self.view_id) + } + pub fn downcast(self) -> Option> { if self.is::() { let result = Some(ViewHandle { diff --git a/gpui/src/elements/container.rs b/gpui/src/elements/container.rs index dd58cf07398ed001f2b05e28c9a7398a4a581c4a..026542989e45e25cdaad0acc8a01086cdbf966b2 100644 --- a/gpui/src/elements/container.rs +++ b/gpui/src/elements/container.rs @@ -84,6 +84,11 @@ impl Container { self } + pub fn with_padding_left(mut self, padding: f32) -> Self { + self.style.padding.left = padding; + self + } + pub fn with_padding_right(mut self, padding: f32) -> Self { self.style.padding.right = padding; self @@ -162,7 +167,10 @@ impl Element for Container { constraint: SizeConstraint, cx: &mut LayoutContext, ) -> (Vector2F, Self::LayoutState) { - let size_buffer = self.margin_size() + self.padding_size() + self.border_size(); + let mut size_buffer = self.margin_size() + self.padding_size(); + if !self.style.border.overlay { + size_buffer += self.border_size(); + } let child_constraint = SizeConstraint { min: (constraint.min - size_buffer).max(Vector2F::zero()), max: (constraint.max - size_buffer).max(Vector2F::zero()), @@ -191,20 +199,43 @@ impl Element for Container { color: shadow.color, }); } - cx.scene.push_quad(Quad { - bounds: quad_bounds, - background: self.style.background_color, - border: self.style.border, - corner_radius: self.style.corner_radius, - }); - let child_origin = quad_bounds.origin() - + vec2f(self.style.padding.left, self.style.padding.top) - + vec2f( - self.style.border.left_width(), - self.style.border.top_width(), - ); - self.child.paint(child_origin, visible_bounds, cx); + let child_origin = + quad_bounds.origin() + vec2f(self.style.padding.left, self.style.padding.top); + + if self.style.border.overlay { + cx.scene.push_quad(Quad { + bounds: quad_bounds, + background: self.style.background_color, + border: Default::default(), + corner_radius: self.style.corner_radius, + }); + + self.child.paint(child_origin, visible_bounds, cx); + + cx.scene.push_layer(None); + cx.scene.push_quad(Quad { + bounds: quad_bounds, + background: Default::default(), + border: self.style.border, + corner_radius: self.style.corner_radius, + }); + cx.scene.pop_layer(); + } else { + cx.scene.push_quad(Quad { + bounds: quad_bounds, + background: self.style.background_color, + border: self.style.border, + corner_radius: self.style.corner_radius, + }); + + let child_origin = child_origin + + vec2f( + self.style.border.left_width(), + self.style.border.top_width(), + ); + self.child.paint(child_origin, visible_bounds, cx); + } } fn dispatch_event( diff --git a/gpui/src/elements/label.rs b/gpui/src/elements/label.rs index c1e048eb93e0c945db450f2112e7b20a1d735cda..4ae1504c1fb0a9dea6c9afbd2b6dc1f1a44711ff 100644 --- a/gpui/src/elements/label.rs +++ b/gpui/src/elements/label.rs @@ -135,7 +135,10 @@ impl Element for Label { ); let size = vec2f( - line.width().max(constraint.min.x()).min(constraint.max.x()), + line.width() + .ceil() + .max(constraint.min.x()) + .min(constraint.max.x()), cx.font_cache .line_height(self.style.text.font_id, self.style.text.font_size), ); diff --git a/gpui/src/elements/uniform_list.rs b/gpui/src/elements/uniform_list.rs index c82d8aa3d6fc740c3179f50b367348b32816cc76..f499801e6384380eed8875068c498a3ffa930ab3 100644 --- a/gpui/src/elements/uniform_list.rs +++ b/gpui/src/elements/uniform_list.rs @@ -5,7 +5,7 @@ use crate::{ vector::{vec2f, Vector2F}, }, json::{self, json}, - ElementBox, MutableAppContext, + ElementBox, }; use json::ToJson; use parking_lot::Mutex; @@ -38,25 +38,39 @@ pub struct LayoutState { pub struct UniformList where - F: Fn(Range, &mut Vec, &mut MutableAppContext), + F: Fn(Range, &mut Vec, &mut LayoutContext), { state: UniformListState, item_count: usize, append_items: F, + padding_top: f32, + padding_bottom: f32, } impl UniformList where - F: Fn(Range, &mut Vec, &mut MutableAppContext), + F: Fn(Range, &mut Vec, &mut LayoutContext), { pub fn new(state: UniformListState, item_count: usize, append_items: F) -> Self { Self { state, item_count, append_items, + padding_top: 0., + padding_bottom: 0., } } + pub fn with_padding_top(mut self, padding: f32) -> Self { + self.padding_top = padding; + self + } + + pub fn with_padding_bottom(mut self, padding: f32) -> Self { + self.padding_bottom = padding; + self + } + fn scroll( &self, _: Vector2F, @@ -84,7 +98,7 @@ where } if let Some(item_ix) = state.scroll_to.take() { - let item_top = item_ix as f32 * item_height; + let item_top = self.padding_top + item_ix as f32 * item_height; let item_bottom = item_top + item_height; if item_top < state.scroll_top { @@ -102,7 +116,7 @@ where impl Element for UniformList where - F: Fn(Range, &mut Vec, &mut MutableAppContext), + F: Fn(Range, &mut Vec, &mut LayoutContext), { type LayoutState = LayoutState; type PaintState = (); @@ -124,7 +138,7 @@ where let mut scroll_max = 0.; let mut items = Vec::new(); - (self.append_items)(0..1, &mut items, cx.app); + (self.append_items)(0..1, &mut items, cx); if let Some(first_item) = items.first_mut() { let mut item_size = first_item.layout(item_constraint, cx); item_size.set_x(size.x()); @@ -137,16 +151,21 @@ where size.set_y(size.y().min(scroll_height).max(constraint.min.y())); } - scroll_max = item_height * self.item_count as f32 - size.y(); + let scroll_height = + item_height * self.item_count as f32 + self.padding_top + self.padding_bottom; + scroll_max = (scroll_height - size.y()).max(0.); self.autoscroll(scroll_max, size.y(), item_height); items.clear(); - let start = cmp::min((self.scroll_top() / item_height) as usize, self.item_count); + let start = cmp::min( + ((self.scroll_top() - self.padding_top) / item_height) as usize, + self.item_count, + ); let end = cmp::min( self.item_count, start + (size.y() / item_height).ceil() as usize + 1, ); - (self.append_items)(start..end, &mut items, cx.app); + (self.append_items)(start..end, &mut items, cx); for item in &mut items { item.layout(item_constraint, cx); } @@ -173,8 +192,11 @@ where ) -> Self::PaintState { cx.scene.push_layer(Some(bounds)); - let mut item_origin = - bounds.origin() - vec2f(0.0, self.state.scroll_top() % layout.item_height); + let mut item_origin = bounds.origin() + - vec2f( + 0., + (self.state.scroll_top() - self.padding_top) % layout.item_height, + ); for item in &mut layout.items { item.paint(item_origin, visible_bounds, cx); diff --git a/gpui/src/presenter.rs b/gpui/src/presenter.rs index 354f0a0f821af81040581a7afedb2eb4652acbc2..8bcdd08aadcd779b0fbb1f4eb894bd3d8f267c21 100644 --- a/gpui/src/presenter.rs +++ b/gpui/src/presenter.rs @@ -7,7 +7,7 @@ use crate::{ platform::Event, text_layout::TextLayoutCache, Action, AnyAction, AssetCache, ElementBox, Entity, FontSystem, ModelHandle, ReadModel, - ReadView, Scene, View, ViewHandle, + ReadView, Scene, UpdateView, View, ViewHandle, }; use pathfinder_geometry::vector::{vec2f, Vector2F}; use serde_json::json; @@ -264,6 +264,16 @@ impl<'a> ReadView for LayoutContext<'a> { } } +impl<'a> UpdateView for LayoutContext<'a> { + fn update_view(&mut self, handle: &ViewHandle, update: F) -> S + where + T: View, + F: FnOnce(&mut T, &mut crate::ViewContext) -> S, + { + self.app.update_view(handle, update) + } +} + impl<'a> ReadModel for LayoutContext<'a> { fn read_model(&self, handle: &ModelHandle) -> &T { self.app.read_model(handle) diff --git a/gpui/src/scene.rs b/gpui/src/scene.rs index 1b9c863647205a09fcafde18c2abdda1aa36e922..b833ffe627d07be3ef255eef9072f4a704d56825 100644 --- a/gpui/src/scene.rs +++ b/gpui/src/scene.rs @@ -69,6 +69,7 @@ pub struct Icon { pub struct Border { pub width: f32, pub color: Color, + pub overlay: bool, pub top: bool, pub right: bool, pub bottom: bool, @@ -85,6 +86,8 @@ impl<'de> Deserialize<'de> for Border { pub width: f32, pub color: Color, #[serde(default)] + pub overlay: bool, + #[serde(default)] pub top: bool, #[serde(default)] pub right: bool, @@ -98,6 +101,7 @@ impl<'de> Deserialize<'de> for Border { let mut border = Border { width: data.width, color: data.color, + overlay: data.overlay, top: data.top, bottom: data.bottom, left: data.left, @@ -329,6 +333,7 @@ impl Border { Self { width, color, + overlay: false, top: false, left: false, bottom: false, @@ -340,6 +345,7 @@ impl Border { Self { width, color, + overlay: false, top: true, left: true, bottom: true, diff --git a/gpui/src/views/select.rs b/gpui/src/views/select.rs index e257455a7afc3ede7f02fc3489d3855eb5444438..a76f156ebfe0fd31ec842b351680063d6a2d1e50 100644 --- a/gpui/src/views/select.rs +++ b/gpui/src/views/select.rs @@ -126,7 +126,7 @@ impl View for Select { UniformList::new( self.list_state.clone(), self.item_count, - move |mut range, items, mut cx| { + move |mut range, items, cx| { let handle = handle.upgrade(cx).unwrap(); let this = handle.read(cx); let selected_item_ix = this.selected_item_ix; @@ -134,9 +134,9 @@ impl View for Select { items.extend(range.map(|ix| { MouseEventHandler::new::( (handle.id(), ix), - &mut cx, + cx, |mouse_state, cx| { - (handle.read(*cx).render_item)( + (handle.read(cx).render_item)( ix, if ix == selected_item_ix { ItemType::Selected diff --git a/server/src/rpc.rs b/server/src/rpc.rs index e3b91c37f1c8466b5af61dd34b0e5367c4f5a388..33e22786d702b5fdce9e32beaedd80d951ec74c7 100644 --- a/server/src/rpc.rs +++ b/server/src/rpc.rs @@ -1157,13 +1157,13 @@ mod tests { ); }); workspace_b - .condition(&cx_b, |workspace, _| workspace.worktrees().len() == 1) + .condition(&cx_b, |workspace, cx| workspace.worktrees(cx).len() == 1) .await; let local_worktree_id_b = workspace_b.read_with(&cx_b, |workspace, cx| { let active_pane = workspace.active_pane().read(cx); assert!(active_pane.active_item().is_none()); - workspace.worktrees().iter().next().unwrap().id() + workspace.worktrees(cx).first().unwrap().id() }); workspace_b .update(&mut cx_b, |worktree, cx| { @@ -1180,7 +1180,7 @@ mod tests { tree.as_local_mut().unwrap().unshare(cx); }); workspace_b - .condition(&cx_b, |workspace, _| workspace.worktrees().len() == 0) + .condition(&cx_b, |workspace, cx| workspace.worktrees(cx).len() == 0) .await; workspace_b.read_with(&cx_b, |workspace, cx| { let active_pane = workspace.active_pane().read(cx); diff --git a/zed/assets/icons/disclosure-closed.svg b/zed/assets/icons/disclosure-closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..87a898787e9476a410d4c32cf6fdf659e3189fa6 --- /dev/null +++ b/zed/assets/icons/disclosure-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/zed/assets/icons/disclosure-open.svg b/zed/assets/icons/disclosure-open.svg new file mode 100644 index 0000000000000000000000000000000000000000..3a76a74d3116118a0e84be54a743325d2b9796bf --- /dev/null +++ b/zed/assets/icons/disclosure-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/zed/assets/themes/_base.toml b/zed/assets/themes/_base.toml index 3e326ab62c6168673be6888778b3cd11317829e5..e03b3eb1340d5f39f8d2c9c31eb003838d008f98 100644 --- a/zed/assets/themes/_base.toml +++ b/zed/assets/themes/_base.toml @@ -18,6 +18,7 @@ padding = { right = 4 } width = 16 [workspace.tab] +height = 34 text = "$text.2" padding = { left = 12, right = 12 } icon_width = 8 @@ -26,10 +27,11 @@ icon_close = "$text.2.color" icon_close_active = "$text.0.color" icon_dirty = "$status.info" icon_conflict = "$status.warn" -border = { left = true, bottom = true, width = 1, color = "$border.0" } +border = { left = true, bottom = true, width = 1, color = "$border.0", overlay = true } [workspace.active_tab] extends = "$workspace.tab" +border.bottom = false background = "$surface.1" text = "$text.0" @@ -41,13 +43,14 @@ border = { right = true, width = 1, color = "$border.0" } padding = { left = 1 } background = "$border.0" -[workspace.sidebar.icon] -color = "$text.2.color" -height = 18 +[workspace.sidebar.item] +icon_color = "$text.2.color" +icon_size = 18 +height = "$workspace.tab.height" -[workspace.sidebar.active_icon] -extends = "$workspace.sidebar.icon" -color = "$text.0.color" +[workspace.sidebar.active_item] +extends = "$workspace.sidebar.item" +icon_color = "$text.0.color" [workspace.left_sidebar] extends = "$workspace.sidebar" @@ -58,7 +61,7 @@ extends = "$workspace.sidebar" border = { width = 1, color = "$border.0", left = true } [panel] -padding = 12 +padding = { top = 12, left = 12, bottom = 12, right = 12 } [chat_panel] extends = "$panel" @@ -159,6 +162,29 @@ extends = "$people_panel.shared_worktree" background = "$state.hover" corner_radius = 6 +[project_panel] +extends = "$panel" +padding.top = 6 # ($workspace.tab.height - $project_panel.entry.height) / 2 + +[project_panel.entry] +text = "$text.1" +height = 22 +icon_color = "$text.3.color" +icon_size = 8 +icon_spacing = 8 + +[project_panel.hovered_entry] +extends = "$project_panel.entry" +background = "$state.hover" + +[project_panel.selected_entry] +extends = "$project_panel.entry" +text = { extends = "$text.0" } + +[project_panel.hovered_selected_entry] +extends = "$project_panel.hovered_entry" +text = { extends = "$text.0" } + [selector] background = "$surface.0" padding = 8 diff --git a/zed/src/file_finder.rs b/zed/src/file_finder.rs index 2749bbe59d7e3dd9d4132b1d3654a01fb91fe50a..caf560c16a54cd6ceadf2f4e035d25d3b26566a2 100644 --- a/zed/src/file_finder.rs +++ b/zed/src/file_finder.rs @@ -385,7 +385,7 @@ impl FileFinder { .workspace .upgrade(&cx)? .read(cx) - .worktrees() + .worktrees(cx) .iter() .map(|tree| tree.read(cx).snapshot()) .collect::>(); diff --git a/zed/src/lib.rs b/zed/src/lib.rs index b7fa3f83d06b00f730945cccd40f2f7a7869a077..12a38938b85c38ac62b44d64ff9ce5151ef0ff07 100644 --- a/zed/src/lib.rs +++ b/zed/src/lib.rs @@ -9,7 +9,8 @@ pub mod http; pub mod language; pub mod menus; pub mod people_panel; -pub mod project_browser; +pub mod project; +pub mod project_panel; pub mod rpc; pub mod settings; #[cfg(any(test, feature = "test-support"))] diff --git a/zed/src/main.rs b/zed/src/main.rs index c88b1465d14742e69341ee7a68513555fa937d08..6070f7eab683421a850c8251afcd3756ed145670 100644 --- a/zed/src/main.rs +++ b/zed/src/main.rs @@ -13,7 +13,7 @@ use zed::{ channel::ChannelList, chat_panel, editor, file_finder, fs::RealFs, - http, language, menus, rpc, settings, theme_selector, + http, language, menus, project_panel, rpc, settings, theme_selector, user::UserStore, workspace::{self, OpenNew, OpenParams, OpenPaths}, AppState, @@ -55,6 +55,7 @@ fn main() { editor::init(cx); file_finder::init(cx); chat_panel::init(cx); + project_panel::init(cx); theme_selector::init(&app_state, cx); cx.set_menus(menus::menus(&app_state.clone())); diff --git a/zed/src/project.rs b/zed/src/project.rs new file mode 100644 index 0000000000000000000000000000000000000000..3f7ef27a455a3527332747b2766ceefe2082dfce --- /dev/null +++ b/zed/src/project.rs @@ -0,0 +1,189 @@ +use crate::{ + fs::Fs, + language::LanguageRegistry, + rpc::Client, + util::TryFutureExt as _, + worktree::{self, Worktree}, + AppState, +}; +use anyhow::Result; +use gpui::{Entity, ModelContext, ModelHandle, Task}; +use std::{path::Path, sync::Arc}; + +pub struct Project { + worktrees: Vec>, + active_entry: Option<(usize, usize)>, + languages: Arc, + rpc: Arc, + fs: Arc, +} + +pub enum Event { + ActiveEntryChanged(Option<(usize, usize)>), + WorktreeRemoved(usize), +} + +impl Project { + pub fn new(app_state: &AppState) -> Self { + Self { + worktrees: Default::default(), + active_entry: None, + languages: app_state.languages.clone(), + rpc: app_state.rpc.clone(), + fs: app_state.fs.clone(), + } + } + + pub fn worktrees(&self) -> &[ModelHandle] { + &self.worktrees + } + + pub fn worktree_for_id(&self, id: usize) -> Option> { + self.worktrees + .iter() + .find(|worktree| worktree.id() == id) + .cloned() + } + + pub fn add_local_worktree( + &mut self, + path: &Path, + cx: &mut ModelContext, + ) -> Task>> { + let fs = self.fs.clone(); + let rpc = self.rpc.clone(); + let languages = self.languages.clone(); + let path = Arc::from(path); + cx.spawn(|this, mut cx| async move { + let worktree = Worktree::open_local(rpc, path, fs, languages, &mut cx).await?; + this.update(&mut cx, |this, cx| { + this.add_worktree(worktree.clone(), cx); + }); + Ok(worktree) + }) + } + + pub fn add_remote_worktree( + &mut self, + remote_id: u64, + cx: &mut ModelContext, + ) -> Task>> { + let rpc = self.rpc.clone(); + let languages = self.languages.clone(); + cx.spawn(|this, mut cx| async move { + rpc.authenticate_and_connect(&cx).await?; + let worktree = + Worktree::open_remote(rpc.clone(), remote_id, languages, &mut cx).await?; + this.update(&mut cx, |this, cx| { + cx.subscribe(&worktree, move |this, _, event, cx| match event { + worktree::Event::Closed => { + this.close_remote_worktree(remote_id, cx); + cx.notify(); + } + }) + .detach(); + this.add_worktree(worktree.clone(), cx); + }); + Ok(worktree) + }) + } + + fn add_worktree(&mut self, worktree: ModelHandle, cx: &mut ModelContext) { + cx.observe(&worktree, |_, _, cx| cx.notify()).detach(); + self.worktrees.push(worktree); + cx.notify(); + } + + pub fn set_active_entry( + &mut self, + entry: Option<(usize, Arc)>, + cx: &mut ModelContext, + ) { + let new_active_entry = entry.and_then(|(worktree_id, path)| { + let worktree = self.worktree_for_id(worktree_id)?; + let entry = worktree.read(cx).entry_for_path(path)?; + Some((worktree_id, entry.id)) + }); + if new_active_entry != self.active_entry { + self.active_entry = new_active_entry; + cx.emit(Event::ActiveEntryChanged(new_active_entry)); + } + } + + pub fn active_entry(&self) -> Option<(usize, usize)> { + self.active_entry + } + + pub fn share_worktree(&self, remote_id: u64, cx: &mut ModelContext) { + let rpc = self.rpc.clone(); + cx.spawn(|this, mut cx| { + async move { + rpc.authenticate_and_connect(&cx).await?; + + let task = this.update(&mut cx, |this, cx| { + for worktree in &this.worktrees { + let task = worktree.update(cx, |worktree, cx| { + worktree.as_local_mut().and_then(|worktree| { + if worktree.remote_id() == Some(remote_id) { + Some(worktree.share(cx)) + } else { + None + } + }) + }); + if task.is_some() { + return task; + } + } + None + }); + + if let Some(task) = task { + task.await?; + } + + Ok(()) + } + .log_err() + }) + .detach(); + } + + pub fn unshare_worktree(&mut self, remote_id: u64, cx: &mut ModelContext) { + for worktree in &self.worktrees { + if worktree.update(cx, |worktree, cx| { + if let Some(worktree) = worktree.as_local_mut() { + if worktree.remote_id() == Some(remote_id) { + worktree.unshare(cx); + return true; + } + } + false + }) { + break; + } + } + } + + pub fn close_remote_worktree(&mut self, id: u64, cx: &mut ModelContext) { + self.worktrees.retain(|worktree| { + let keep = worktree.update(cx, |worktree, cx| { + if let Some(worktree) = worktree.as_remote_mut() { + if worktree.remote_id() == id { + worktree.close_all_buffers(cx); + return false; + } + } + true + }); + if !keep { + cx.emit(Event::WorktreeRemoved(worktree.id())); + } + keep + }); + } +} + +impl Entity for Project { + type Event = Event; +} diff --git a/zed/src/project_browser.rs b/zed/src/project_browser.rs deleted file mode 100644 index 796441c7041e737ec9a78dff95f49dc3cc09595f..0000000000000000000000000000000000000000 --- a/zed/src/project_browser.rs +++ /dev/null @@ -1,19 +0,0 @@ -use gpui::{elements::Empty, Element, Entity, View}; - -pub struct ProjectBrowser; - -pub enum Event {} - -impl Entity for ProjectBrowser { - type Event = Event; -} - -impl View for ProjectBrowser { - fn ui_name() -> &'static str { - "ProjectBrowser" - } - - fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox { - Empty::new().boxed() - } -} diff --git a/zed/src/project_panel.rs b/zed/src/project_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..3fdb0c442ae083f5585b0ceb63c24da4f6c34926 --- /dev/null +++ b/zed/src/project_panel.rs @@ -0,0 +1,861 @@ +use crate::{ + project::{self, Project}, + theme, + workspace::Workspace, + worktree::{self, Worktree}, + Settings, +}; +use gpui::{ + action, + elements::{ + Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, Svg, + UniformList, UniformListState, + }, + keymap::{ + self, + menu::{SelectNext, SelectPrev}, + Binding, + }, + platform::CursorStyle, + AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, ReadModel, View, + ViewContext, ViewHandle, WeakViewHandle, +}; +use postage::watch; +use std::{ + collections::{hash_map, HashMap}, + ffi::OsStr, + ops::Range, +}; + +pub struct ProjectPanel { + project: ModelHandle, + list: UniformListState, + visible_entries: Vec>, + expanded_dir_ids: HashMap>, + selection: Option, + settings: watch::Receiver, + handle: WeakViewHandle, +} + +#[derive(Copy, Clone)] +struct Selection { + worktree_id: usize, + entry_id: usize, + index: usize, +} + +#[derive(Debug, PartialEq, Eq)] +struct EntryDetails { + filename: String, + depth: usize, + is_dir: bool, + is_expanded: bool, + is_selected: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProjectEntry { + pub worktree_id: usize, + pub entry_id: usize, +} + +action!(ExpandSelectedEntry); +action!(CollapseSelectedEntry); +action!(ToggleExpanded, ProjectEntry); +action!(Open, ProjectEntry); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(ProjectPanel::expand_selected_entry); + cx.add_action(ProjectPanel::collapse_selected_entry); + cx.add_action(ProjectPanel::toggle_expanded); + cx.add_action(ProjectPanel::select_prev); + cx.add_action(ProjectPanel::select_next); + cx.add_action(ProjectPanel::open_entry); + cx.add_bindings([ + Binding::new("right", ExpandSelectedEntry, Some("ProjectPanel")), + Binding::new("left", CollapseSelectedEntry, Some("ProjectPanel")), + ]); +} + +pub enum Event { + OpenedEntry { worktree_id: usize, entry_id: usize }, +} + +impl ProjectPanel { + pub fn new( + project: ModelHandle, + settings: watch::Receiver, + cx: &mut ViewContext, + ) -> ViewHandle { + let project_panel = cx.add_view(|cx: &mut ViewContext| { + cx.observe(&project, |this, _, cx| { + this.update_visible_entries(None, cx); + cx.notify(); + }) + .detach(); + cx.subscribe(&project, |this, _, event, cx| match event { + project::Event::ActiveEntryChanged(Some((worktree_id, entry_id))) => { + this.expand_entry(*worktree_id, *entry_id, cx); + this.update_visible_entries(Some((*worktree_id, *entry_id)), cx); + this.autoscroll(); + cx.notify(); + } + project::Event::WorktreeRemoved(id) => { + this.expanded_dir_ids.remove(id); + this.update_visible_entries(None, cx); + cx.notify(); + } + _ => {} + }) + .detach(); + + let mut this = Self { + project: project.clone(), + settings, + list: Default::default(), + visible_entries: Default::default(), + expanded_dir_ids: Default::default(), + selection: None, + handle: cx.handle().downgrade(), + }; + this.update_visible_entries(None, cx); + this + }); + cx.subscribe(&project_panel, move |workspace, _, event, cx| match event { + Event::OpenedEntry { + worktree_id, + entry_id, + } => { + if let Some(worktree) = project.read(cx).worktree_for_id(*worktree_id) { + if let Some(entry) = worktree.read(cx).entry_for_id(*entry_id) { + workspace + .open_entry((worktree.id(), entry.path.clone()), cx) + .map(|t| t.detach()); + } + } + } + }) + .detach(); + + project_panel + } + + fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext) { + if let Some((worktree, entry)) = self.selected_entry(cx) { + let expanded_dir_ids = + if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) { + expanded_dir_ids + } else { + return; + }; + + if entry.is_dir() { + match expanded_dir_ids.binary_search(&entry.id) { + Ok(_) => self.select_next(&SelectNext, cx), + Err(ix) => { + expanded_dir_ids.insert(ix, entry.id); + self.update_visible_entries(None, cx); + cx.notify(); + } + } + } else { + let event = Event::OpenedEntry { + worktree_id: worktree.id(), + entry_id: entry.id, + }; + cx.emit(event); + } + } + } + + fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext) { + if let Some((worktree, mut entry)) = self.selected_entry(cx) { + let expanded_dir_ids = + if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) { + expanded_dir_ids + } else { + return; + }; + + loop { + match expanded_dir_ids.binary_search(&entry.id) { + Ok(ix) => { + expanded_dir_ids.remove(ix); + self.update_visible_entries(Some((worktree.id(), entry.id)), cx); + cx.notify(); + break; + } + Err(_) => { + if let Some(parent_entry) = + entry.path.parent().and_then(|p| worktree.entry_for_path(p)) + { + entry = parent_entry; + } else { + break; + } + } + } + } + } + } + + fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext) { + let ProjectEntry { + worktree_id, + entry_id, + } = action.0; + + if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) { + match expanded_dir_ids.binary_search(&entry_id) { + Ok(ix) => { + expanded_dir_ids.remove(ix); + } + Err(ix) => { + expanded_dir_ids.insert(ix, entry_id); + } + } + self.update_visible_entries(Some((worktree_id, entry_id)), cx); + cx.focus_self(); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(selection) = self.selection { + let prev_ix = selection.index.saturating_sub(1); + let (worktree, entry) = self.visible_entry_for_index(prev_ix, cx).unwrap(); + self.selection = Some(Selection { + worktree_id: worktree.id(), + entry_id: entry.id, + index: prev_ix, + }); + self.autoscroll(); + cx.notify(); + } else { + self.select_first(cx); + } + } + + fn open_entry(&mut self, action: &Open, cx: &mut ViewContext) { + cx.emit(Event::OpenedEntry { + worktree_id: action.0.worktree_id, + entry_id: action.0.entry_id, + }); + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(selection) = self.selection { + let next_ix = selection.index + 1; + if let Some((worktree, entry)) = self.visible_entry_for_index(next_ix, cx) { + self.selection = Some(Selection { + worktree_id: worktree.id(), + entry_id: entry.id, + index: next_ix, + }); + self.autoscroll(); + cx.notify(); + } + } else { + self.select_first(cx); + } + } + + fn select_first(&mut self, cx: &mut ViewContext) { + if let Some(worktree) = self.project.read(cx).worktrees().first() { + let worktree_id = worktree.id(); + let worktree = worktree.read(cx); + if let Some(root_entry) = worktree.root_entry() { + self.selection = Some(Selection { + worktree_id, + entry_id: root_entry.id, + index: 0, + }); + self.autoscroll(); + cx.notify(); + } + } + } + + fn autoscroll(&mut self) { + if let Some(selection) = self.selection { + self.list.scroll_to(selection.index); + } + } + + fn visible_entry_for_index<'a>( + &self, + target_ix: usize, + cx: &'a AppContext, + ) -> Option<(&'a Worktree, &'a worktree::Entry)> { + let project = self.project.read(cx); + let mut offset = None; + let mut ix = 0; + for (worktree_ix, visible_entries) in self.visible_entries.iter().enumerate() { + if target_ix < ix + visible_entries.len() { + let worktree = project.worktrees()[worktree_ix].read(cx); + offset = Some((worktree, visible_entries[target_ix - ix])); + break; + } else { + ix += visible_entries.len(); + } + } + + offset.and_then(|(worktree, offset)| { + let mut entries = worktree.entries(false); + entries.advance_to_offset(offset); + Some((worktree, entries.entry()?)) + }) + } + + fn selected_entry<'a>( + &self, + cx: &'a AppContext, + ) -> Option<(&'a Worktree, &'a worktree::Entry)> { + let selection = self.selection?; + let project = self.project.read(cx); + let worktree = project.worktree_for_id(selection.worktree_id)?.read(cx); + Some((worktree, worktree.entry_for_id(selection.entry_id)?)) + } + + fn update_visible_entries( + &mut self, + new_selected_entry: Option<(usize, usize)>, + cx: &mut ViewContext, + ) { + let worktrees = self.project.read(cx).worktrees(); + self.visible_entries.clear(); + + let mut entry_ix = 0; + for worktree in worktrees { + let snapshot = worktree.read(cx).snapshot(); + let worktree_id = worktree.id(); + + let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) { + hash_map::Entry::Occupied(e) => e.into_mut(), + hash_map::Entry::Vacant(e) => { + // The first time a worktree's root entry becomes available, + // mark that root entry as expanded. + if let Some(entry) = snapshot.root_entry() { + e.insert(vec![entry.id]).as_slice() + } else { + &[] + } + } + }; + + let mut visible_worktree_entries = Vec::new(); + let mut entry_iter = snapshot.entries(false); + while let Some(item) = entry_iter.entry() { + visible_worktree_entries.push(entry_iter.offset()); + if let Some(new_selected_entry) = new_selected_entry { + if new_selected_entry == (worktree.id(), item.id) { + self.selection = Some(Selection { + worktree_id, + entry_id: item.id, + index: entry_ix, + }); + } + } else if self.selection.map_or(false, |e| { + e.worktree_id == worktree_id && e.entry_id == item.id + }) { + self.selection = Some(Selection { + worktree_id, + entry_id: item.id, + index: entry_ix, + }); + } + + entry_ix += 1; + if expanded_dir_ids.binary_search(&item.id).is_err() { + if entry_iter.advance_to_sibling() { + continue; + } + } + entry_iter.advance(); + } + self.visible_entries.push(visible_worktree_entries); + } + } + + fn expand_entry(&mut self, worktree_id: usize, entry_id: usize, cx: &mut ViewContext) { + let project = self.project.read(cx); + if let Some((worktree, expanded_dir_ids)) = project + .worktree_for_id(worktree_id) + .zip(self.expanded_dir_ids.get_mut(&worktree_id)) + { + let worktree = worktree.read(cx); + + if let Some(mut entry) = worktree.entry_for_id(entry_id) { + loop { + if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) { + expanded_dir_ids.insert(ix, entry.id); + } + + if let Some(parent_entry) = + entry.path.parent().and_then(|p| worktree.entry_for_path(p)) + { + entry = parent_entry; + } else { + break; + } + } + } + } + } + + fn for_each_visible_entry( + &self, + range: Range, + cx: &mut C, + mut callback: impl FnMut(ProjectEntry, EntryDetails, &mut C), + ) { + let project = self.project.read(cx); + let worktrees = project.worktrees().to_vec(); + let mut ix = 0; + for (worktree_ix, visible_worktree_entries) in self.visible_entries.iter().enumerate() { + if ix >= range.end { + return; + } + if ix + visible_worktree_entries.len() <= range.start { + ix += visible_worktree_entries.len(); + continue; + } + + let end_ix = range.end.min(ix + visible_worktree_entries.len()); + let worktree = &worktrees[worktree_ix]; + let expanded_entry_ids = self + .expanded_dir_ids + .get(&worktree.id()) + .map(Vec::as_slice) + .unwrap_or(&[]); + let snapshot = worktree.read(cx).snapshot(); + let root_name = OsStr::new(snapshot.root_name()); + let mut cursor = snapshot.entries(false); + + for ix in visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix] + .iter() + .copied() + { + cursor.advance_to_offset(ix); + if let Some(entry) = cursor.entry() { + let filename = entry.path.file_name().unwrap_or(root_name); + let details = EntryDetails { + filename: filename.to_string_lossy().to_string(), + depth: entry.path.components().count(), + is_dir: entry.is_dir(), + is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(), + is_selected: self.selection.map_or(false, |e| { + e.worktree_id == worktree.id() && e.entry_id == entry.id + }), + }; + let entry = ProjectEntry { + worktree_id: worktree.id(), + entry_id: entry.id, + }; + callback(entry, details, cx); + } + } + ix = end_ix; + } + } + + fn render_entry( + entry: ProjectEntry, + details: EntryDetails, + theme: &theme::ProjectPanel, + cx: &mut ViewContext, + ) -> ElementBox { + let is_dir = details.is_dir; + MouseEventHandler::new::( + (entry.worktree_id, entry.entry_id), + cx, + |state, _| { + let style = match (details.is_selected, state.hovered) { + (false, false) => &theme.entry, + (false, true) => &theme.hovered_entry, + (true, false) => &theme.selected_entry, + (true, true) => &theme.hovered_selected_entry, + }; + Flex::row() + .with_child( + ConstrainedBox::new( + Align::new( + ConstrainedBox::new(if is_dir { + if details.is_expanded { + Svg::new("icons/disclosure-open.svg") + .with_color(style.icon_color) + .boxed() + } else { + Svg::new("icons/disclosure-closed.svg") + .with_color(style.icon_color) + .boxed() + } + } else { + Empty::new().boxed() + }) + .with_max_width(style.icon_size) + .with_max_height(style.icon_size) + .boxed(), + ) + .boxed(), + ) + .with_width(style.icon_size) + .boxed(), + ) + .with_child( + Label::new(details.filename, style.text.clone()) + .contained() + .with_margin_left(style.icon_spacing) + .aligned() + .left() + .boxed(), + ) + .constrained() + .with_height(theme.entry.height) + .contained() + .with_style(style.container) + .with_padding_left(theme.container.padding.left + details.depth as f32 * 20.) + .boxed() + }, + ) + .on_click(move |cx| { + if is_dir { + cx.dispatch_action(ToggleExpanded(entry)) + } else { + cx.dispatch_action(Open(entry)) + } + }) + .with_cursor_style(CursorStyle::PointingHand) + .boxed() + } +} + +impl View for ProjectPanel { + fn ui_name() -> &'static str { + "ProjectPanel" + } + + fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox { + let settings = self.settings.clone(); + let mut container_style = settings.borrow().theme.project_panel.container; + let padding = std::mem::take(&mut container_style.padding); + let handle = self.handle.clone(); + UniformList::new( + self.list.clone(), + self.visible_entries + .iter() + .map(|worktree_entries| worktree_entries.len()) + .sum(), + move |range, items, cx| { + let theme = &settings.borrow().theme.project_panel; + let this = handle.upgrade(cx).unwrap(); + this.update(cx.app, |this, cx| { + this.for_each_visible_entry(range.clone(), cx, |entry, details, cx| { + items.push(Self::render_entry(entry, details, theme, cx)); + }); + }) + }, + ) + .with_padding_top(padding.top) + .with_padding_bottom(padding.bottom) + .contained() + .with_style(container_style) + .boxed() + } + + fn keymap_context(&self, _: &AppContext) -> keymap::Context { + let mut cx = Self::default_keymap_context(); + cx.set.insert("menu".into()); + cx + } +} + +impl Entity for ProjectPanel { + type Event = Event; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::test_app_state; + use gpui::{TestAppContext, ViewHandle}; + use serde_json::json; + use std::{collections::HashSet, path::Path}; + + #[gpui::test] + async fn test_visible_list(mut cx: gpui::TestAppContext) { + let app_state = cx.update(test_app_state); + let settings = app_state.settings.clone(); + let fs = app_state.fs.as_fake(); + + 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 = cx.add_model(|_| Project::new(&app_state)); + let root1 = project + .update(&mut cx, |project, cx| { + project.add_local_worktree("/root1".as_ref(), cx) + }) + .await + .unwrap(); + root1 + .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete()) + .await; + let root2 = project + .update(&mut cx, |project, cx| { + project.add_local_worktree("/root2".as_ref(), cx) + }) + .await + .unwrap(); + root2 + .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete()) + .await; + + let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let panel = workspace.update(&mut cx, |_, cx| ProjectPanel::new(project, settings, cx)); + assert_eq!( + visible_entry_details(&panel, 0..50, &mut cx), + &[ + EntryDetails { + filename: "root1".to_string(), + depth: 0, + is_dir: true, + is_expanded: true, + is_selected: false, + }, + EntryDetails { + filename: ".dockerignore".to_string(), + depth: 1, + is_dir: false, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "a".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "b".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "c".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "root2".to_string(), + depth: 0, + is_dir: true, + is_expanded: true, + is_selected: false + }, + EntryDetails { + filename: "d".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false + }, + EntryDetails { + filename: "e".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false + } + ], + ); + + toggle_expand_dir(&panel, "root1/b", &mut cx); + assert_eq!( + visible_entry_details(&panel, 0..50, &mut cx), + &[ + EntryDetails { + filename: "root1".to_string(), + depth: 0, + is_dir: true, + is_expanded: true, + is_selected: false, + }, + EntryDetails { + filename: ".dockerignore".to_string(), + depth: 1, + is_dir: false, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "a".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "b".to_string(), + depth: 1, + is_dir: true, + is_expanded: true, + is_selected: true, + }, + EntryDetails { + filename: "3".to_string(), + depth: 2, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "4".to_string(), + depth: 2, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "c".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false, + }, + EntryDetails { + filename: "root2".to_string(), + depth: 0, + is_dir: true, + is_expanded: true, + is_selected: false + }, + EntryDetails { + filename: "d".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false + }, + EntryDetails { + filename: "e".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false + } + ] + ); + + assert_eq!( + visible_entry_details(&panel, 5..8, &mut cx), + [ + EntryDetails { + filename: "4".to_string(), + depth: 2, + is_dir: true, + is_expanded: false, + is_selected: false + }, + EntryDetails { + filename: "c".to_string(), + depth: 1, + is_dir: true, + is_expanded: false, + is_selected: false + }, + EntryDetails { + filename: "root2".to_string(), + depth: 0, + is_dir: true, + is_expanded: true, + is_selected: false + } + ] + ); + + fn toggle_expand_dir( + panel: &ViewHandle, + path: impl AsRef, + cx: &mut TestAppContext, + ) { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees() { + 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( + &ToggleExpanded(ProjectEntry { + worktree_id: worktree.id(), + entry_id, + }), + cx, + ); + return; + } + } + panic!("no worktree for path {:?}", path); + }); + } + + fn visible_entry_details( + panel: &ViewHandle, + range: Range, + cx: &mut TestAppContext, + ) -> Vec { + let mut result = Vec::new(); + let mut project_entries = HashSet::new(); + panel.update(cx, |panel, cx| { + panel.for_each_visible_entry(range, cx, |project_entry, details, _| { + assert!( + project_entries.insert(project_entry), + "duplicate project entry {:?} {:?}", + project_entry, + details + ); + result.push(details); + }); + }); + + result + } + } +} diff --git a/zed/src/theme.rs b/zed/src/theme.rs index a5378fe033c2f70c7767b75a7fe9cc6ec434b47d..59bd2c5d6ec7682b72fc4f493328066ae8f740a7 100644 --- a/zed/src/theme.rs +++ b/zed/src/theme.rs @@ -25,6 +25,7 @@ pub struct Theme { pub workspace: Workspace, pub chat_panel: ChatPanel, pub people_panel: PeoplePanel, + pub project_panel: ProjectPanel, pub selector: Selector, pub editor: EditorStyle, pub syntax: SyntaxTheme, @@ -66,6 +67,7 @@ pub struct OfflineIcon { #[derive(Clone, Deserialize)] pub struct Tab { + pub height: f32, #[serde(flatten)] pub container: ContainerStyle, #[serde(flatten)] @@ -83,14 +85,15 @@ pub struct Sidebar { #[serde(flatten)] pub container: ContainerStyle, pub width: f32, - pub icon: SidebarIcon, - pub active_icon: SidebarIcon, + pub item: SidebarItem, + pub active_item: SidebarItem, pub resize_handle: ContainerStyle, } #[derive(Deserialize)] -pub struct SidebarIcon { - pub color: Color, +pub struct SidebarItem { + pub icon_color: Color, + pub icon_size: f32, pub height: f32, } @@ -106,6 +109,27 @@ pub struct ChatPanel { pub hovered_sign_in_prompt: TextStyle, } +#[derive(Debug, Deserialize)] +pub struct ProjectPanel { + #[serde(flatten)] + pub container: ContainerStyle, + pub entry: ProjectPanelEntry, + pub hovered_entry: ProjectPanelEntry, + pub selected_entry: ProjectPanelEntry, + pub hovered_selected_entry: ProjectPanelEntry, +} + +#[derive(Debug, Deserialize)] +pub struct ProjectPanelEntry { + pub height: f32, + #[serde(flatten)] + pub container: ContainerStyle, + pub text: TextStyle, + pub icon_color: Color, + pub icon_size: f32, + pub icon_spacing: f32, +} + #[derive(Deserialize)] pub struct PeoplePanel { #[serde(flatten)] diff --git a/zed/src/workspace.rs b/zed/src/workspace.rs index 6beb4899f492e0ce110ebb9335d44e23d0ba7d55..f382af321512095256b30c0b906af684c89d9e31 100644 --- a/zed/src/workspace.rs +++ b/zed/src/workspace.rs @@ -6,14 +6,14 @@ use crate::{ chat_panel::ChatPanel, editor::Buffer, fs::Fs, - language::LanguageRegistry, people_panel::{JoinWorktree, LeaveWorktree, PeoplePanel, ShareWorktree, UnshareWorktree}, - project_browser::ProjectBrowser, + project::Project, + project_panel::ProjectPanel, rpc, settings::Settings, user, - util::TryFutureExt as _, - worktree::{self, File, Worktree}, + workspace::sidebar::{Side, Sidebar, SidebarItemId, ToggleSidebarItem, ToggleSidebarItemFocus}, + worktree::{File, Worktree}, AppState, Authenticate, }; use anyhow::Result; @@ -32,9 +32,8 @@ use log::error; pub use pane::*; pub use pane_group::*; use postage::{prelude::Stream, watch}; -use sidebar::{Side, Sidebar, ToggleSidebarItem}; use std::{ - collections::{hash_map::Entry, HashMap, HashSet}, + collections::{hash_map::Entry, HashMap}, future::Future, path::{Path, PathBuf}, sync::Arc, @@ -56,6 +55,7 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(Workspace::debug_elements); cx.add_action(Workspace::open_new_file); cx.add_action(Workspace::toggle_sidebar_item); + cx.add_action(Workspace::toggle_sidebar_item_focus); cx.add_action(Workspace::share_worktree); cx.add_action(Workspace::unshare_worktree); cx.add_action(Workspace::join_worktree); @@ -63,6 +63,22 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_bindings(vec![ Binding::new("cmd-s", Save, None), Binding::new("cmd-alt-i", DebugElements, None), + Binding::new( + "cmd-shift-!", + ToggleSidebarItem(SidebarItemId { + side: Side::Left, + item_index: 0, + }), + None, + ), + Binding::new( + "cmd-1", + ToggleSidebarItemFocus(SidebarItemId { + side: Side::Left, + item_index: 0, + }), + None, + ), ]); pane::init(cx); } @@ -339,7 +355,6 @@ impl Clone for Box { pub struct Workspace { pub settings: watch::Receiver, - languages: Arc, rpc: Arc, user_store: ModelHandle, fs: Arc, @@ -349,7 +364,7 @@ pub struct Workspace { right_sidebar: Sidebar, panes: Vec>, active_pane: ViewHandle, - worktrees: HashSet>, + project: ModelHandle, items: Vec>, loading_items: HashMap< (usize, Arc), @@ -360,8 +375,17 @@ pub struct Workspace { impl Workspace { pub fn new(app_state: &AppState, cx: &mut ViewContext) -> Self { + let project = cx.add_model(|_| Project::new(app_state)); + cx.observe(&project, |_, _, cx| cx.notify()).detach(); + let pane = cx.add_view(|_| Pane::new(app_state.settings.clone())); let pane_id = pane.id(); + cx.observe(&pane, move |me, _, cx| { + let active_entry = me.active_entry(cx); + me.project + .update(cx, |project, cx| project.set_active_entry(active_entry, cx)); + }) + .detach(); cx.subscribe(&pane, move |me, _, event, cx| { me.handle_pane_event(pane_id, event, cx) }) @@ -371,7 +395,7 @@ impl Workspace { let mut left_sidebar = Sidebar::new(Side::Left); left_sidebar.add_item( "icons/folder-tree-16.svg", - cx.add_view(|_| ProjectBrowser).into(), + ProjectPanel::new(project.clone(), app_state.settings.clone(), cx).into(), ); let mut right_sidebar = Sidebar::new(Side::Right); @@ -418,21 +442,20 @@ impl Workspace { panes: vec![pane.clone()], active_pane: pane.clone(), settings: app_state.settings.clone(), - languages: app_state.languages.clone(), rpc: app_state.rpc.clone(), user_store: app_state.user_store.clone(), fs: app_state.fs.clone(), left_sidebar, right_sidebar, - worktrees: Default::default(), + project, items: Default::default(), loading_items: Default::default(), _observe_current_user, } } - pub fn worktrees(&self) -> &HashSet> { - &self.worktrees + pub fn worktrees<'a>(&self, cx: &'a AppContext) -> &'a [ModelHandle] { + &self.project.read(cx).worktrees() } pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool { @@ -440,7 +463,7 @@ impl Workspace { } pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool { - for worktree in &self.worktrees { + for worktree in self.worktrees(cx) { let worktree = worktree.read(cx).as_local(); if worktree.map_or(false, |w| w.contains_abs_path(path)) { return true; @@ -451,7 +474,7 @@ impl Workspace { pub fn worktree_scans_complete(&self, cx: &AppContext) -> impl Future + 'static { let futures = self - .worktrees + .worktrees(cx) .iter() .filter_map(|worktree| worktree.read(cx).as_local()) .map(|worktree| worktree.scan_complete()) @@ -511,7 +534,7 @@ impl Workspace { cx.spawn(|this, mut cx| async move { let mut entry_id = None; this.read_with(&cx, |this, cx| { - for tree in this.worktrees.iter() { + for tree in this.worktrees(cx) { if let Some(relative_path) = tree .read(cx) .as_local() @@ -551,19 +574,8 @@ impl Workspace { path: &Path, cx: &mut ViewContext, ) -> Task>> { - let languages = self.languages.clone(); - let rpc = self.rpc.clone(); - let fs = self.fs.clone(); - let path = Arc::from(path); - cx.spawn(|this, mut cx| async move { - let worktree = Worktree::open_local(rpc, path, fs, languages, &mut cx).await?; - this.update(&mut cx, |this, cx| { - cx.observe(&worktree, |_, _, cx| cx.notify()).detach(); - this.worktrees.insert(worktree.clone()); - cx.notify(); - }); - Ok(worktree) - }) + self.project + .update(cx, |project, cx| project.add_local_worktree(path, cx)) } pub fn toggle_modal(&mut self, cx: &mut ViewContext, add_view: F) @@ -616,7 +628,7 @@ impl Workspace { let (worktree_id, path) = entry.clone(); - let worktree = match self.worktrees.get(&worktree_id).cloned() { + let worktree = match self.project.read(cx).worktree_for_id(worktree_id) { Some(worktree) => worktree, None => { log::error!("worktree {} does not exist", worktree_id); @@ -727,11 +739,15 @@ impl Workspace { self.active_pane().read(cx).active_item() } + fn active_entry(&self, cx: &ViewContext) -> Option<(usize, Arc)> { + self.active_item(cx).and_then(|item| item.entry_id(cx)) + } + pub fn save_active_item(&mut self, _: &Save, cx: &mut ViewContext) { if let Some(item) = self.active_item(cx) { let handle = cx.handle(); if item.entry_id(cx.as_ref()).is_none() { - let worktree = self.worktrees.iter().next(); + let worktree = self.worktrees(cx).first(); let start_abs_path = worktree .and_then(|w| w.read(cx).as_local()) .map_or(Path::new(""), |w| w.abs_path()) @@ -806,6 +822,26 @@ impl Workspace { cx.notify(); } + pub fn toggle_sidebar_item_focus( + &mut self, + action: &ToggleSidebarItemFocus, + cx: &mut ViewContext, + ) { + let sidebar = match action.0.side { + Side::Left => &mut self.left_sidebar, + Side::Right => &mut self.right_sidebar, + }; + sidebar.activate_item(action.0.item_index); + if let Some(active_item) = sidebar.active_item() { + if active_item.is_focused(cx) { + cx.focus_self(); + } else { + cx.focus(active_item); + } + } + cx.notify(); + } + pub fn debug_elements(&mut self, _: &DebugElements, cx: &mut ViewContext) { match to_string_pretty(&cx.debug_elements()) { Ok(json) => { @@ -823,128 +859,34 @@ impl Workspace { } fn share_worktree(&mut self, action: &ShareWorktree, cx: &mut ViewContext) { - let rpc = self.rpc.clone(); - let remote_id = action.0; - cx.spawn(|this, mut cx| { - async move { - rpc.authenticate_and_connect(&cx).await?; - - let task = this.update(&mut cx, |this, cx| { - for worktree in &this.worktrees { - let task = worktree.update(cx, |worktree, cx| { - worktree.as_local_mut().and_then(|worktree| { - if worktree.remote_id() == Some(remote_id) { - Some(worktree.share(cx)) - } else { - None - } - }) - }); - - if task.is_some() { - return task; - } - } - None - }); - - if let Some(share_task) = task { - share_task.await?; - } - - Ok(()) - } - .log_err() - }) - .detach(); + self.project + .update(cx, |p, cx| p.share_worktree(action.0, cx)); } fn unshare_worktree(&mut self, action: &UnshareWorktree, cx: &mut ViewContext) { - let remote_id = action.0; - for worktree in &self.worktrees { - if worktree.update(cx, |worktree, cx| { - if let Some(worktree) = worktree.as_local_mut() { - if worktree.remote_id() == Some(remote_id) { - worktree.unshare(cx); - return true; - } - } - false - }) { - break; - } - } + self.project + .update(cx, |p, cx| p.unshare_worktree(action.0, cx)); } fn join_worktree(&mut self, action: &JoinWorktree, cx: &mut ViewContext) { - let rpc = self.rpc.clone(); - let languages = self.languages.clone(); - let worktree_id = action.0; - - cx.spawn(|this, mut cx| { - async move { - rpc.authenticate_and_connect(&cx).await?; - let worktree = - Worktree::open_remote(rpc.clone(), worktree_id, languages, &mut cx).await?; - this.update(&mut cx, |this, cx| { - cx.observe(&worktree, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&worktree, move |this, _, event, cx| match event { - worktree::Event::Closed => { - this.worktrees.retain(|worktree| { - worktree.update(cx, |worktree, cx| { - if let Some(worktree) = worktree.as_remote_mut() { - if worktree.remote_id() == worktree_id { - worktree.close_all_buffers(cx); - return false; - } - } - true - }) - }); - - cx.notify(); - } - }) - .detach(); - this.worktrees.insert(worktree); - cx.notify(); - }); - - Ok(()) - } - .log_err() - }) - .detach(); + self.project + .update(cx, |p, cx| p.add_remote_worktree(action.0, cx).detach()); } fn leave_worktree(&mut self, action: &LeaveWorktree, cx: &mut ViewContext) { - let remote_id = action.0; - cx.spawn(|this, mut cx| { - async move { - this.update(&mut cx, |this, cx| { - this.worktrees.retain(|worktree| { - worktree.update(cx, |worktree, cx| { - if let Some(worktree) = worktree.as_remote_mut() { - if worktree.remote_id() == remote_id { - worktree.close_all_buffers(cx); - return false; - } - } - true - }) - }) - }); - - Ok(()) - } - .log_err() - }) - .detach(); + self.project + .update(cx, |p, cx| p.close_remote_worktree(action.0, cx)); } fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { let pane = cx.add_view(|_| Pane::new(self.settings.clone())); let pane_id = pane.id(); + cx.observe(&pane, move |me, _, cx| { + let active_entry = me.active_entry(cx); + me.project + .update(cx, |project, cx| project.set_active_entry(active_entry, cx)); + }) + .detach(); cx.subscribe(&pane, move |me, _, event, cx| { me.handle_pane_event(pane_id, event, cx) }) @@ -1186,7 +1128,7 @@ pub trait WorkspaceHandle { impl WorkspaceHandle for ViewHandle { fn file_entries(&self, cx: &AppContext) -> Vec<(usize, Arc)> { self.read(cx) - .worktrees() + .worktrees(cx) .iter() .flat_map(|tree| { let tree_id = tree.id(); @@ -1254,8 +1196,8 @@ mod tests { .await; assert_eq!(cx.window_ids().len(), 1); let workspace_1 = cx.root_view::(cx.window_ids()[0]).unwrap(); - workspace_1.read_with(&cx, |workspace, _| { - assert_eq!(workspace.worktrees().len(), 2) + workspace_1.read_with(&cx, |workspace, cx| { + assert_eq!(workspace.worktrees(cx).len(), 2) }); cx.update(|cx| { @@ -1433,7 +1375,7 @@ mod tests { cx.read(|cx| { let worktree_roots = workspace .read(cx) - .worktrees() + .worktrees(cx) .iter() .map(|w| w.read(cx).as_local().unwrap().abs_path()) .collect::>(); @@ -1526,7 +1468,7 @@ mod tests { let tree = cx.read(|cx| { workspace .read(cx) - .worktrees() + .worktrees(cx) .iter() .next() .unwrap() diff --git a/zed/src/workspace/pane.rs b/zed/src/workspace/pane.rs index 31ec57354ad131f8542f211c5ee88cfb2520ad8f..6af62e8d1d80bf84e0392ac7510ee4f4733933ea 100644 --- a/zed/src/workspace/pane.rs +++ b/zed/src/workspace/pane.rs @@ -2,12 +2,11 @@ use super::{ItemViewHandle, SplitDirection}; use crate::settings::Settings; use gpui::{ action, - color::Color, elements::*, geometry::{rect::RectF, vector::vec2f}, keymap::Binding, platform::CursorStyle, - Border, Entity, MutableAppContext, Quad, RenderContext, View, ViewContext, ViewHandle, + Entity, MutableAppContext, Quad, RenderContext, View, ViewContext, ViewHandle, }; use postage::watch; use std::{cmp, path::Path, sync::Arc}; @@ -180,10 +179,6 @@ impl Pane { fn render_tabs(&self, cx: &mut RenderContext) -> ElementBox { let settings = self.settings.borrow(); let theme = &settings.theme; - let line_height = cx.font_cache().line_height( - theme.workspace.tab.label.text.font_id, - theme.workspace.tab.label.text.font_size, - ); enum Tabs {} let tabs = MouseEventHandler::new::(0, cx, |mouse_state, cx| { @@ -202,12 +197,11 @@ impl Pane { title.push('…'); } - let mut style = theme.workspace.tab.clone(); - if is_active { - style = theme.workspace.active_tab.clone(); - style.container.border.bottom = false; - style.container.padding.bottom += style.container.border.width; - } + let mut style = if is_active { + theme.workspace.active_tab.clone() + } else { + theme.workspace.tab.clone() + }; if ix == 0 { style.container.border.left = false; } @@ -319,26 +313,11 @@ impl Pane { }) } - // Ensure there's always a minimum amount of space after the last tab, - // so that the tab's border doesn't abut the window's border. - let mut border = Border::bottom(1.0, Color::default()); - border.color = theme.workspace.tab.container.border.color; - - row.add_child( - ConstrainedBox::new( - Container::new(Empty::new().boxed()) - .with_border(border) - .boxed(), - ) - .with_min_width(20.) - .named("fixed-filler"), - ); - row.add_child( Expanded::new( 0.0, Container::new(Empty::new().boxed()) - .with_border(border) + .with_border(theme.workspace.tab.container.border) .boxed(), ) .named("filler"), @@ -348,7 +327,7 @@ impl Pane { }); ConstrainedBox::new(tabs.boxed()) - .with_height(line_height + 16.) + .with_height(theme.workspace.tab.height) .named("tabs") } } diff --git a/zed/src/workspace/sidebar.rs b/zed/src/workspace/sidebar.rs index 3ceba15df0051701873c472a993ced0fe0b0ed64..377e5a16f590d3cd0b750ec1e317ed8c31371887 100644 --- a/zed/src/workspace/sidebar.rs +++ b/zed/src/workspace/sidebar.rs @@ -23,10 +23,11 @@ struct Item { view: AnyViewHandle, } -action!(ToggleSidebarItem, ToggleArg); +action!(ToggleSidebarItem, SidebarItemId); +action!(ToggleSidebarItemFocus, SidebarItemId); #[derive(Clone)] -pub struct ToggleArg { +pub struct SidebarItemId { pub side: Side, pub item_index: usize, } @@ -45,6 +46,10 @@ impl Sidebar { self.items.push(Item { icon_path, view }); } + pub fn activate_item(&mut self, item_ix: usize) { + self.active_item_ix = Some(item_ix); + } + pub fn toggle_item(&mut self, item_ix: usize) { if self.active_item_ix == Some(item_ix) { self.active_item_ix = None; @@ -68,11 +73,6 @@ impl Sidebar { pub fn render(&self, settings: &Settings, cx: &mut RenderContext) -> ElementBox { let side = self.side; - let theme = &settings.theme; - let line_height = cx.font_cache().line_height( - theme.workspace.tab.label.text.font_id, - theme.workspace.tab.label.text.font_size, - ); let theme = self.theme(settings); ConstrainedBox::new( @@ -80,9 +80,9 @@ impl Sidebar { Flex::column() .with_children(self.items.iter().enumerate().map(|(item_index, item)| { let theme = if Some(item_index) == self.active_item_ix { - &theme.active_icon + &theme.active_item } else { - &theme.icon + &theme.item }; enum SidebarButton {} MouseEventHandler::new::( @@ -93,21 +93,24 @@ impl Sidebar { Align::new( ConstrainedBox::new( Svg::new(item.icon_path) - .with_color(theme.color) + .with_color(theme.icon_color) .boxed(), ) - .with_height(theme.height) + .with_height(theme.icon_size) .boxed(), ) .boxed(), ) - .with_height(line_height + 16.0) + .with_height(theme.height) .boxed() }, ) .with_cursor_style(CursorStyle::PointingHand) .on_mouse_down(move |cx| { - cx.dispatch_action(ToggleSidebarItem(ToggleArg { side, item_index })) + cx.dispatch_action(ToggleSidebarItem(SidebarItemId { + side, + item_index, + })) }) .boxed() })) diff --git a/zed/src/worktree.rs b/zed/src/worktree.rs index 5863312343eb5aaf025f505d8c8bccc2551127c1..eaeefcd46257f293530eaee73488fd30461a9cc5 100644 --- a/zed/src/worktree.rs +++ b/zed/src/worktree.rs @@ -11,7 +11,7 @@ use crate::{ time::{self, ReplicaId}, util::{Bias, TryFutureExt}, }; -use ::ignore::gitignore::Gitignore; +use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{anyhow, Result}; use futures::{Stream, StreamExt}; pub use fuzzy::{match_paths, PathMatch}; @@ -732,12 +732,15 @@ impl LocalWorktree { next_entry_id: Arc::new(next_entry_id), }; if let Some(metadata) = metadata { - snapshot.insert_entry(Entry::new( - path.into(), - &metadata, - &snapshot.next_entry_id, - snapshot.root_char_bag, - )); + snapshot.insert_entry( + Entry::new( + path.into(), + &metadata, + &snapshot.next_entry_id, + snapshot.root_char_bag, + ), + fs.as_ref(), + ); } let (mut remote_id_tx, remote_id_rx) = watch::channel(); @@ -1156,6 +1159,16 @@ impl LocalWorktree { } } +fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { + let contents = smol::block_on(fs.load(&abs_path))?; + let parent = abs_path.parent().unwrap_or(Path::new("/")); + let mut builder = GitignoreBuilder::new(parent); + for line in contents.lines() { + builder.add_line(Some(abs_path.into()), line)?; + } + Ok(builder.build()?) +} + pub fn refresh_buffer(abs_path: PathBuf, fs: &Arc, cx: &mut ModelContext) { let fs = fs.clone(); cx.spawn(|buffer, mut cx| async move { @@ -1556,7 +1569,7 @@ impl Snapshot { &self.root_name } - fn entry_for_path(&self, path: impl AsRef) -> Option<&Entry> { + pub fn entry_for_path(&self, path: impl AsRef) -> Option<&Entry> { let path = path.as_ref(); self.traverse_from_path(true, true, path) .entry() @@ -1569,7 +1582,7 @@ impl Snapshot { }) } - fn entry_for_id(&self, id: usize) -> Option<&Entry> { + pub fn entry_for_id(&self, id: usize) -> Option<&Entry> { let entry = self.entries_by_id.get(&id, &())?; self.entry_for_path(&entry.path) } @@ -1578,16 +1591,23 @@ impl Snapshot { self.entry_for_path(path.as_ref()).map(|e| e.inode) } - fn insert_entry(&mut self, mut entry: Entry) -> Entry { + fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry { if !entry.is_dir() && entry.path.file_name() == Some(&GITIGNORE) { - let (ignore, err) = Gitignore::new(self.abs_path.join(&entry.path)); - if let Some(err) = err { - log::error!("error in ignore file {:?} - {:?}", &entry.path, err); + let abs_path = self.abs_path.join(&entry.path); + match build_gitignore(&abs_path, fs) { + Ok(ignore) => { + let ignore_dir_path = entry.path.parent().unwrap(); + self.ignores + .insert(ignore_dir_path.into(), (Arc::new(ignore), self.scan_id)); + } + Err(error) => { + log::error!( + "error loading .gitignore file {:?} - {:?}", + &entry.path, + error + ); + } } - - let ignore_dir_path = entry.path.parent().unwrap(); - self.ignores - .insert(ignore_dir_path.into(), (Arc::new(ignore), self.scan_id)); } self.reuse_entry_id(&mut entry); @@ -2204,13 +2224,20 @@ impl BackgroundScanner { // If we find a .gitignore, add it to the stack of ignores used to determine which paths are ignored if child_name == *GITIGNORE { - let (ignore, err) = Gitignore::new(&child_abs_path); - if let Some(err) = err { - log::error!("error in ignore file {:?} - {:?}", child_name, err); + match build_gitignore(&child_abs_path, self.fs.as_ref()) { + Ok(ignore) => { + let ignore = Arc::new(ignore); + ignore_stack = ignore_stack.append(job.path.clone(), ignore.clone()); + new_ignore = Some(ignore); + } + Err(error) => { + log::error!( + "error loading .gitignore file {:?} - {:?}", + child_name, + error + ); + } } - let ignore = Arc::new(ignore); - ignore_stack = ignore_stack.append(job.path.clone(), ignore.clone()); - new_ignore = Some(ignore); // Update ignore status of any child entries we've already processed to reflect the // ignore file in the current directory. Because `.gitignore` starts with a `.`, @@ -2319,7 +2346,7 @@ impl BackgroundScanner { snapshot.root_char_bag, ); fs_entry.is_ignored = ignore_stack.is_all(); - snapshot.insert_entry(fs_entry); + snapshot.insert_entry(fs_entry, self.fs.as_ref()); if metadata.is_dir { scan_queue_tx .send(ScanJob { @@ -2488,7 +2515,7 @@ async fn refresh_entry( &next_entry_id, root_char_bag, ); - Ok(snapshot.lock().insert_entry(entry)) + Ok(snapshot.lock().insert_entry(entry, fs)) } fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag { @@ -2605,13 +2632,13 @@ pub struct Traversal<'a> { impl<'a> Traversal<'a> { pub fn advance(&mut self) -> bool { + self.advance_to_offset(self.offset() + 1) + } + + pub fn advance_to_offset(&mut self, offset: usize) -> bool { self.cursor.seek_forward( &TraversalTarget::Count { - count: self - .cursor - .start() - .count(self.include_dirs, self.include_ignored) - + 1, + count: offset, include_dirs: self.include_dirs, include_ignored: self.include_ignored, }, @@ -2641,6 +2668,12 @@ impl<'a> Traversal<'a> { pub fn entry(&self) -> Option<&'a Entry> { self.cursor.item() } + + pub fn offset(&self) -> usize { + self.cursor + .start() + .count(self.include_dirs, self.include_ignored) + } } impl<'a> Iterator for Traversal<'a> { @@ -2766,6 +2799,48 @@ mod tests { use std::time::UNIX_EPOCH; use std::{env, fmt::Write, os::unix, time::SystemTime}; + #[gpui::test] + async fn test_traversal(cx: gpui::TestAppContext) { + let fs = FakeFs::new(); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "a/b\n", + "a": { + "b": "", + "c": "", + } + }), + ) + .await; + + let tree = Worktree::open_local( + rpc::Client::new(), + Arc::from(Path::new("/root")), + Arc::new(fs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(&cx, |tree, _| { + assert_eq!( + tree.entries(false) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new(".gitignore"), + Path::new("a"), + Path::new("a/c"), + ] + ); + }) + } + #[gpui::test] async fn test_populate_and_search(cx: gpui::TestAppContext) { let dir = temp_tree(json!({ @@ -3252,14 +3327,17 @@ mod tests { root_char_bag: Default::default(), next_entry_id: next_entry_id.clone(), }; - initial_snapshot.insert_entry(Entry::new( - Path::new("").into(), - &smol::block_on(fs.metadata(root_dir.path())) - .unwrap() - .unwrap(), - &next_entry_id, - Default::default(), - )); + initial_snapshot.insert_entry( + Entry::new( + Path::new("").into(), + &smol::block_on(fs.metadata(root_dir.path())) + .unwrap() + .unwrap(), + &next_entry_id, + Default::default(), + ), + fs.as_ref(), + ); let mut scanner = BackgroundScanner::new( Arc::new(Mutex::new(initial_snapshot.clone())), notify_tx,