new path picker (#11015)

Conrad Irwin , Nathan , and Bennet created

Still TODO:

* Disable the new save-as for local projects
* Wire up sending the new path to the remote server

Release Notes:

- Added the ability to "Save-as" in remote projects

---------

Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Bennet <bennetbo@gmx.de>

Change summary

Cargo.lock                                          |   1 
README.md                                           |  51 -
crates/collab/src/tests/dev_server_tests.rs         |  32 +
crates/collab/src/tests/integration_tests.rs        |   7 
crates/diagnostics/src/diagnostics.rs               |   3 
crates/diagnostics/src/diagnostics_tests.rs         |   5 
crates/editor/src/items.rs                          |  11 
crates/file_finder/Cargo.toml                       |   1 
crates/file_finder/src/file_finder.rs               |   5 
crates/file_finder/src/new_path_prompt.rs           | 463 +++++++++++++++
crates/gpui/src/platform.rs                         |   6 
crates/gpui/src/platform/mac/window.rs              |   9 
crates/gpui/src/platform/windows/window.rs          |   2 
crates/gpui/src/style.rs                            |   7 
crates/picker/src/picker.rs                         |  30 
crates/project/src/project.rs                       |  70 +
crates/project/src/project_tests.rs                 |   7 
crates/project_panel/src/project_panel.rs           |   2 
crates/recent_projects/src/remote_projects.rs       |   2 
crates/rpc/proto/zed.proto                          |   6 
crates/search/src/project_search.rs                 |   6 
crates/ui/src/components/label/highlighted_label.rs |  65 +
crates/workspace/src/item.rs                        |  11 
crates/workspace/src/notifications.rs               |   2 
crates/workspace/src/pane.rs                        |  14 
crates/workspace/src/workspace.rs                   |  59 +
crates/worktree/src/worktree.rs                     |  47 +
27 files changed, 775 insertions(+), 149 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3815,6 +3815,7 @@ dependencies = [
  "ctor",
  "editor",
  "env_logger",
+ "futures 0.3.28",
  "fuzzy",
  "gpui",
  "itertools 0.11.0",

README.md 🔗

@@ -1,51 +0,0 @@
-# Zed
-
-[![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml)
-
-Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter).
-
-## Installation
-
-You can [download](https://zed.dev/download) Zed today for macOS (v10.15+).
-
-Support for additional platforms is on our [roadmap](https://zed.dev/roadmap):
-
-- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/7015))
-- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394))
-- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396))
-
-For macOS users, you can also install Zed using [Homebrew](https://brew.sh/):
-
-```sh
-brew install --cask zed
-```
-
-Alternatively, to install the Preview release:
-
-```sh
-brew tap homebrew/cask-versions
-brew install zed-preview
-```
-
-## Developing Zed
-
-- [Building Zed for macOS](./docs/src/developing_zed__building_zed_macos.md)
-- [Building Zed for Linux](./docs/src/developing_zed__building_zed_linux.md)
-- [Building Zed for Windows](./docs/src/developing_zed__building_zed_windows.md)
-- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md)
-
-## Contributing
-
-See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways you can contribute to Zed.
-
-Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open roles.
-
-## Licensing
-
-License information for third party dependencies must be correctly provided for CI to pass.
-
-We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following:
-
-- Is it showing a `no license specified` error for a crate you've created? If so, add `publish = false` under `[package]` in your crate's Cargo.toml.
-- Is the error `failed to satisfy license requirements` for a dependency? If so, first determine what license the project has and whether this system is sufficient to comply with this license's requirements. If you're unsure, ask a lawyer. Once you've verified that this system is acceptable add the license's SPDX identifier to the `accepted` array in `script/licenses/zed-licenses.toml`.
-- Is `cargo-about` unable to find the license for a dependency? If so, add a clarification field at the end of `script/licenses/zed-licenses.toml`, as specified in the [cargo-about book](https://embarkstudios.github.io/cargo-about/cli/generate/config.html#crate-configuration).

crates/collab/src/tests/dev_server_tests.rs 🔗

@@ -366,3 +366,35 @@ async fn test_create_remote_project_path_validation(
         ErrorCode::RemoteProjectPathDoesNotExist
     ));
 }
+
+#[gpui::test]
+async fn test_save_as_remote(cx1: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) {
+    let (server, client1) = TestServer::start1(cx1).await;
+
+    // Creating a project with a path that does exist should not fail
+    let (dev_server, remote_workspace) =
+        create_remote_project(&server, client1.app_state.clone(), cx1, cx2).await;
+
+    let mut cx = VisualTestContext::from_window(remote_workspace.into(), cx1);
+
+    cx.simulate_keystrokes("cmd-p 1 enter");
+    cx.simulate_keystrokes("cmd-shift-s");
+    cx.simulate_input("2.txt");
+    cx.simulate_keystrokes("enter");
+
+    cx.executor().run_until_parked();
+
+    let title = remote_workspace
+        .update(&mut cx, |ws, cx| {
+            ws.active_item(cx).unwrap().tab_description(0, &cx).unwrap()
+        })
+        .unwrap();
+
+    assert_eq!(title, "2.txt");
+
+    let path = Path::new("/remote/2.txt");
+    assert_eq!(
+        dev_server.fs().load(&path).await.unwrap(),
+        "remote\nremote\nremote"
+    );
+}

crates/collab/src/tests/integration_tests.rs 🔗

@@ -2468,7 +2468,12 @@ async fn test_propagate_saves_and_fs_changes(
     });
     project_a
         .update(cx_a, |project, cx| {
-            project.save_buffer_as(new_buffer_a.clone(), "/a/file3.rs".into(), cx)
+            let path = ProjectPath {
+                path: Arc::from(Path::new("file3.rs")),
+                worktree_id: worktree_a.read(cx).id(),
+            };
+
+            project.save_buffer_as(new_buffer_a.clone(), path, cx)
         })
         .await
         .unwrap();

crates/diagnostics/src/diagnostics.rs 🔗

@@ -36,7 +36,6 @@ use std::{
     cmp::Ordering,
     mem,
     ops::Range,
-    path::PathBuf,
 };
 use theme::ActiveTheme;
 pub use toolbar_controls::ToolbarControls;
