Merge pull request #31 from zed-industries/open-files

Max Brunsfeld created

Allow opening files from the CLI and from the File > Open menu

Change summary

gpui/src/app.rs                     |   4 
zed/src/editor/buffer/mod.rs        |   7 
zed/src/editor/buffer_view.rs       |   7 
zed/src/file_finder.rs              | 291 +++++++++++++++++++-----------
zed/src/workspace/mod.rs            |  12 
zed/src/workspace/workspace.rs      |  25 +
zed/src/workspace/workspace_view.rs | 204 +++++++++++++++++---
zed/src/worktree.rs                 |  73 +++++--
zed/src/worktree/fuzzy.rs           |   8 
9 files changed, 442 insertions(+), 189 deletions(-)

Detailed changes

gpui/src/app.rs 🔗

@@ -1717,6 +1717,10 @@ impl<'a, T: View> ViewContext<'a, T> {
         self.window_id
     }
 
+    pub fn foreground(&self) -> &Rc<executor::Foreground> {
+        self.app.foreground_executor()
+    }
+
     pub fn background_executor(&self) -> &Arc<executor::Background> {
         &self.app.ctx.background
     }

zed/src/editor/buffer/mod.rs 🔗

@@ -18,11 +18,12 @@ use crate::{
     worktree::FileHandle,
 };
 use anyhow::{anyhow, Result};
-use gpui::{Entity, ModelContext};
+use gpui::{AppContext, Entity, ModelContext};
 use lazy_static::lazy_static;
 use rand::prelude::*;
 use std::{
     cmp,
+    ffi::OsString,
     hash::BuildHasher,
     iter::{self, Iterator},
     ops::{AddAssign, Range},
@@ -447,6 +448,10 @@ impl Buffer {
         }
     }
 
+    pub fn file_name(&self, ctx: &AppContext) -> Option<OsString> {
+        self.file.as_ref().and_then(|file| file.file_name(ctx))
+    }
+
     pub fn path(&self) -> Option<Arc<Path>> {
         self.file.as_ref().map(|file| file.path())
     }

zed/src/editor/buffer_view.rs 🔗

