From 664f779eb47341960468fcfc16d40751d7d1a953 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 26 Apr 2024 13:25:25 -0600 Subject: [PATCH] new path picker (#11015) 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 Co-authored-by: Bennet --- 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 +- .../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(-) create mode 100644 crates/file_finder/src/new_path_prompt.rs diff --git a/Cargo.lock b/Cargo.lock index 0d4c9518ba..6302e78434 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3815,6 +3815,7 @@ dependencies = [ "ctor", "editor", "env_logger", + "futures 0.3.28", "fuzzy", "gpui", "itertools 0.11.0", diff --git a/README.md b/README.md index 186aa4ab70..e69de29bb2 100644 --- a/README.md +++ b/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). diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index 0917f4994f..8769b721b3 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/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" + ); +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 65e57a8ff3..e4fb75514f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/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(); diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index b59e819db2..d06ff824fb 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/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, - _: PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { unreachable!() diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 3e7b4b67f2..a1bbd26a2b 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/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}; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index da617c3fea..aa6b36d597 100644 --- a/crates/editor/src/items.rs +++ b/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, - abs_path: PathBuf, + path: ProjectPath, cx: &mut ViewContext, ) -> Task> { 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, cx: &mut ViewContext) -> Task> { diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 03411c130a..2fe030bf7b 100644 --- a/crates/file_finder/Cargo.toml +++ b/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" diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 8447dc8a55..096e8c0eaa 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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::>(); diff --git a/crates/file_finder/src/new_path_prompt.rs b/crates/file_finder/src/new_path_prompt.rs new file mode 100644 index 0000000000..e538576b98 --- /dev/null +++ b/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, + suffix: Option, +} + +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 { + 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 { + 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, + tx: Option>>, + selected_index: usize, + matches: Vec, + last_selected_dir: Option, + cancel_flag: Arc, + should_dismiss: bool, +} + +impl NewPathPrompt { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext) { + 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>, + cx: &mut ViewContext, + ) { + 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>) { + self.selected_index = ix; + cx.notify(); + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> 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::>(); + 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::>(); + + 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>) -> Option { + 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>) { + 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>) { + 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>, + ) -> Option { + 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 { + Arc::from("[directory/]filename.ext") + } +} + +impl NewPathDelegate { + fn set_search_matches( + &mut self, + query: String, + prefix: String, + suffix: Option, + matches: Vec, + cx: &mut ViewContext>, + ) { + 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()), + }) + } + } + } +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 23cd8bce52..2b3b901ebf 100644 --- a/crates/gpui/src/platform.rs +++ b/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) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 0780d89e7b..7b57c576f1 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/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(); diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index f4cf1ded44..e05904426a 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/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; } diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 4a7c78d751..6d7e3ac94e 100644 --- a/crates/gpui/src/style.rs +++ b/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) { diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index f3fbc4f111..da0a85a8e0 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -79,11 +79,18 @@ pub trait PickerDelegate: Sized + 'static { false } + fn confirm_update_query(&mut self, _cx: &mut ViewContext>) -> Option { + None + } + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); /// 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>) {} fn dismissed(&mut self, cx: &mut ViewContext>); + fn should_dismiss(&self) -> bool { + true + } fn selected_as_query(&self) -> Option { None } @@ -267,8 +274,10 @@ impl Picker { } pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - 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) { @@ -280,7 +289,7 @@ impl Picker { 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 Picker { { self.confirm_on_update = Some(true) } else { - self.delegate.confirm(true, cx); + self.do_confirm(true, cx); } } @@ -311,7 +320,16 @@ impl Picker { 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) { + 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 Picker { 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(); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index fa4c8d88d3..352ffa01e6 100644 --- a/crates/project/src/project.rs +++ b/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, } +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, - abs_path: PathBuf, + path: ProjectPath, cx: &mut ModelContext, ) -> Task> { - 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.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!(), }) } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 188fb50b53..275b2f3f97 100644 --- a/crates/project/src/project_tests.rs +++ b/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(); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ddbd8429ff..286f058d1e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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"], diff --git a/crates/recent_projects/src/remote_projects.rs b/crates/recent_projects/src/remote_projects.rs index 447b22771c..61900efef7 100644 --- a/crates/recent_projects/src/remote_projects.rs +++ b/crates/recent_projects/src/remote_projects.rs @@ -216,7 +216,7 @@ impl RemoteProjects { fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { 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"], diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index b3014d1748..cf75750e15 100644 --- a/crates/rpc/proto/zed.proto +++ b/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 { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 65ca55c5b7..111a0ace61 100644 --- a/crates/search/src/project_search.rs +++ b/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, - _: PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { unreachable!("save_as should not have been called") diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 876f584672..0408a2e6c9 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -50,38 +50,49 @@ impl LabelCommon for HighlightedLabel { } } +pub fn highlight_ranges( + text: &str, + indices: &Vec, + style: HighlightStyle, +) -> Vec<(Range, HighlightStyle)> { + let mut highlight_indices = indices.iter().copied().peekable(); + let mut highlights: Vec<(Range, 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; + } + + highlights.push((start_ix..end_ix, style)); + } + + highlights +} + impl RenderOnce for HighlightedLabel { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let highlight_color = cx.theme().colors().text_accent; - let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); - let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + let highlights = highlight_ranges( + &self.label, + &self.highlight_indices, + HighlightStyle { + color: Some(highlight_color), + ..Default::default() + }, + ); - while let Some(start_ix) = highlight_indices.next() { - let mut end_ix = start_ix; - - 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; - } - - highlights.push(( - start_ix..end_ix, - 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 diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 15184a9d3b..e45c6ffe13 100644 --- a/crates/workspace/src/item.rs +++ b/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 { fn save_as( &mut self, _project: Model, - _abs_path: PathBuf, + _path: ProjectPath, _cx: &mut ViewContext, ) -> Task> { 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, - abs_path: PathBuf, + path: ProjectPath, cx: &mut WindowContext, ) -> Task>; fn reload(&self, project: Model, cx: &mut WindowContext) -> Task>; @@ -647,10 +646,10 @@ impl ItemHandle for View { fn save_as( &self, project: Model, - abs_path: PathBuf, + path: ProjectPath, cx: &mut WindowContext, ) -> Task> { - 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, cx: &mut WindowContext) -> Task> { @@ -1126,7 +1125,7 @@ pub mod test { fn save_as( &mut self, _: Model, - _: std::path::PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { self.save_as_count += 1; diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index c7ea762e15..2757b6e561 100644 --- a/crates/workspace/src/notifications.rs +++ b/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) } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index c9027f2c90..59c208924a 100644 --- a/crates/workspace/src/pane.rs +++ b/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?; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 98cddd8d25..25a20ec0ce 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -544,6 +544,10 @@ pub enum OpenVisible { OnlyDirectories, } +type PromptForNewPath = Box< + dyn Fn(&mut Workspace, &mut ViewContext) -> oneshot::Receiver>, +>; + /// 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, centered_layout: bool, bounds_save_task_queued: Option>, + on_prompt_for_new_path: Option, } impl EventEmitter 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, + ) -> oneshot::Receiver> { + 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 { self.titlebar_item.clone() } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 5e4556f3d2..10cbbf5339 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1625,6 +1625,7 @@ impl RemoteWorktree { pub fn save_buffer( &self, buffer_handle: Model, + new_path: Option, cx: &mut ModelContext, ) -> Task> { 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, &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) -> 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), ), } }