@@ -740,7 +739,7 @@ impl Item for ProjectDiagnosticsEditor {
     fn save_as(
         &mut self,
         _: Model<Project>,
-        _: PathBuf,
+        _: ProjectPath,
         _: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
         unreachable!()

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -13,7 +13,10 @@ use project::FakeFs;
 use rand::{rngs::StdRng, seq::IteratorRandom as _, Rng};
 use serde_json::json;
 use settings::SettingsStore;
-use std::{env, path::Path};
+use std::{
+    env,
+    path::{Path, PathBuf},
+};
 use unindent::Unindent as _;
 use util::{post_inc, RandomCharIter};
 

crates/editor/src/items.rs 🔗

@@ -26,7 +26,7 @@ use std::{
     cmp::{self, Ordering},
     iter,
     ops::Range,
-    path::{Path, PathBuf},
+    path::Path,
     sync::Arc,
 };
 use text::{BufferId, Selection};
@@ -750,7 +750,7 @@ impl Item for Editor {
     fn save_as(
         &mut self,
         project: Model<Project>,
-        abs_path: PathBuf,
+        path: ProjectPath,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
         let buffer = self
@@ -759,14 +759,13 @@ impl Item for Editor {
             .as_singleton()
             .expect("cannot call save_as on an excerpt list");
 
-        let file_extension = abs_path
+        let file_extension = path
+            .path
             .extension()
             .map(|a| a.to_string_lossy().to_string());
         self.report_editor_event("save", file_extension, cx);
 
-        project.update(cx, |project, cx| {
-            project.save_buffer_as(buffer, abs_path, cx)
-        })
+        project.update(cx, |project, cx| project.save_buffer_as(buffer, path, cx))
     }
 
     fn reload(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {

crates/file_finder/Cargo.toml 🔗

@@ -16,6 +16,7 @@ doctest = false
 anyhow.workspace = true
 collections.workspace = true
 editor.workspace = true
+futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 itertools = "0.11"

crates/file_finder/src/file_finder.rs 🔗

@@ -1,6 +1,8 @@
 #[cfg(test)]
 mod file_finder_tests;
 
+mod new_path_prompt;
+
 use collections::{HashMap, HashSet};
 use editor::{scroll::Autoscroll, Bias, Editor};
 use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
@@ -10,6 +12,7 @@ use gpui::{
     ViewContext, VisualContext, WeakView,
 };
 use itertools::Itertools;
+use new_path_prompt::NewPathPrompt;
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
 use settings::Settings;
@@ -37,6 +40,7 @@ pub struct FileFinder {
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(FileFinder::register).detach();
+    cx.observe_new_views(NewPathPrompt::register).detach();
 }
 
 impl FileFinder {
@@ -454,6 +458,7 @@ impl FileFinderDelegate {
                         .root_entry()
                         .map_or(false, |entry| entry.is_ignored),
                     include_root_name,
+                    directories_only: false,
                 }
             })
             .collect::<Vec<_>>();

crates/file_finder/src/new_path_prompt.rs 🔗

@@ -0,0 +1,463 @@
+use futures::channel::oneshot;
+use fuzzy::PathMatch;
+use gpui::{HighlightStyle, Model, StyledText};
+use picker::{Picker, PickerDelegate};
+use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
+use std::{
+    path::PathBuf,
+    sync::{
+        atomic::{self, AtomicBool},
+        Arc,
+    },
+};
+use ui::{highlight_ranges, prelude::*, LabelLike, ListItemSpacing};
+use ui::{ListItem, ViewContext};
+use util::ResultExt;
+use workspace::Workspace;
+
+pub(crate) struct NewPathPrompt;
+
+#[derive(Debug, Clone)]
+struct Match {
+    path_match: Option<PathMatch>,
+    suffix: Option<String>,
+}
+
+impl Match {
+    fn entry<'a>(&'a self, project: &'a Project, cx: &'a WindowContext) -> Option<&'a Entry> {
+        if let Some(suffix) = &self.suffix {
+            let (worktree, path) = if let Some(path_match) = &self.path_match {
+                (
+                    project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
+                    path_match.path.join(suffix),
+                )
+            } else {
+                (project.worktrees().next(), PathBuf::from(suffix))
+            };
+
+            worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
+        } else if let Some(path_match) = &self.path_match {
+            let worktree =
+                project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
+            worktree.read(cx).entry_for_path(path_match.path.as_ref())
+        } else {
+            None
+        }
+    }
+
+    fn is_dir(&self, project: &Project, cx: &WindowContext) -> bool {
+        self.entry(project, cx).is_some_and(|e| e.is_dir())
+            || self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
+    }
+
+    fn relative_path(&self) -> String {
+        if let Some(path_match) = &self.path_match {
+            if let Some(suffix) = &self.suffix {
+                format!(
+                    "{}/{}",
+                    path_match.path.to_string_lossy(),
+                    suffix.trim_end_matches('/')
+                )
+            } else {
+                path_match.path.to_string_lossy().to_string()
+            }
+        } else if let Some(suffix) = &self.suffix {
+            suffix.trim_end_matches('/').to_string()
+        } else {
+            "".to_string()
+        }
+    }
+
+    fn project_path(&self, project: &Project, cx: &WindowContext) -> Option<ProjectPath> {
+        let worktree_id = if let Some(path_match) = &self.path_match {
+            WorktreeId::from_usize(path_match.worktree_id)
+        } else {
+            project.worktrees().next()?.read(cx).id()
+        };
+
+        let path = PathBuf::from(self.relative_path());
+
+        Some(ProjectPath {
+            worktree_id,
+            path: Arc::from(path),
+        })
+    }
+
+    fn existing_prefix(&self, project: &Project, cx: &WindowContext) -> Option<PathBuf> {
+        let worktree = project.worktrees().next()?.read(cx);
+        let mut prefix = PathBuf::new();
+        let parts = self.suffix.as_ref()?.split('/');
+        for part in parts {
+            if worktree.entry_for_path(prefix.join(&part)).is_none() {
+                return Some(prefix);
+            }
+            prefix = prefix.join(part);
+        }
+
+        None
+    }
+
+    fn styled_text(&self, project: &Project, cx: &WindowContext) -> StyledText {
+        let mut text = "./".to_string();
+        let mut highlights = Vec::new();
+        let mut offset = text.as_bytes().len();
+
+        let separator = '/';
+        let dir_indicator = "[…]";
+
+        if let Some(path_match) = &self.path_match {
+            text.push_str(&path_match.path.to_string_lossy());
+            for (range, style) in highlight_ranges(
+                &path_match.path.to_string_lossy(),
+                &path_match.positions,
+                gpui::HighlightStyle::color(Color::Accent.color(cx)),
+            ) {
+                highlights.push((range.start + offset..range.end + offset, style))
+            }
+            text.push(separator);
+            offset = text.as_bytes().len();
+
+            if let Some(suffix) = &self.suffix {
+                text.push_str(suffix);
+                let entry = self.entry(project, cx);
+                let color = if let Some(entry) = entry {
+                    if entry.is_dir() {
+                        Color::Accent
+                    } else {
+                        Color::Conflict
+                    }
+                } else {
+                    Color::Created
+                };
+                highlights.push((
+                    offset..offset + suffix.as_bytes().len(),
+                    HighlightStyle::color(color.color(cx)),
+                ));
+                offset += suffix.as_bytes().len();
+                if entry.is_some_and(|e| e.is_dir()) {
+                    text.push(separator);
+                    offset += separator.len_utf8();
+
+                    text.push_str(dir_indicator);
+                    highlights.push((
+                        offset..offset + dir_indicator.bytes().len(),
+                        HighlightStyle::color(Color::Muted.color(cx)),
+                    ));
+                }
+            } else {
+                text.push_str(dir_indicator);
+                highlights.push((
+                    offset..offset + dir_indicator.bytes().len(),
+                    HighlightStyle::color(Color::Muted.color(cx)),
+                ))
+            }
+        } else if let Some(suffix) = &self.suffix {
+            text.push_str(suffix);
+            let existing_prefix_len = self
+                .existing_prefix(project, cx)
+                .map(|prefix| prefix.to_string_lossy().as_bytes().len())
+                .unwrap_or(0);
+
+            if existing_prefix_len > 0 {
+                highlights.push((
+                    offset..offset + existing_prefix_len,
+                    HighlightStyle::color(Color::Accent.color(cx)),
+                ));
+            }
+            highlights.push((
+                offset + existing_prefix_len..offset + suffix.as_bytes().len(),
+                HighlightStyle::color(if self.entry(project, cx).is_some() {
+                    Color::Conflict.color(cx)
+                } else {
+                    Color::Created.color(cx)
+                }),
+            ));
+            offset += suffix.as_bytes().len();
+            if suffix.ends_with('/') {
+                text.push_str(dir_indicator);
+                highlights.push((
+                    offset..offset + dir_indicator.bytes().len(),
+                    HighlightStyle::color(Color::Muted.color(cx)),
+                ));
+            }
+        }
+
+        StyledText::new(text).with_highlights(&cx.text_style().clone(), highlights)
+    }
+}
+
+pub struct NewPathDelegate {
+    project: Model<Project>,
+    tx: Option<oneshot::Sender<Option<ProjectPath>>>,
+    selected_index: usize,
+    matches: Vec<Match>,
+    last_selected_dir: Option<String>,
+    cancel_flag: Arc<AtomicBool>,
+    should_dismiss: bool,
+}
+
+impl NewPathPrompt {
+    pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+        if workspace.project().read(cx).is_remote() {
+            workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
+                let (tx, rx) = futures::channel::oneshot::channel();
+                Self::prompt_for_new_path(workspace, tx, cx);
+                rx
+            }));
+        }
+    }
+
+    fn prompt_for_new_path(
+        workspace: &mut Workspace,
+        tx: oneshot::Sender<Option<ProjectPath>>,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let project = workspace.project().clone();
+        workspace.toggle_modal(cx, |cx| {
+            let delegate = NewPathDelegate {
+                project,
+                tx: Some(tx),
+                selected_index: 0,
+                matches: vec![],
+                cancel_flag: Arc::new(AtomicBool::new(false)),
+                last_selected_dir: None,
+                should_dismiss: true,
+            };
+
+            Picker::uniform_list(delegate, cx).width(rems(34.))
+        });
+    }
+}
+
+impl PickerDelegate for NewPathDelegate {
+    type ListItem = ui::ListItem;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<picker::Picker<Self>>) {
+        self.selected_index = ix;
+        cx.notify();
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<picker::Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let query = query.trim().trim_start_matches('/');
+        let (dir, suffix) = if let Some(index) = query.rfind('/') {
+            let suffix = if index + 1 < query.len() {
+                Some(query[index + 1..].to_string())
+            } else {
+                None
+            };
+            (query[0..index].to_string(), suffix)
+        } else {
+            (query.to_string(), None)
+        };
+
+        let worktrees = self
+            .project
+            .read(cx)
+            .visible_worktrees(cx)
+            .collect::<Vec<_>>();
+        let include_root_name = worktrees.len() > 1;
+        let candidate_sets = worktrees
+            .into_iter()
+            .map(|worktree| {
+                let worktree = worktree.read(cx);
+                PathMatchCandidateSet {
+                    snapshot: worktree.snapshot(),
+                    include_ignored: worktree
+                        .root_entry()
+                        .map_or(false, |entry| entry.is_ignored),
+                    include_root_name,
+                    directories_only: true,
+                }
+            })
+            .collect::<Vec<_>>();
+
+        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
+        self.cancel_flag = Arc::new(AtomicBool::new(false));
+
+        let cancel_flag = self.cancel_flag.clone();
+        let query = query.to_string();
+        let prefix = dir.clone();
+        cx.spawn(|picker, mut cx| async move {
+            let matches = fuzzy::match_path_sets(
+                candidate_sets.as_slice(),
+                &dir,
+                None,
+                false,
+                100,
+                &cancel_flag,
+                cx.background_executor().clone(),
+            )
+            .await;
+            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
+            if did_cancel {
+                return;
+            }
+            picker
+                .update(&mut cx, |picker, cx| {
+                    picker
+                        .delegate
+                        .set_search_matches(query, prefix, suffix, matches, cx)
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
+        let m = self.matches.get(self.selected_index)?;
+        if m.is_dir(self.project.read(cx), cx) {
+            let path = m.relative_path();
+            self.last_selected_dir = Some(path.clone());
+            Some(format!("{}/", path))
+        } else {
+            None
+        }
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
+        let Some(m) = self.matches.get(self.selected_index) else {
+            return;
+        };
+
+        let exists = m.entry(self.project.read(cx), cx).is_some();
+        if exists {
+            self.should_dismiss = false;
+            let answer = cx.prompt(
+                gpui::PromptLevel::Destructive,
+                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
+                Some(
+                    "A file or folder with the same name already eixsts. Replacing it will overwrite its current contents.",
+                ),
+                &["Replace", "Cancel"],
+            );
+            let m = m.clone();
+            cx.spawn(|picker, mut cx| async move {
+                let answer = answer.await.ok();
+                picker
+                    .update(&mut cx, |picker, cx| {
+                        picker.delegate.should_dismiss = true;
+                        if answer != Some(0) {
+                            return;
+                        }
+                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
+                            if let Some(tx) = picker.delegate.tx.take() {
+                                tx.send(Some(path)).ok();
+                            }
+                        }
+                        cx.emit(gpui::DismissEvent);
+                    })
+                    .ok();
+            })
+            .detach();
+            return;
+        }
+
+        if let Some(path) = m.project_path(self.project.read(cx), cx) {
+            if let Some(tx) = self.tx.take() {
+                tx.send(Some(path)).ok();
+            }
+        }
+        cx.emit(gpui::DismissEvent);
+    }
+
+    fn should_dismiss(&self) -> bool {
+        self.should_dismiss
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
+        if let Some(tx) = self.tx.take() {
+            tx.send(None).ok();
+        }
+        cx.emit(gpui::DismissEvent)
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        cx: &mut ViewContext<picker::Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let m = self.matches.get(ix)?;
+
+        Some(
+            ListItem::new(ix)
+                .spacing(ListItemSpacing::Sparse)
+                .inset(true)
+                .selected(selected)
+                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))),
+        )
+    }
+
+    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
+        "Type a path...".into()
+    }
+
+    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        Arc::from("[directory/]filename.ext")
+    }
+}
+
+impl NewPathDelegate {
+    fn set_search_matches(
+        &mut self,
+        query: String,
+        prefix: String,
+        suffix: Option<String>,
+        matches: Vec<PathMatch>,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) {
+        cx.notify();
+        if query.is_empty() {
+            self.matches = vec![];
+            return;
+        }
+
+        let mut directory_exists = false;
+
+        self.matches = matches
+            .into_iter()
+            .map(|m| {
+                if m.path.as_ref().to_string_lossy() == prefix {
+                    directory_exists = true
+                }
+                Match {
+                    path_match: Some(m),
+                    suffix: suffix.clone(),
+                }
+            })
+            .collect();
+
+        if !directory_exists {
+            if suffix.is_none()
+                || self
+                    .last_selected_dir
+                    .as_ref()
+                    .is_some_and(|d| query.starts_with(d))
+            {
+                self.matches.insert(
+                    0,
+                    Match {
+                        path_match: None,
+                        suffix: Some(query.clone()),
+                    },
+                )
+            } else {
+                self.matches.push(Match {
+                    path_match: None,
+                    suffix: Some(query.clone()),
+                })
+            }
+        }
+    }
+}

