Detailed changes
@@ -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<usize, Box<dyn Fn(&AppContext) -> crate::json::Value>>,
foreground: Rc<executor::Foreground>,
pending_effects: VecDeque<Effect>,
+ pending_notifications: HashSet<usize>,
pending_flushes: usize,
flushing_effects: bool,
next_cursor_style_handle_id: Arc<AtomicUsize>,
@@ -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<A: 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<S: Entity, F>(
@@ -2891,6 +2900,11 @@ impl AnyViewHandle {
TypeId::of::<T>() == 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<T: View>(self) -> Option<ViewHandle<T>> {
if self.is::<T>() {
let result = Some(ViewHandle {
@@ -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(
@@ -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),
);
@@ -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<F>
where
- F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+ F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
{
state: UniformListState,
item_count: usize,
append_items: F,
+ padding_top: f32,
+ padding_bottom: f32,
}
impl<F> UniformList<F>
where
- F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+ F: Fn(Range<usize>, &mut Vec<ElementBox>, &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<F> Element for UniformList<F>
where
- F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+ F: Fn(Range<usize>, &mut Vec<ElementBox>, &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);
@@ -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<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
+ where
+ T: View,
+ F: FnOnce(&mut T, &mut crate::ViewContext<T>) -> S,
+ {
+ self.app.update_view(handle, update)
+ }
+}
+
impl<'a> ReadModel for LayoutContext<'a> {
fn read_model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T {
self.app.read_model(handle)
@@ -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,
@@ -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::<Item, _, _, _>(
(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
@@ -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);
@@ -0,0 +1,3 @@
+<svg width="4" height="8" viewBox="0 0 4 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0.923915 0.64914L3.7699 3.67213C3.85691 3.76411 3.9004 3.88214 3.9004 4.00028C3.9004 4.11835 3.85689 4.23635 3.7699 4.32843L0.923915 7.35142C0.742536 7.54234 0.440436 7.5503 0.249113 7.36932C0.0564376 7.18784 0.0496359 6.88444 0.230468 6.69431L2.7841 3.99948L0.230468 1.30465C0.0496359 1.11452 0.0563979 0.813217 0.249113 0.630446C0.440436 0.449663 0.742536 0.457618 0.923915 0.64914Z" fill="#66686A"/>
+</svg>
@@ -0,0 +1,3 @@
+<svg width="8" height="4" viewBox="0 0 8 4" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.35131 0.916948L4.32837 3.76288C4.23689 3.8663 4.11756 3.91005 4.00022 3.91005C3.88289 3.91005 3.76396 3.86654 3.67208 3.77955L0.649138 0.916948C0.457619 0.733981 0.449664 0.431687 0.630444 0.240765C0.812019 0.0478537 1.11531 0.0418875 1.30543 0.222866L4.00022 2.77446L6.69501 0.220877C6.88518 0.0400179 7.18723 0.0468593 7.37 0.239522C7.55019 0.431687 7.54223 0.733981 7.35131 0.916948Z" fill="#66686A"/>
+</svg>
@@ -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
@@ -385,7 +385,7 @@ impl FileFinder {
.workspace
.upgrade(&cx)?
.read(cx)
- .worktrees()
+ .worktrees(cx)
.iter()
.map(|tree| tree.read(cx).snapshot())
.collect::<Vec<_>>();
@@ -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"))]
@@ -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()));
@@ -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<ModelHandle<Worktree>>,
+ active_entry: Option<(usize, usize)>,
+ languages: Arc<LanguageRegistry>,
+ rpc: Arc<Client>,
+ fs: Arc<dyn Fs>,
+}
+
+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<Worktree>] {
+ &self.worktrees
+ }
+
+ pub fn worktree_for_id(&self, id: usize) -> Option<ModelHandle<Worktree>> {
+ self.worktrees
+ .iter()
+ .find(|worktree| worktree.id() == id)
+ .cloned()
+ }
+
+ pub fn add_local_worktree(
+ &mut self,
+ path: &Path,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<ModelHandle<Worktree>>> {
+ 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<Self>,
+ ) -> Task<Result<ModelHandle<Worktree>>> {
+ 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<Worktree>, cx: &mut ModelContext<Self>) {
+ cx.observe(&worktree, |_, _, cx| cx.notify()).detach();
+ self.worktrees.push(worktree);
+ cx.notify();
+ }
+
+ pub fn set_active_entry(
+ &mut self,
+ entry: Option<(usize, Arc<Path>)>,
+ cx: &mut ModelContext<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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>) {
+ 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;
+}
@@ -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()
- }
-}
@@ -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<Project>,
+ list: UniformListState,
+ visible_entries: Vec<Vec<usize>>,
+ expanded_dir_ids: HashMap<usize, Vec<usize>>,
+ selection: Option<Selection>,
+ settings: watch::Receiver<Settings>,
+ handle: WeakViewHandle<Self>,
+}
+
+#[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<Project>,
+ settings: watch::Receiver<Settings>,
+ cx: &mut ViewContext<Workspace>,
+ ) -> ViewHandle<Self> {
+ let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>) {
+ 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<C: ReadModel>(
+ &self,
+ range: Range<usize>,
+ 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<Self>,
+ ) -> ElementBox {
+ let is_dir = details.is_dir;
+ MouseEventHandler::new::<Self, _, _, _>(
+ (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<ProjectPanel>,
+ path: impl AsRef<Path>,
+ 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<ProjectPanel>,
+ range: Range<usize>,
+ cx: &mut TestAppContext,
+ ) -> Vec<EntryDetails> {
+ 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
+ }
+ }
+}
@@ -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)]
@@ -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<dyn ItemHandle> {
pub struct Workspace {
pub settings: watch::Receiver<Settings>,
- languages: Arc<LanguageRegistry>,
rpc: Arc<rpc::Client>,
user_store: ModelHandle<user::UserStore>,
fs: Arc<dyn Fs>,
@@ -349,7 +364,7 @@ pub struct Workspace {
right_sidebar: Sidebar,
panes: Vec<ViewHandle<Pane>>,
active_pane: ViewHandle<Pane>,
- worktrees: HashSet<ModelHandle<Worktree>>,
+ project: ModelHandle<Project>,
items: Vec<Box<dyn WeakItemHandle>>,
loading_items: HashMap<
(usize, Arc<Path>),
@@ -360,8 +375,17 @@ pub struct Workspace {
impl Workspace {
pub fn new(app_state: &AppState, cx: &mut ViewContext<Self>) -> 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<ModelHandle<Worktree>> {
- &self.worktrees
+ pub fn worktrees<'a>(&self, cx: &'a AppContext) -> &'a [ModelHandle<Worktree>] {
+ &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<Output = ()> + '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<Self>,
) -> Task<Result<ModelHandle<Worktree>>> {
- 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<V, F>(&mut self, cx: &mut ViewContext<Self>, 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<Self>) -> Option<(usize, Arc<Path>)> {
+ self.active_item(cx).and_then(|item| item.entry_id(cx))
+ }
+
pub fn save_active_item(&mut self, _: &Save, cx: &mut ViewContext<Self>) {
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<Self>,
+ ) {
+ 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<Self>) {
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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>) -> ViewHandle<Pane> {
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<Workspace> {
fn file_entries(&self, cx: &AppContext) -> Vec<(usize, Arc<Path>)> {
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::<Workspace>(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::<HashSet<_>>();
@@ -1526,7 +1468,7 @@ mod tests {
let tree = cx.read(|cx| {
workspace
.read(cx)
- .worktrees()
+ .worktrees(cx)
.iter()
.next()
.unwrap()
@@ -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<Self>) -> 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::<Tabs, _, _, _>(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")
}
}
@@ -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<Workspace>) -> 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::<SidebarButton, _, _, _>(
@@ -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()
}))
@@ -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<Gitignore> {
+ 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<dyn Fs>, cx: &mut ModelContext<Buffer>) {
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<Path>) -> Option<&Entry> {
+ pub fn entry_for_path(&self, path: impl AsRef<Path>) -> 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<_>>(),
+ 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,