@@ -1362,11 +1362,8 @@ impl workspace::ItemView for BufferView {
     }
 
     fn title(&self, app: &AppContext) -> std::string::String {
-        if let Some(path) = self.buffer.read(app).path() {
-            path.file_name()
-                .expect("buffer's path is always to a file")
-                .to_string_lossy()
-                .into()
+        if let Some(name) = self.buffer.read(app).file_name(app) {
+            name.to_string_lossy().into()
         } else {
             "untitled".into()
         }

zed/src/file_finder.rs 🔗

@@ -33,8 +33,7 @@ pub struct FileFinder {
     latest_search_did_cancel: bool,
     latest_search_query: String,
     matches: Vec<PathMatch>,
-    include_root_name: bool,
-    selected: Option<Arc<Path>>,
+    selected: Option<(usize, Arc<Path>)>,
     cancel_flag: Arc<AtomicBool>,
     list_state: UniformListState,
 }
@@ -147,101 +146,110 @@ impl FileFinder {
         index: usize,
         app: &AppContext,
     ) -> Option<ElementBox> {
-        let tree_id = path_match.tree_id;
-
-        self.worktree(tree_id, app).map(|tree| {
-            let prefix = if self.include_root_name {
-                tree.root_name()
-            } else {
-                ""
-            };
-            let path = path_match.path.clone();
-            let path_string = path_match.path.to_string_lossy();
-            let file_name = path_match
-                .path
-                .file_name()
-                .unwrap_or_default()
-                .to_string_lossy();
-
-            let path_positions = path_match.positions.clone();
-            let file_name_start =
-                prefix.len() + path_string.chars().count() - file_name.chars().count();
-            let mut file_name_positions = Vec::new();
-            file_name_positions.extend(path_positions.iter().filter_map(|pos| {
-                if pos >= &file_name_start {
-                    Some(pos - file_name_start)
-                } else {
-                    None
-                }
-            }));
-
-            let settings = smol::block_on(self.settings.read());
-            let highlight_color = ColorU::from_u32(0x304ee2ff);
-            let bold = *Properties::new().weight(Weight::BOLD);
-
-            let mut full_path = prefix.to_string();
-            full_path.push_str(&path_string);
-
-            let mut container = Container::new(
-                Flex::row()
-                    .with_child(
-                        Container::new(
-                            LineBox::new(
-                                settings.ui_font_family,
-                                settings.ui_font_size,
-                                Svg::new("icons/file-16.svg").boxed(),
+        self.labels_for_match(path_match, app).map(
+            |(file_name, file_name_positions, full_path, full_path_positions)| {
+                let settings = smol::block_on(self.settings.read());
+                let highlight_color = ColorU::from_u32(0x304ee2ff);
+                let bold = *Properties::new().weight(Weight::BOLD);
+                let mut container = Container::new(
+                    Flex::row()
+                        .with_child(
+                            Container::new(
+                                LineBox::new(
+                                    settings.ui_font_family,
+                                    settings.ui_font_size,
+                                    Svg::new("icons/file-16.svg").boxed(),
+                                )
+                                .boxed(),
                             )
+                            .with_padding_right(6.0)
                             .boxed(),
                         )
-                        .with_padding_right(6.0)
-                        .boxed(),
-                    )
-                    .with_child(
-                        Expanded::new(
-                            1.0,
-                            Flex::column()
-                                .with_child(
-                                    Label::new(
-                                        file_name.to_string(),
-                                        settings.ui_font_family,
-                                        settings.ui_font_size,
+                        .with_child(
+                            Expanded::new(
+                                1.0,
+                                Flex::column()
+                                    .with_child(
+                                        Label::new(
+                                            file_name.to_string(),
+                                            settings.ui_font_family,
+                                            settings.ui_font_size,
+                                        )
+                                        .with_highlights(highlight_color, bold, file_name_positions)
+                                        .boxed(),
                                     )
-                                    .with_highlights(highlight_color, bold, file_name_positions)
-                                    .boxed(),
-                                )
-                                .with_child(
-                                    Label::new(
-                                        full_path,
-                                        settings.ui_font_family,
-                                        settings.ui_font_size,
+                                    .with_child(
+                                        Label::new(
+                                            full_path,
+                                            settings.ui_font_family,
+                                            settings.ui_font_size,
+                                        )
+                                        .with_highlights(highlight_color, bold, full_path_positions)
+                                        .boxed(),
                                     )
-                                    .with_highlights(highlight_color, bold, path_positions)
                                     .boxed(),
-                                )
-                                .boxed(),
+                            )
+                            .boxed(),
                         )
                         .boxed(),
-                    )
-                    .boxed(),
-            )
-            .with_uniform_padding(6.0);
+                )
+                .with_uniform_padding(6.0);
 
-            let selected_index = self.selected_index();
-            if index == selected_index || index < self.matches.len() - 1 {
-                container =
-                    container.with_border(Border::bottom(1.0, ColorU::from_u32(0xdbdbdcff)));
-            }
+                let selected_index = self.selected_index();
+                if index == selected_index || index < self.matches.len() - 1 {
+                    container =
+                        container.with_border(Border::bottom(1.0, ColorU::from_u32(0xdbdbdcff)));
+                }
 
-            if index == selected_index {
-                container = container.with_background_color(ColorU::from_u32(0xdbdbdcff));
-            }
+                if index == selected_index {
+                    container = container.with_background_color(ColorU::from_u32(0xdbdbdcff));
+                }
+
+                let entry = (path_match.tree_id, path_match.path.clone());
+                EventHandler::new(container.boxed())
+                    .on_mouse_down(move |ctx| {
+                        ctx.dispatch_action("file_finder:select", entry.clone());
+                        true
+                    })
+                    .named("match")
+            },
+        )
+    }
+
+    fn labels_for_match(
+        &self,
+        path_match: &PathMatch,
+        app: &AppContext,
+    ) -> Option<(String, Vec<usize>, String, Vec<usize>)> {
+        self.worktree(path_match.tree_id, app).map(|tree| {
+            let prefix = if path_match.include_root_name {
+                tree.root_name()
+            } else {
+                ""
+            };
 
-            EventHandler::new(container.boxed())
-                .on_mouse_down(move |ctx| {
-                    ctx.dispatch_action("file_finder:select", (tree_id, path.clone()));
-                    true
+            let path_string = path_match.path.to_string_lossy();
+            let full_path = [prefix, path_string.as_ref()].join("");
+            let path_positions = path_match.positions.clone();
+
+            let file_name = path_match.path.file_name().map_or_else(
+                || prefix.to_string(),
+                |file_name| file_name.to_string_lossy().to_string(),
+            );
+            let file_name_start =
+                prefix.chars().count() + path_string.chars().count() - file_name.chars().count();
+            let file_name_positions = path_positions
+                .iter()
+                .filter_map(|pos| {
+                    if pos >= &file_name_start {
+                        Some(pos - file_name_start)
+                    } else {
+                        None
+                    }
                 })
-                .named("match")
+                .collect();
+
+            (file_name, file_name_positions, full_path, path_positions)
         })
     }
 
@@ -267,7 +275,9 @@ impl FileFinder {
     ) {
         match event {
             Event::Selected(tree_id, path) => {
-                workspace_view.open_entry((*tree_id, path.clone()), ctx);
+                workspace_view
+                    .open_entry((*tree_id, path.clone()), ctx)
+                    .map(|d| d.detach());
                 workspace_view.dismiss_modal(ctx);
             }
             Event::Dismissed => {
@@ -298,7 +308,6 @@ impl FileFinder {
             latest_search_did_cancel: false,
             latest_search_query: String::new(),
             matches: Vec::new(),
-            include_root_name: false,
             selected: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
             list_state: UniformListState::new(),
@@ -335,7 +344,9 @@ impl FileFinder {
     fn selected_index(&self) -> usize {
         if let Some(selected) = self.selected.as_ref() {
             for (ix, path_match) in self.matches.iter().enumerate() {
-                if path_match.path.as_ref() == selected.as_ref() {
+                if (path_match.tree_id, path_match.path.as_ref())
+                    == (selected.0, selected.1.as_ref())
+                {
                     return ix;
                 }
             }
@@ -347,7 +358,8 @@ impl FileFinder {
         let mut selected_index = self.selected_index();
         if selected_index > 0 {
             selected_index -= 1;
-            self.selected = Some(self.matches[selected_index].path.clone());
+            let mat = &self.matches[selected_index];
+            self.selected = Some((mat.tree_id, mat.path.clone()));
         }
         self.list_state.scroll_to(selected_index);
         ctx.notify();
@@ -357,7 +369,8 @@ impl FileFinder {
         let mut selected_index = self.selected_index();
         if selected_index + 1 < self.matches.len() {
             selected_index += 1;
-            self.selected = Some(self.matches[selected_index].path.clone());
+            let mat = &self.matches[selected_index];
+            self.selected = Some((mat.tree_id, mat.path.clone()));
         }
         self.list_state.scroll_to(selected_index);
         ctx.notify();
@@ -403,7 +416,7 @@ impl FileFinder {
                 pool,
             );
             let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
-            (search_id, include_root_name, did_cancel, query, matches)
+            (search_id, did_cancel, query, matches)
         });
 
         ctx.spawn(task, Self::update_matches).detach();
@@ -411,13 +424,7 @@ impl FileFinder {
 
     fn update_matches(
         &mut self,
-        (search_id, include_root_name, did_cancel, query, matches): (
-            usize,
-            bool,
-            bool,
-            String,
-            Vec<PathMatch>,
-        ),
+        (search_id, did_cancel, query, matches): (usize, bool, String, Vec<PathMatch>),
         ctx: &mut ViewContext<Self>,
     ) {
         if search_id >= self.latest_search_id {
@@ -429,7 +436,6 @@ impl FileFinder {
             }
             self.latest_search_query = query;
             self.latest_search_did_cancel = did_cancel;
-            self.include_root_name = include_root_name;
             self.list_state.scroll_to(self.selected_index());
             ctx.notify();
         }
@@ -454,20 +460,16 @@ mod tests {
     };
     use gpui::App;
     use serde_json::json;
-    use smol::fs;
+    use std::fs;
     use tempdir::TempDir;
 
     #[test]
     fn test_matching_paths() {
         App::test_async((), |mut app| async move {
             let tmp_dir = TempDir::new("example").unwrap();
-            fs::create_dir(tmp_dir.path().join("a")).await.unwrap();
-            fs::write(tmp_dir.path().join("a/banana"), "banana")
-                .await
-                .unwrap();
-            fs::write(tmp_dir.path().join("a/bandana"), "bandana")
-                .await
-                .unwrap();
+            fs::create_dir(tmp_dir.path().join("a")).unwrap();
+            fs::write(tmp_dir.path().join("a/banana"), "banana").unwrap();
+            fs::write(tmp_dir.path().join("a/bandana"), "bandana").unwrap();
             app.update(|ctx| {
                 super::init(ctx);
                 editor::init(ctx);
@@ -560,7 +562,6 @@ mod tests {
                 finder.update_matches(
                     (
                         finder.latest_search_id,
-                        true,
                         true, // did-cancel
                         query.clone(),
                         vec![matches[1].clone(), matches[3].clone()],
@@ -573,7 +574,6 @@ mod tests {
                 finder.update_matches(
                     (
                         finder.latest_search_id,
-                        true,
                         true, // did-cancel
                         query.clone(),
                         vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
@@ -585,4 +585,77 @@ mod tests {
             });
         });
     }
+
+    #[test]
+    fn test_single_file_worktrees() {
+        App::test_async((), |mut app| async move {
+            let temp_dir = TempDir::new("test-single-file-worktrees").unwrap();
+            let dir_path = temp_dir.path().join("the-parent-dir");
+            let file_path = dir_path.join("the-file");
+            fs::create_dir(&dir_path).unwrap();
+            fs::write(&file_path, "").unwrap();
+
+            let settings = settings::channel(&app.font_cache()).unwrap().1;
+            let workspace = app.add_model(|ctx| Workspace::new(vec![file_path], ctx));
+            app.read(|ctx| workspace.read(ctx).worktree_scans_complete(ctx))
+                .await;
+            let (_, finder) =
+                app.add_window(|ctx| FileFinder::new(settings, workspace.clone(), ctx));
+
+            // Even though there is only one worktree, that worktree's filename
+            // is included in the matching, because the worktree is a single file.
+            finder.update(&mut app, |f, ctx| f.spawn_search("thf".into(), ctx));
+            finder.condition(&app, |f, _| f.matches.len() == 1).await;
+
+            app.read(|ctx| {
+                let finder = finder.read(ctx);
+                let (file_name, file_name_positions, full_path, full_path_positions) =
+                    finder.labels_for_match(&finder.matches[0], ctx).unwrap();
+
+                assert_eq!(file_name, "the-file");
+                assert_eq!(file_name_positions, &[0, 1, 4]);
+                assert_eq!(full_path, "the-file");
+                assert_eq!(full_path_positions, &[0, 1, 4]);
+            });
+
+            // Since the worktree root is a file, searching for its name followed by a slash does
+            // not match anything.
+            finder.update(&mut app, |f, ctx| f.spawn_search("thf/".into(), ctx));
+            finder.condition(&app, |f, _| f.matches.len() == 0).await;
+        });
+    }
+
+    #[test]
+    fn test_multiple_matches_with_same_relative_path() {
+        App::test_async((), |mut app| async move {
+            let tmp_dir = temp_tree(json!({
+                "dir1": { "a.txt": "" },
+                "dir2": { "a.txt": "" }
+            }));
+            let settings = settings::channel(&app.font_cache()).unwrap().1;
+            let workspace = app.add_model(|ctx| {
+                Workspace::new(
+                    vec![tmp_dir.path().join("dir1"), tmp_dir.path().join("dir2")],
+                    ctx,
+                )
+            });
+            app.read(|ctx| workspace.read(ctx).worktree_scans_complete(ctx))
+                .await;
+            let (_, finder) =
+                app.add_window(|ctx| FileFinder::new(settings, workspace.clone(), ctx));
+
+            // Run a search that matches two files with the same relative path.
+            finder.update(&mut app, |f, ctx| f.spawn_search("a.t".into(), ctx));
+            finder.condition(&app, |f, _| f.matches.len() == 2).await;
+
+            // Can switch between different matches with the same relative path.
+            finder.update(&mut app, |f, ctx| {
+                assert_eq!(f.selected_index(), 0);
+                f.select_next(&(), ctx);
+                assert_eq!(f.selected_index(), 1);
+                f.select_prev(&(), ctx);
+                assert_eq!(f.selected_index(), 0);
+            });
+        });
+    }
 }

zed/src/workspace/mod.rs 🔗

@@ -52,7 +52,8 @@ fn open_paths(params: &OpenParams, app: &mut MutableAppContext) {
         if let Some(handle) = app.root_view::<WorkspaceView>(window_id) {
             if handle.update(app, |view, ctx| {
                 if view.contains_paths(&params.paths, ctx.as_ref()) {
-                    view.open_paths(&params.paths, ctx.as_mut());
+                    let open_paths = view.open_paths(&params.paths, ctx);
+                    ctx.foreground().spawn(open_paths).detach();
                     log::info!("open paths on existing workspace");
                     true
                 } else {
@@ -67,8 +68,13 @@ fn open_paths(params: &OpenParams, app: &mut MutableAppContext) {
     log::info!("open new workspace");
 
     // Add a new workspace if necessary
-    let workspace = app.add_model(|ctx| Workspace::new(params.paths.clone(), ctx));
-    app.add_window(|ctx| WorkspaceView::new(workspace, params.settings.clone(), ctx));
+    let workspace = app.add_model(|ctx| Workspace::new(vec![], ctx));
+    app.add_window(|ctx| {
+        let view = WorkspaceView::new(workspace, params.settings.clone(), ctx);
+        let open_paths = view.open_paths(&params.paths, ctx);
+        ctx.foreground().spawn(open_paths).detach();
+        view
+    });
 }
 
 fn quit(_: &(), app: &mut MutableAppContext) {

zed/src/workspace/workspace.rs 🔗

@@ -117,23 +117,31 @@ impl Workspace {
             .any(|worktree| worktree.read(app).contains_abs_path(path))
     }
 
-    pub fn open_paths(&mut self, paths: &[PathBuf], ctx: &mut ModelContext<Self>) {
-        for path in paths.iter().cloned() {
-            self.open_path(path, ctx);
-        }
+    pub fn open_paths(
+        &mut self,
+        paths: &[PathBuf],
+        ctx: &mut ModelContext<Self>,
+    ) -> Vec<(usize, Arc<Path>)> {
+        paths
+            .iter()
+            .cloned()
+            .map(move |path| self.open_path(path, ctx))
+            .collect()
     }
 
-    pub fn open_path<'a>(&'a mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
+    fn open_path(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) -> (usize, Arc<Path>) {
         for tree in self.worktrees.iter() {
-            if tree.read(ctx).contains_abs_path(&path) {
-                return;
+            if let Ok(relative_path) = path.strip_prefix(tree.read(ctx).abs_path()) {
+                return (tree.id(), relative_path.into());
             }
         }
 
-        let worktree = ctx.add_model(|ctx| Worktree::new(path, ctx));
+        let worktree = ctx.add_model(|ctx| Worktree::new(path.clone(), ctx));
+        let worktree_id = worktree.id();
         ctx.observe(&worktree, Self::on_worktree_updated);
         self.worktrees.insert(worktree);
         ctx.notify();
+        (worktree_id, Path::new("").into())
     }
 
     pub fn open_entry(
@@ -174,7 +182,6 @@ impl Workspace {
         let replica_id = self.replica_id;
         let file = worktree.file(path.clone(), ctx.as_ref())?;
         let history = file.load_history(ctx.as_ref());
-        // let buffer = async move { Ok(Buffer::from_history(replica_id, file, history.await?)) };
 
         let (mut tx, rx) = watch::channel(None);
         self.items.insert(item_key, OpenedItem::Loading(rx));

zed/src/workspace/workspace_view.rs 🔗

@@ -1,9 +1,10 @@
 use super::{pane, Pane, PaneGroup, SplitDirection, Workspace};
 use crate::{settings::Settings, watch};
-use futures_core::future::LocalBoxFuture;
+use futures_core::{future::LocalBoxFuture, Future};
 use gpui::{
     color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext,
-    ClipboardItem, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
+    ClipboardItem, Entity, EntityTask, ModelHandle, MutableAppContext, View, ViewContext,
+    ViewHandle,
 };
 use log::error;
 use std::{
@@ -161,9 +162,39 @@ impl WorkspaceView {
         self.workspace.read(app).contains_paths(paths, app)
     }
 
-    pub fn open_paths(&self, paths: &[PathBuf], app: &mut MutableAppContext) {
-        self.workspace
-            .update(app, |workspace, ctx| workspace.open_paths(paths, ctx));
+    pub fn open_paths(
+        &self,
+        paths: &[PathBuf],
+        ctx: &mut ViewContext<Self>,
+    ) -> impl Future<Output = ()> {
+        let entries = self
+            .workspace
+            .update(ctx, |workspace, ctx| workspace.open_paths(paths, ctx));
+        let bg = ctx.background_executor().clone();
+        let tasks = paths
+            .iter()
+            .cloned()
+            .zip(entries.into_iter())
+            .map(|(path, entry)| {
+                ctx.spawn(
+                    bg.spawn(async move { path.is_file() }),
+                    |me, is_file, ctx| {
+                        if is_file {
+                            me.open_entry(entry, ctx)
+                        } else {
+                            None
+                        }
+                    },
+                )
+            })
+            .collect::<Vec<_>>();
+        async move {
+            for task in tasks {
+                if let Some(task) = task.await {
+                    task.await;
+                }
+            }
+        }
     }
 
     pub fn toggle_modal<V, F>(&mut self, ctx: &mut ViewContext<Self>, add_view: F)
@@ -193,16 +224,21 @@ impl WorkspaceView {
         }
     }
 
-    pub fn open_entry(&mut self, entry: (usize, Arc<Path>), ctx: &mut ViewContext<Self>) {
+    #[must_use]
+    pub fn open_entry(
+        &mut self,
+        entry: (usize, Arc<Path>),
+        ctx: &mut ViewContext<Self>,
+    ) -> Option<EntityTask<()>> {
         if self.loading_entries.contains(&entry) {
-            return;
+            return None;
         }
 
         if self
             .active_pane()
             .update(ctx, |pane, ctx| pane.activate_entry(entry.clone(), ctx))
         {
-            return;
+            return None;
         }
 
         self.loading_entries.insert(entry.clone());
@@ -210,10 +246,13 @@ impl WorkspaceView {
         match self.workspace.update(ctx, |workspace, ctx| {
             workspace.open_entry(entry.clone(), ctx)
         }) {
-            Err(error) => error!("{}", error),
+            Err(error) => {
+                error!("{}", error);
+                None
+            }
             Ok(item) => {
                 let settings = self.settings.clone();
-                ctx.spawn(item, move |me, item, ctx| {
+                Some(ctx.spawn(item, move |me, item, ctx| {
                     me.loading_entries.remove(&entry);
                     match item {
                         Ok(item) => {
@@ -224,8 +263,7 @@ impl WorkspaceView {
                             error!("{}", error);
                         }
                     }
-                })
-                .detach();
+                }))
             }
         }
     }
@@ -382,6 +420,7 @@ mod tests {
     use crate::{settings, test::temp_tree, workspace::WorkspaceHandle as _};
     use gpui::App;
     use serde_json::json;
+    use std::collections::HashSet;
 
     #[test]
     fn test_open_entry() {
@@ -408,13 +447,23 @@ mod tests {
             let pane = app.read(|ctx| workspace_view.read(ctx).active_pane().clone());
 
             // Open the first entry
-            workspace_view.update(&mut app, |w, ctx| w.open_entry(file1.clone(), ctx));
-            pane.condition(&app, |pane, _| pane.items().len() == 1)
+            workspace_view
+                .update(&mut app, |w, ctx| w.open_entry(file1.clone(), ctx))
+                .unwrap()
                 .await;
+            app.read(|ctx| {
+                let pane = pane.read(ctx);
+                assert_eq!(
+                    pane.active_item().unwrap().entry_id(ctx),
+                    Some(file1.clone())
+                );
+                assert_eq!(pane.items().len(), 1);
+            });
 
             // Open the second entry
-            workspace_view.update(&mut app, |w, ctx| w.open_entry(file2.clone(), ctx));
-            pane.condition(&app, |pane, _| pane.items().len() == 2)
+            workspace_view
+                .update(&mut app, |w, ctx| w.open_entry(file2.clone(), ctx))
+                .unwrap()
                 .await;
             app.read(|ctx| {
                 let pane = pane.read(ctx);
@@ -422,25 +471,107 @@ mod tests {
                     pane.active_item().unwrap().entry_id(ctx),
                     Some(file2.clone())
                 );
+                assert_eq!(pane.items().len(), 2);
             });
 
-            // Open the first entry again
-            workspace_view.update(&mut app, |w, ctx| w.open_entry(file1.clone(), ctx));
-            pane.condition(&app, move |pane, ctx| {
-                pane.active_item().unwrap().entry_id(ctx) == Some(file1.clone())
+            // Open the first entry again. The existing pane item is activated.
+            workspace_view.update(&mut app, |w, ctx| {
+                assert!(w.open_entry(file1.clone(), ctx).is_none())
+            });
+            app.read(|ctx| {
+                let pane = pane.read(ctx);
+                assert_eq!(
+                    pane.active_item().unwrap().entry_id(ctx),
+                    Some(file1.clone())
+                );
+                assert_eq!(pane.items().len(), 2);
+            });
+
+            // Open the third entry twice concurrently. Only one pane item is added.
+            workspace_view
+                .update(&mut app, |w, ctx| {
+                    let task = w.open_entry(file3.clone(), ctx).unwrap();
+                    assert!(w.open_entry(file3.clone(), ctx).is_none());
+                    task
+                })
+                .await;
+            app.read(|ctx| {
+                let pane = pane.read(ctx);
+                assert_eq!(
+                    pane.active_item().unwrap().entry_id(ctx),
+                    Some(file3.clone())
+                );
+                assert_eq!(pane.items().len(), 3);
+            });
+        });
+    }
+
+    #[test]
+    fn test_open_paths() {
+        App::test_async((), |mut app| async move {
+            let dir1 = temp_tree(json!({
+                "a.txt": "",
+            }));
+            let dir2 = temp_tree(json!({
+                "b.txt": "",
+            }));
+
+            let workspace = app.add_model(|ctx| Workspace::new(vec![dir1.path().into()], ctx));
+            let settings = settings::channel(&app.font_cache()).unwrap().1;
+            let (_, workspace_view) =
+                app.add_window(|ctx| WorkspaceView::new(workspace.clone(), settings, ctx));
+            app.read(|ctx| workspace.read(ctx).worktree_scans_complete(ctx))
+                .await;
+
+            // Open a file within an existing worktree.
+            app.update(|ctx| {
+                workspace_view.update(ctx, |view, ctx| {
+                    view.open_paths(&[dir1.path().join("a.txt")], ctx)
+                })
             })
             .await;
             app.read(|ctx| {
-                assert_eq!(pane.read(ctx).items().len(), 2);
+                workspace_view
+                    .read(ctx)
+                    .active_pane()
+                    .read(ctx)
+                    .active_item()
+                    .unwrap()
+                    .title(ctx)
+                    == "a.txt"
             });
 
-            // Open the third entry twice concurrently
-            workspace_view.update(&mut app, |w, ctx| {
-                w.open_entry(file3.clone(), ctx);
-                w.open_entry(file3.clone(), ctx);
+            // Open a file outside of any existing worktree.
+            app.update(|ctx| {
+                workspace_view.update(ctx, |view, ctx| {
+                    view.open_paths(&[dir2.path().join("b.txt")], ctx)
+                })
+            })
+            .await;
+            app.update(|ctx| {
+                let worktree_roots = workspace
+                    .read(ctx)
+                    .worktrees()
+                    .iter()
+                    .map(|w| w.read(ctx).abs_path())
+                    .collect::<HashSet<_>>();
+                assert_eq!(
+                    worktree_roots,
+                    vec![dir1.path(), &dir2.path().join("b.txt")]
+                        .into_iter()
+                        .collect(),
+                );
+            });
+            app.read(|ctx| {
+                workspace_view
+                    .read(ctx)
+                    .active_pane()
+                    .read(ctx)
+                    .active_item()
+                    .unwrap()
+                    .title(ctx)
+                    == "b.txt"
             });
-            pane.condition(&app, |pane, _| pane.items().len() == 3)
-                .await;
         });
     }
 
@@ -468,15 +599,16 @@ mod tests {
                 app.add_window(|ctx| WorkspaceView::new(workspace.clone(), settings, ctx));
             let pane_1 = app.read(|ctx| workspace_view.read(ctx).active_pane().clone());
 
-            workspace_view.update(&mut app, |w, ctx| w.open_entry(file1.clone(), ctx));
-            {
-                let file1 = file1.clone();
-                pane_1
-                    .condition(&app, move |pane, ctx| {
-                        pane.active_item().and_then(|i| i.entry_id(ctx)) == Some(file1.clone())
-                    })
-                    .await;
-            }
+            workspace_view
+                .update(&mut app, |w, ctx| w.open_entry(file1.clone(), ctx))
+                .unwrap()
+                .await;
+            app.read(|ctx| {
+                assert_eq!(
+                    pane_1.read(ctx).active_item().unwrap().entry_id(ctx),
+                    Some(file1.clone())
+                );
+            });
 
             app.dispatch_action(window_id, vec![pane_1.id()], "pane:split_right", ());
             app.update(|ctx| {

zed/src/worktree.rs 🔗

@@ -20,7 +20,7 @@ use smol::{channel::Sender, Timer};
 use std::{
     cmp,
     collections::{HashMap, HashSet},
-    ffi::{CStr, OsStr},
+    ffi::{CStr, OsStr, OsString},
     fmt, fs,
     future::Future,
     io::{self, Read, Write},
@@ -68,16 +68,13 @@ struct FileHandleState {
 impl Worktree {
     pub fn new(path: impl Into<Arc<Path>>, ctx: &mut ModelContext<Self>) -> Self {
         let abs_path = path.into();
-        let root_name = abs_path
-            .file_name()
-            .map_or(String::new(), |n| n.to_string_lossy().to_string() + "/");
         let (scan_state_tx, scan_state_rx) = smol::channel::unbounded();
         let id = ctx.model_id();
         let snapshot = Snapshot {
             id,
             scan_id: 0,
             abs_path,
-            root_name,
+            root_name: Default::default(),
             ignores: Default::default(),
             entries: Default::default(),
         };
@@ -163,6 +160,10 @@ impl Worktree {
         self.snapshot.clone()
     }
 
+    pub fn abs_path(&self) -> &Path {
+        self.snapshot.abs_path.as_ref()
+    }
+
     pub fn contains_abs_path(&self, path: &Path) -> bool {
         path.starts_with(&self.snapshot.abs_path)
     }
@@ -172,7 +173,11 @@ impl Worktree {
         path: &Path,
         ctx: &AppContext,
     ) -> impl Future<Output = Result<History>> {
-        let abs_path = self.snapshot.abs_path.join(path);
+        let abs_path = if path.file_name().is_some() {
+            self.snapshot.abs_path.join(path)
+        } else {
+            self.snapshot.abs_path.to_path_buf()
+        };
         ctx.background_executor().spawn(async move {
             let mut file = std::fs::File::open(&abs_path)?;
             let mut base_text = String::new();
@@ -261,8 +266,8 @@ impl Snapshot {
         self.entry_for_path("").unwrap()
     }
 
-    /// Returns the filename of the snapshot's root directory,
-    /// with a trailing slash.
+    /// Returns the filename of the snapshot's root, plus a trailing slash if the snapshot's root is
+    /// a directory.
     pub fn root_name(&self) -> &str {
         &self.root_name
     }
@@ -381,10 +386,22 @@ impl fmt::Debug for Snapshot {
 }
 
 impl FileHandle {
+    /// Returns this file's path relative to the root of its worktree.
     pub fn path(&self) -> Arc<Path> {
         self.state.lock().path.clone()
     }
 
+    /// Returns the last component of this handle's absolute path. If this handle refers to the root
+    /// of its worktree, then this method will return the name of the worktree itself.
+    pub fn file_name<'a>(&'a self, ctx: &'a AppContext) -> Option<OsString> {
+        self.state
+            .lock()
+            .path
+            .file_name()
+            .or_else(|| self.worktree.read(ctx).abs_path().file_name())
+            .map(Into::into)
+    }
+
     pub fn is_deleted(&self) -> bool {
         self.state.lock().is_deleted
     }
@@ -461,6 +478,10 @@ impl Entry {
     fn is_dir(&self) -> bool {
         matches!(self.kind, EntryKind::Dir | EntryKind::PendingDir)
     }
+
+    fn is_file(&self) -> bool {
+        matches!(self.kind, EntryKind::File(_))
+    }
 }
 
 impl sum_tree::Item for Entry {
@@ -469,7 +490,7 @@ impl sum_tree::Item for Entry {
     fn summary(&self) -> Self::Summary {
         let file_count;
         let visible_file_count;
-        if matches!(self.kind, EntryKind::File(_)) {
+        if self.is_file() {
             file_count = 1;
             if self.is_ignored {
                 visible_file_count = 0;
@@ -611,14 +632,8 @@ impl BackgroundScanner {
         notify: Sender<ScanState>,
         worktree_id: usize,
     ) -> Self {
-        let root_char_bag = snapshot
-            .lock()
-            .root_name
-            .chars()
-            .map(|c| c.to_ascii_lowercase())
-            .collect();
         let mut scanner = Self {
-            root_char_bag,
+            root_char_bag: Default::default(),
             snapshot,
             notify,
             handles,
@@ -679,7 +694,7 @@ impl BackgroundScanner {
         });
     }
 
-    fn scan_dirs(&self) -> io::Result<()> {
+    fn scan_dirs(&mut self) -> io::Result<()> {
         self.snapshot.lock().scan_id += 1;
 
         let path: Arc<Path> = Arc::from(Path::new(""));
@@ -687,19 +702,29 @@ impl BackgroundScanner {
         let metadata = fs::metadata(&abs_path)?;
         let inode = metadata.ino();
         let is_symlink = fs::symlink_metadata(&abs_path)?.file_type().is_symlink();
+        let is_dir = metadata.file_type().is_dir();
+
+        // After determining whether the root entry is a file or a directory, populate the
+        // snapshot's "root name", which will be used for the purpose of fuzzy matching.
+        let mut root_name = abs_path
+            .file_name()
+            .map_or(String::new(), |f| f.to_string_lossy().to_string());
+        if is_dir {
+            root_name.push('/');
+        }
+        self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
+        self.snapshot.lock().root_name = root_name;
 
-        if metadata.file_type().is_dir() {
-            let dir_entry = Entry {
+        if is_dir {
+            self.snapshot.lock().insert_entry(Entry {
                 kind: EntryKind::PendingDir,
                 path: path.clone(),
                 inode,
                 is_symlink,
                 is_ignored: false,
-            };
-            self.snapshot.lock().insert_entry(dir_entry);
+            });
 
             let (tx, rx) = crossbeam_channel::unbounded();
-
             tx.send(ScanJob {
                 abs_path: abs_path.to_path_buf(),
                 path,
@@ -1521,7 +1546,7 @@ mod tests {
             scanner.snapshot().check_invariants();
 
             let (notify_tx, _notify_rx) = smol::channel::unbounded();
-            let new_scanner = BackgroundScanner::new(
+            let mut new_scanner = BackgroundScanner::new(
                 Arc::new(Mutex::new(Snapshot {
                     id: 0,
                     scan_id: 0,
@@ -1691,7 +1716,7 @@ mod tests {
             let mut files = self.files(0);
             let mut visible_files = self.visible_files(0);
             for entry in self.entries.cursor::<(), ()>() {
-                if matches!(entry.kind, EntryKind::File(_)) {
+                if entry.is_file() {
                     assert_eq!(files.next().unwrap().inode(), entry.inode);
                     if !entry.is_ignored {
                         assert_eq!(visible_files.next().unwrap().inode(), entry.inode);

zed/src/worktree/fuzzy.rs 🔗

@@ -24,6 +24,7 @@ pub struct PathMatch {
     pub positions: Vec<usize>,
     pub tree_id: usize,
     pub path: Arc<Path>,
+    pub include_root_name: bool,
 }
 
 impl PartialEq for PathMatch {
@@ -84,7 +85,7 @@ where
 
     pool.scoped(|scope| {
         for (segment_idx, results) in segment_results.iter_mut().enumerate() {
-            let trees = snapshots.clone();
+            let snapshots = snapshots.clone();
             let cancel_flag = &cancel_flag;
             scope.execute(move || {
                 let segment_start = segment_idx * segment_size;
@@ -99,12 +100,14 @@ where
                 let mut best_position_matrix = Vec::new();
 
                 let mut tree_start = 0;
-                for snapshot in trees {
+                for snapshot in snapshots {
                     let tree_end = if include_ignored {
                         tree_start + snapshot.file_count()
                     } else {
                         tree_start + snapshot.visible_file_count()
                     };
+
+                    let include_root_name = include_root_name || snapshot.root_entry().is_file();
                     if tree_start < segment_end && segment_start < tree_end {
                         let start = max(tree_start, segment_start) - tree_start;
                         let end = min(tree_end, segment_end) - tree_start;
@@ -246,6 +249,7 @@ fn match_single_tree_paths<'a>(
                 path: candidate.path.clone(),
                 score,
                 positions: match_positions.clone(),
+                include_root_name,
             };
             if let Err(i) = results.binary_search_by(|m| mat.cmp(&m)) {
                 if results.len() < max_results {