crates/gpui/src/platform.rs 🔗

@@ -693,7 +693,7 @@ pub struct PathPromptOptions {
 }
 
 /// What kind of prompt styling to show
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, Debug, PartialEq)]
 pub enum PromptLevel {
     /// A prompt that is shown when the user should be notified of something
     Info,
@@ -703,6 +703,10 @@ pub enum PromptLevel {
 
     /// A prompt that is shown when a critical problem has occurred
     Critical,
+
+    /// A prompt that is shown when asking the user to confirm a potentially destructive action
+    /// (overwriting a file for example)
+    Destructive,
 }
 
 /// The style of the cursor (pointer)

crates/gpui/src/platform/mac/window.rs 🔗

@@ -904,7 +904,7 @@ impl PlatformWindow for MacWindow {
             let alert_style = match level {
                 PromptLevel::Info => 1,
                 PromptLevel::Warning => 0,
-                PromptLevel::Critical => 2,
+                PromptLevel::Critical | PromptLevel::Destructive => 2,
             };
             let _: () = msg_send![alert, setAlertStyle: alert_style];
             let _: () = msg_send![alert, setMessageText: ns_string(msg)];
@@ -919,10 +919,17 @@ impl PlatformWindow for MacWindow {
             {
                 let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)];
                 let _: () = msg_send![button, setTag: ix as NSInteger];
+                if level == PromptLevel::Destructive && answer != &"Cancel" {
+                    let _: () = msg_send![button, setHasDestructiveAction: YES];
+                }
             }
             if let Some((ix, answer)) = latest_non_cancel_label {
                 let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)];
                 let _: () = msg_send![button, setTag: ix as NSInteger];
+                let _: () = msg_send![button, setHasDestructiveAction: YES];
+                if level == PromptLevel::Destructive {
+                    let _: () = msg_send![button, setHasDestructiveAction: YES];
+                }
             }
 
             let (done_tx, done_rx) = oneshot::channel();

crates/gpui/src/platform/windows/window.rs 🔗

@@ -1455,7 +1455,7 @@ impl PlatformWindow for WindowsWindow {
                             title = windows::core::w!("Warning");
                             main_icon = TD_WARNING_ICON;
                         }
-                        crate::PromptLevel::Critical => {
+                        crate::PromptLevel::Critical | crate::PromptLevel::Destructive => {
                             title = windows::core::w!("Critical");
                             main_icon = TD_ERROR_ICON;
                         }

crates/gpui/src/style.rs 🔗

@@ -628,6 +628,13 @@ impl From<&TextStyle> for HighlightStyle {
 }
 
 impl HighlightStyle {
+    /// Create a highlight style with just a color
+    pub fn color(color: Hsla) -> Self {
+        Self {
+            color: Some(color),
+            ..Default::default()
+        }
+    }
     /// Blend this highlight style with another.
     /// Non-continuous properties, like font_weight and font_style, are overwritten.
     pub fn highlight(&mut self, other: HighlightStyle) {

crates/picker/src/picker.rs 🔗

@@ -79,11 +79,18 @@ pub trait PickerDelegate: Sized + 'static {
         false
     }
 
+    fn confirm_update_query(&mut self, _cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
+        None
+    }
+
     fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
     /// Instead of interacting with currently selected entry, treats editor input literally,
     /// performing some kind of action on it.
     fn confirm_input(&mut self, _secondary: bool, _: &mut ViewContext<Picker<Self>>) {}
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
+    fn should_dismiss(&self) -> bool {
+        true
+    }
     fn selected_as_query(&self) -> Option<String> {
         None
     }
@@ -267,8 +274,10 @@ impl<D: PickerDelegate> Picker<D> {
     }
 
     pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
-        self.delegate.dismissed(cx);
-        cx.emit(DismissEvent);
+        if self.delegate.should_dismiss() {
+            self.delegate.dismissed(cx);
+            cx.emit(DismissEvent);
+        }
     }
 
     fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
@@ -280,7 +289,7 @@ impl<D: PickerDelegate> Picker<D> {
             self.confirm_on_update = Some(false)
         } else {
             self.pending_update_matches.take();
-            self.delegate.confirm(false, cx);
+            self.do_confirm(false, cx);
         }
     }
 
@@ -292,7 +301,7 @@ impl<D: PickerDelegate> Picker<D> {
         {
             self.confirm_on_update = Some(true)
         } else {
-            self.delegate.confirm(true, cx);
+            self.do_confirm(true, cx);
         }
     }
 
@@ -311,7 +320,16 @@ impl<D: PickerDelegate> Picker<D> {
         cx.stop_propagation();
         cx.prevent_default();
         self.delegate.set_selected_index(ix, cx);
-        self.delegate.confirm(secondary, cx);
+        self.do_confirm(secondary, cx)
+    }
+
+    fn do_confirm(&mut self, secondary: bool, cx: &mut ViewContext<Self>) {
+        if let Some(update_query) = self.delegate.confirm_update_query(cx) {
+            self.set_query(update_query, cx);
+            self.delegate.set_selected_index(0, cx);
+        } else {
+            self.delegate.confirm(secondary, cx)
+        }
     }
 
     fn on_input_editor_event(
@@ -385,7 +403,7 @@ impl<D: PickerDelegate> Picker<D> {
         self.scroll_to_item_index(index);
         self.pending_update_matches = None;
         if let Some(secondary) = self.confirm_on_update.take() {
-            self.delegate.confirm(secondary, cx);
+            self.do_confirm(secondary, cx);
         }
         cx.notify();
     }

crates/project/src/project.rs 🔗

@@ -32,6 +32,7 @@ use futures::{
     stream::FuturesUnordered,
     AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
 };
+use fuzzy::CharBag;
 use git::{blame::Blame, repository::GitRepository};
 use globset::{Glob, GlobSet, GlobSetBuilder};
 use gpui::{
@@ -370,6 +371,22 @@ pub struct ProjectPath {
     pub path: Arc<Path>,
 }
 
+impl ProjectPath {
+    pub fn from_proto(p: proto::ProjectPath) -> Self {
+        Self {
+            worktree_id: WorktreeId::from_proto(p.worktree_id),
+            path: Arc::from(PathBuf::from(p.path)),
+        }
+    }
+
+    pub fn to_proto(&self) -> proto::ProjectPath {
+        proto::ProjectPath {
+            worktree_id: self.worktree_id.to_proto(),
+            path: self.path.to_string_lossy().to_string(),
+        }
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct InlayHint {
     pub position: language::Anchor,
@@ -2189,33 +2206,37 @@ impl Project {
         let path = file.path.clone();
         worktree.update(cx, |worktree, cx| match worktree {
             Worktree::Local(worktree) => worktree.save_buffer(buffer, path, false, cx),
-            Worktree::Remote(worktree) => worktree.save_buffer(buffer, cx),
+            Worktree::Remote(worktree) => worktree.save_buffer(buffer, None, cx),
         })
     }
 
     pub fn save_buffer_as(
         &mut self,
         buffer: Model<Buffer>,
-        abs_path: PathBuf,
+        path: ProjectPath,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
-        let worktree_task = self.find_or_create_local_worktree(&abs_path, true, cx);
         let old_file = File::from_dyn(buffer.read(cx).file())
             .filter(|f| f.is_local())
             .cloned();
+        let Some(worktree) = self.worktree_for_id(path.worktree_id, cx) else {
+            return Task::ready(Err(anyhow!("worktree does not exist")));
+        };
+
         cx.spawn(move |this, mut cx| async move {
             if let Some(old_file) = &old_file {
                 this.update(&mut cx, |this, cx| {
                     this.unregister_buffer_from_language_servers(&buffer, old_file, cx);
                 })?;
             }
-            let (worktree, path) = worktree_task.await?;
             worktree
                 .update(&mut cx, |worktree, cx| match worktree {
                     Worktree::Local(worktree) => {
-                        worktree.save_buffer(buffer.clone(), path.into(), true, cx)
+                        worktree.save_buffer(buffer.clone(), path.path, true, cx)
+                    }
+                    Worktree::Remote(worktree) => {
+                        worktree.save_buffer(buffer.clone(), Some(path.to_proto()), cx)
                     }
-                    Worktree::Remote(_) => panic!("cannot remote buffers as new files"),
                 })?
                 .await?;
 
@@ -8676,8 +8697,17 @@ impl Project {
             .await?;
         let buffer_id = buffer.update(&mut cx, |buffer, _| buffer.remote_id())?;
 
-        this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx))?
+        if let Some(new_path) = envelope.payload.new_path {
+            let new_path = ProjectPath::from_proto(new_path);
+            this.update(&mut cx, |this, cx| {
+                this.save_buffer_as(buffer.clone(), new_path, cx)
+            })?
             .await?;
+        } else {
+            this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx))?
+                .await?;
+        }
+
         buffer.update(&mut cx, |buffer, _| proto::BufferSaved {
             project_id,
             buffer_id: buffer_id.into(),
@@ -10414,6 +10444,7 @@ pub struct PathMatchCandidateSet {
     pub snapshot: Snapshot,
     pub include_ignored: bool,
     pub include_root_name: bool,
+    pub directories_only: bool,
 }
 
 impl<'a> fuzzy::PathMatchCandidateSet<'a> for PathMatchCandidateSet {
@@ -10443,7 +10474,11 @@ impl<'a> fuzzy::PathMatchCandidateSet<'a> for PathMatchCandidateSet {
 
     fn candidates(&'a self, start: usize) -> Self::Candidates {
         PathMatchCandidateSetIter {
-            traversal: self.snapshot.files(self.include_ignored, start),
+            traversal: if self.directories_only {
+                self.snapshot.directories(self.include_ignored, start)
+            } else {
+                self.snapshot.files(self.include_ignored, start)
+            },
         }
     }
 }
@@ -10456,15 +10491,16 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> {
     type Item = fuzzy::PathMatchCandidate<'a>;
 
     fn next(&mut self) -> Option<Self::Item> {
-        self.traversal.next().map(|entry| {
-            if let EntryKind::File(char_bag) = entry.kind {
-                fuzzy::PathMatchCandidate {
-                    path: &entry.path,
-                    char_bag,
-                }
-            } else {
-                unreachable!()
-            }
+        self.traversal.next().map(|entry| match entry.kind {
+            EntryKind::Dir => fuzzy::PathMatchCandidate {
+                path: &entry.path,
+                char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()),
+            },
+            EntryKind::File(char_bag) => fuzzy::PathMatchCandidate {
+                path: &entry.path,
+                char_bag,
+            },
+            EntryKind::UnloadedDir | EntryKind::PendingDir => unreachable!(),
         })
     }
 }

crates/project/src/project_tests.rs 🔗

@@ -2942,7 +2942,12 @@ async fn test_save_as(cx: &mut gpui::TestAppContext) {
     });
     project
         .update(cx, |project, cx| {
-            project.save_buffer_as(buffer.clone(), "/dir/file1.rs".into(), cx)
+            let worktree_id = project.worktrees().next().unwrap().read(cx).id();
+            let path = ProjectPath {
+                worktree_id,
+                path: Arc::from(Path::new("file1.rs")),
+            };
+            project.save_buffer_as(buffer.clone(), path, cx)
         })
         .await
         .unwrap();

crates/project_panel/src/project_panel.rs 🔗

@@ -887,7 +887,7 @@ impl ProjectPanel {
 
             let answer = (!action.skip_prompt).then(|| {
                 cx.prompt(
-                    PromptLevel::Info,
+                    PromptLevel::Destructive,
                     &format!("Delete {file_name:?}?"),
                     None,
                     &["Delete", "Cancel"],

crates/recent_projects/src/remote_projects.rs 🔗

@@ -216,7 +216,7 @@ impl RemoteProjects {
 
     fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
         let answer = cx.prompt(
-            gpui::PromptLevel::Info,
+            gpui::PromptLevel::Destructive,
             "Are you sure?",
             Some("This will delete the dev server and all of its remote projects."),
             &["Delete", "Cancel"],

crates/rpc/proto/zed.proto 🔗

@@ -769,6 +769,12 @@ message SaveBuffer {
     uint64 project_id = 1;
     uint64 buffer_id = 2;
     repeated VectorClockEntry version = 3;
+    optional ProjectPath new_path = 4;
+}
+
+message ProjectPath {
+    uint64 worktree_id = 1;
+    string path = 2;
 }
 
 message BufferSaved {

crates/search/src/project_search.rs 🔗

@@ -19,14 +19,14 @@ use gpui::{
     WeakModel, WeakView, WhiteSpace, WindowContext,
 };
 use menu::Confirm;
-use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project};
+use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath};
 use settings::Settings;
 use smol::stream::StreamExt;
 use std::{
     any::{Any, TypeId},
     mem,
     ops::{Not, Range},
-    path::{Path, PathBuf},
+    path::Path,
 };
 use theme::ThemeSettings;
 use ui::{
@@ -439,7 +439,7 @@ impl Item for ProjectSearchView {
     fn save_as(
         &mut self,
         _: Model<Project>,
-        _: PathBuf,
+        _: ProjectPath,
         _: &mut ViewContext<Self>,
     ) -> Task<anyhow::Result<()>> {
         unreachable!("save_as should not have been called")

crates/ui/src/components/label/highlighted_label.rs 🔗

@@ -50,38 +50,49 @@ impl LabelCommon for HighlightedLabel {
     }
 }
 
-impl RenderOnce for HighlightedLabel {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let highlight_color = cx.theme().colors().text_accent;
+pub fn highlight_ranges(
+    text: &str,
+    indices: &Vec<usize>,
+    style: HighlightStyle,
+) -> Vec<(Range<usize>, HighlightStyle)> {
+    let mut highlight_indices = indices.iter().copied().peekable();
+    let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
+
+    while let Some(start_ix) = highlight_indices.next() {
+        let mut end_ix = start_ix;
+
+        loop {
+            end_ix = end_ix + text[end_ix..].chars().next().unwrap().len_utf8();
+            if let Some(&next_ix) = highlight_indices.peek() {
+                if next_ix == end_ix {
+                    end_ix = next_ix;
+                    highlight_indices.next();
+                    continue;
+                }
+            }
+            break;
+        }
 
-        let mut highlight_indices = self.highlight_indices.iter().copied().peekable();
-        let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
+        highlights.push((start_ix..end_ix, style));
+    }
 
-        while let Some(start_ix) = highlight_indices.next() {
-            let mut end_ix = start_ix;
+    highlights
+}
 
-            loop {
-                end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8();
-                if let Some(&next_ix) = highlight_indices.peek() {
-                    if next_ix == end_ix {
-                        end_ix = next_ix;
-                        highlight_indices.next();
-                        continue;
-                    }
-                }
-                break;
-            }
+impl RenderOnce for HighlightedLabel {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let highlight_color = cx.theme().colors().text_accent;
 
-            highlights.push((
-                start_ix..end_ix,
-                HighlightStyle {
-                    color: Some(highlight_color),
-                    ..Default::default()
-                },
-            ));
-        }
+        let highlights = highlight_ranges(
+            &self.label,
+            &self.highlight_indices,
+            HighlightStyle {
+                color: Some(highlight_color),
+                ..Default::default()
+            },
+        );
 
-        let mut text_style = cx.text_style().clone();
+        let mut text_style = cx.text_style();
         text_style.color = self.base.color.color(cx);
 
         self.base

crates/workspace/src/item.rs 🔗

@@ -26,7 +26,6 @@ use std::{
     any::{Any, TypeId},
     cell::RefCell,
     ops::Range,
-    path::PathBuf,
     rc::Rc,
     sync::Arc,
     time::Duration,
@@ -196,7 +195,7 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
     fn save_as(
         &mut self,
         _project: Model<Project>,
-        _abs_path: PathBuf,
+        _path: ProjectPath,
         _cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
         unimplemented!("save_as() must be implemented if can_save() returns true")
@@ -309,7 +308,7 @@ pub trait ItemHandle: 'static + Send {
     fn save_as(
         &self,
         project: Model<Project>,
-        abs_path: PathBuf,
+        path: ProjectPath,
         cx: &mut WindowContext,
     ) -> Task<Result<()>>;
     fn reload(&self, project: Model<Project>, cx: &mut WindowContext) -> Task<Result<()>>;
@@ -647,10 +646,10 @@ impl<T: Item> ItemHandle for View<T> {
     fn save_as(
         &self,
         project: Model<Project>,
-        abs_path: PathBuf,
+        path: ProjectPath,
         cx: &mut WindowContext,
     ) -> Task<anyhow::Result<()>> {
-        self.update(cx, |item, cx| item.save_as(project, abs_path, cx))
+        self.update(cx, |item, cx| item.save_as(project, path, cx))
     }
 
     fn reload(&self, project: Model<Project>, cx: &mut WindowContext) -> Task<Result<()>> {
@@ -1126,7 +1125,7 @@ pub mod test {
         fn save_as(
             &mut self,
             _: Model<Project>,
-            _: std::path::PathBuf,
+            _: ProjectPath,
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.save_as_count += 1;

crates/workspace/src/notifications.rs 🔗

@@ -263,7 +263,7 @@ impl Render for LanguageServerPrompt {
                                             PromptLevel::Warning => {
                                                 Some(DiagnosticSeverity::WARNING)
                                             }
-                                            PromptLevel::Critical => {
+                                            PromptLevel::Critical | PromptLevel::Destructive => {
                                                 Some(DiagnosticSeverity::ERROR)
                                             }
                                         }

crates/workspace/src/pane.rs 🔗

@@ -26,7 +26,7 @@ use std::{
     any::Any,
     cmp, fmt, mem,
     ops::ControlFlow,
-    path::{Path, PathBuf},
+    path::PathBuf,
     rc::Rc,
     sync::{
         atomic::{AtomicUsize, Ordering},
@@ -1322,14 +1322,10 @@ impl Pane {
                 pane.update(cx, |_, cx| item.save(should_format, project, cx))?
                     .await?;
             } else if can_save_as {
-                let start_abs_path = project
-                    .update(cx, |project, cx| {
-                        let worktree = project.visible_worktrees(cx).next()?;
-                        Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
-                    })?
-                    .unwrap_or_else(|| Path::new("").into());
-
-                let abs_path = cx.update(|cx| cx.prompt_for_new_path(&start_abs_path))?;
+                let abs_path = pane.update(cx, |pane, cx| {
+                    pane.workspace
+                        .update(cx, |workspace, cx| workspace.prompt_for_new_path(cx))
+                })??;
                 if let Some(abs_path) = abs_path.await.ok().flatten() {
                     pane.update(cx, |_, cx| item.save_as(project, abs_path, cx))?
                         .await?;

crates/workspace/src/workspace.rs 🔗

@@ -544,6 +544,10 @@ pub enum OpenVisible {
     OnlyDirectories,
 }
 
+type PromptForNewPath = Box<
+    dyn Fn(&mut Workspace, &mut ViewContext<Workspace>) -> oneshot::Receiver<Option<ProjectPath>>,
+>;
+
 /// Collects everything project-related for a certain window opened.
 /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
 ///
@@ -585,6 +589,7 @@ pub struct Workspace {
     bounds: Bounds<Pixels>,
     centered_layout: bool,
     bounds_save_task_queued: Option<Task<()>>,
+    on_prompt_for_new_path: Option<PromptForNewPath>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -875,6 +880,7 @@ impl Workspace {
             bounds: Default::default(),
             centered_layout: false,
             bounds_save_task_queued: None,
+            on_prompt_for_new_path: None,
         }
     }
 
@@ -1223,6 +1229,59 @@ impl Workspace {
         cx.notify();
     }
 
+    pub fn set_prompt_for_new_path(&mut self, prompt: PromptForNewPath) {
+        self.on_prompt_for_new_path = Some(prompt)
+    }
+
+    pub fn prompt_for_new_path(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+    ) -> oneshot::Receiver<Option<ProjectPath>> {
+        if let Some(prompt) = self.on_prompt_for_new_path.take() {
+            let rx = prompt(self, cx);
+            self.on_prompt_for_new_path = Some(prompt);
+            rx
+        } else {
+            let start_abs_path = self
+                .project
+                .update(cx, |project, cx| {
+                    let worktree = project.visible_worktrees(cx).next()?;
+                    Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
+                })
+                .unwrap_or_else(|| Path::new("").into());
+
+            let (tx, rx) = oneshot::channel();
+            let abs_path = cx.prompt_for_new_path(&start_abs_path);
+            cx.spawn(|this, mut cx| async move {
+                let abs_path = abs_path.await?;
+                let project_path = abs_path.and_then(|abs_path| {
+                    this.update(&mut cx, |this, cx| {
+                        this.project.update(cx, |project, cx| {
+                            project.find_or_create_local_worktree(abs_path, true, cx)
+                        })
+                    })
+                    .ok()
+                });
+
+                if let Some(project_path) = project_path {
+                    let (worktree, path) = project_path.await?;
+                    let worktree_id = worktree.read_with(&cx, |worktree, _| worktree.id())?;
+                    tx.send(Some(ProjectPath {
+                        worktree_id,
+                        path: path.into(),
+                    }))
+                    .ok();
+                } else {
+                    tx.send(None).ok();
+                }
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+
+            rx
+        }
+    }
+
     pub fn titlebar_item(&self) -> Option<AnyView> {
         self.titlebar_item.clone()
     }

crates/worktree/src/worktree.rs 🔗

@@ -1625,6 +1625,7 @@ impl RemoteWorktree {
     pub fn save_buffer(
         &self,
         buffer_handle: Model<Buffer>,
+        new_path: Option<proto::ProjectPath>,
         cx: &mut ModelContext<Worktree>,
     ) -> Task<Result<()>> {
         let buffer = buffer_handle.read(cx);
@@ -1637,6 +1638,7 @@ impl RemoteWorktree {
                 .request(proto::SaveBuffer {
                     project_id,
                     buffer_id,
+                    new_path,
                     version: serialize_version(&version),
                 })
                 .await?;
@@ -1911,6 +1913,7 @@ impl Snapshot {
 
     fn traverse_from_offset(
         &self,
+        include_files: bool,
         include_dirs: bool,
         include_ignored: bool,
         start_offset: usize,
@@ -1919,6 +1922,7 @@ impl Snapshot {
         cursor.seek(
             &TraversalTarget::Count {
                 count: start_offset,
+                include_files,
                 include_dirs,
                 include_ignored,
             },
@@ -1927,6 +1931,7 @@ impl Snapshot {
         );
         Traversal {
             cursor,
+            include_files,
             include_dirs,
             include_ignored,
         }
@@ -1934,6 +1939,7 @@ impl Snapshot {
 
     fn traverse_from_path(
         &self,
+        include_files: bool,
         include_dirs: bool,
         include_ignored: bool,
         path: &Path,
@@ -1942,17 +1948,22 @@ impl Snapshot {
         cursor.seek(&TraversalTarget::Path(path), Bias::Left, &());
         Traversal {
             cursor,
+            include_files,
             include_dirs,
             include_ignored,
         }
     }
 
     pub fn files(&self, include_ignored: bool, start: usize) -> Traversal {
-        self.traverse_from_offset(false, include_ignored, start)
+        self.traverse_from_offset(true, false, include_ignored, start)
+    }
+
+    pub fn directories(&self, include_ignored: bool, start: usize) -> Traversal {
+        self.traverse_from_offset(false, true, include_ignored, start)
     }
 
     pub fn entries(&self, include_ignored: bool) -> Traversal {
-        self.traverse_from_offset(true, include_ignored, 0)
+        self.traverse_from_offset(true, true, include_ignored, 0)
     }
 
     pub fn repositories(&self) -> impl Iterator<Item = (&Arc<Path>, &RepositoryEntry)> {
@@ -2084,6 +2095,7 @@ impl Snapshot {
         cursor.seek(&TraversalTarget::Path(parent_path), Bias::Right, &());
         let traversal = Traversal {
             cursor,
+            include_files: true,
             include_dirs: true,
             include_ignored: true,
         };
@@ -2103,6 +2115,7 @@ impl Snapshot {
         cursor.seek(&TraversalTarget::Path(parent_path), Bias::Left, &());
         let mut traversal = Traversal {
             cursor,
+            include_files: true,
             include_dirs,
             include_ignored,
         };
@@ -2141,7 +2154,7 @@ impl Snapshot {
 
     pub fn entry_for_path(&self, path: impl AsRef<Path>) -> Option<&Entry> {
         let path = path.as_ref();
-        self.traverse_from_path(true, true, path)
+        self.traverse_from_path(true, true, true, path)
             .entry()
             .and_then(|entry| {
                 if entry.path.as_ref() == path {
@@ -4532,12 +4545,15 @@ struct TraversalProgress<'a> {
 }
 
 impl<'a> TraversalProgress<'a> {
-    fn count(&self, include_dirs: bool, include_ignored: bool) -> usize {
-        match (include_ignored, include_dirs) {
-            (true, true) => self.count,
-            (true, false) => self.file_count,
-            (false, true) => self.non_ignored_count,
-            (false, false) => self.non_ignored_file_count,
+    fn count(&self, include_files: bool, include_dirs: bool, include_ignored: bool) -> usize {
+        match (include_files, include_dirs, include_ignored) {
+            (true, true, true) => self.count,
+            (true, true, false) => self.non_ignored_count,
+            (true, false, true) => self.file_count,
+            (true, false, false) => self.non_ignored_file_count,
+            (false, true, true) => self.count - self.file_count,
+            (false, true, false) => self.non_ignored_count - self.non_ignored_file_count,
+            (false, false, _) => 0,
         }
     }
 }
@@ -4600,6 +4616,7 @@ impl<'a> sum_tree::Dimension<'a, EntrySummary> for GitStatuses {
 pub struct Traversal<'a> {
     cursor: sum_tree::Cursor<'a, Entry, TraversalProgress<'a>>,
     include_ignored: bool,
+    include_files: bool,
     include_dirs: bool,
 }
 
@@ -4609,6 +4626,7 @@ impl<'a> Traversal<'a> {
             &TraversalTarget::Count {
                 count: self.end_offset() + 1,
                 include_dirs: self.include_dirs,
+                include_files: self.include_files,
                 include_ignored: self.include_ignored,
             },
             Bias::Left,
@@ -4624,7 +4642,8 @@ impl<'a> Traversal<'a> {
                 &(),
             );
             if let Some(entry) = self.cursor.item() {
-                if (self.include_dirs || !entry.is_dir())
+                if (self.include_files || !entry.is_file())
+                    && (self.include_dirs || !entry.is_dir())
                     && (self.include_ignored || !entry.is_ignored)
                 {
                     return true;
@@ -4641,13 +4660,13 @@ impl<'a> Traversal<'a> {
     pub fn start_offset(&self) -> usize {
         self.cursor
             .start()
-            .count(self.include_dirs, self.include_ignored)
+            .count(self.include_files, self.include_dirs, self.include_ignored)
     }
 
     pub fn end_offset(&self) -> usize {
         self.cursor
             .end(&())
-            .count(self.include_dirs, self.include_ignored)
+            .count(self.include_files, self.include_dirs, self.include_ignored)
     }
 }
 
@@ -4670,6 +4689,7 @@ enum TraversalTarget<'a> {
     PathSuccessor(&'a Path),
     Count {
         count: usize,
+        include_files: bool,
         include_ignored: bool,
         include_dirs: bool,
     },
@@ -4688,11 +4708,12 @@ impl<'a, 'b> SeekTarget<'a, EntrySummary, TraversalProgress<'a>> for TraversalTa
             }
             TraversalTarget::Count {
                 count,
+                include_files,
                 include_dirs,
                 include_ignored,
             } => Ord::cmp(
                 count,
-                &cursor_location.count(*include_dirs, *include_ignored),
+                &cursor_location.count(*include_files, *include_dirs, *include_ignored),
             ),
         }
     }