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 <nathan@zed.dev>
Co-authored-by: Bennet <bennetbo@gmx.de>
This commit is contained in:
Conrad Irwin 2024-04-26 13:25:25 -06:00 committed by GitHub
parent 314b723292
commit 664f779eb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 775 additions and 149 deletions

1
Cargo.lock generated
View File

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

View File

@ -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).

View File

@ -366,3 +366,35 @@ async fn test_create_remote_project_path_validation(
ErrorCode::RemoteProjectPathDoesNotExist 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"
);
}

View File

@ -2468,7 +2468,12 @@ async fn test_propagate_saves_and_fs_changes(
}); });
project_a project_a
.update(cx_a, |project, cx| { .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 .await
.unwrap(); .unwrap();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()),
})
}
}
}
}

View File

@ -693,7 +693,7 @@ pub struct PathPromptOptions {
} }
/// What kind of prompt styling to show /// What kind of prompt styling to show
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug, PartialEq)]
pub enum PromptLevel { pub enum PromptLevel {
/// A prompt that is shown when the user should be notified of something /// A prompt that is shown when the user should be notified of something
Info, Info,
@ -703,6 +703,10 @@ pub enum PromptLevel {
/// A prompt that is shown when a critical problem has occurred /// A prompt that is shown when a critical problem has occurred
Critical, 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) /// The style of the cursor (pointer)

View File

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

View File

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

View File

@ -628,6 +628,13 @@ impl From<&TextStyle> for HighlightStyle {
} }
impl 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. /// Blend this highlight style with another.
/// Non-continuous properties, like font_weight and font_style, are overwritten. /// Non-continuous properties, like font_weight and font_style, are overwritten.
pub fn highlight(&mut self, other: HighlightStyle) { pub fn highlight(&mut self, other: HighlightStyle) {

View File

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

View File

@ -32,6 +32,7 @@ use futures::{
stream::FuturesUnordered, stream::FuturesUnordered,
AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
}; };
use fuzzy::CharBag;
use git::{blame::Blame, repository::GitRepository}; use git::{blame::Blame, repository::GitRepository};
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use gpui::{ use gpui::{
@ -370,6 +371,22 @@ pub struct ProjectPath {
pub path: Arc<Path>, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlayHint { pub struct InlayHint {
pub position: language::Anchor, pub position: language::Anchor,
@ -2189,33 +2206,37 @@ impl Project {
let path = file.path.clone(); let path = file.path.clone();
worktree.update(cx, |worktree, cx| match worktree { worktree.update(cx, |worktree, cx| match worktree {
Worktree::Local(worktree) => worktree.save_buffer(buffer, path, false, cx), 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( pub fn save_buffer_as(
&mut self, &mut self,
buffer: Model<Buffer>, buffer: Model<Buffer>,
abs_path: PathBuf, path: ProjectPath,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Task<Result<()>> { ) -> 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()) let old_file = File::from_dyn(buffer.read(cx).file())
.filter(|f| f.is_local()) .filter(|f| f.is_local())
.cloned(); .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 { cx.spawn(move |this, mut cx| async move {
if let Some(old_file) = &old_file { if let Some(old_file) = &old_file {
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.unregister_buffer_from_language_servers(&buffer, old_file, cx); this.unregister_buffer_from_language_servers(&buffer, old_file, cx);
})?; })?;
} }
let (worktree, path) = worktree_task.await?;
worktree worktree
.update(&mut cx, |worktree, cx| match worktree { .update(&mut cx, |worktree, cx| match worktree {
Worktree::Local(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?; .await?;
@ -8676,8 +8697,17 @@ impl Project {
.await?; .await?;
let buffer_id = buffer.update(&mut cx, |buffer, _| buffer.remote_id())?; 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?; .await?;
} else {
this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx))?
.await?;
}
buffer.update(&mut cx, |buffer, _| proto::BufferSaved { buffer.update(&mut cx, |buffer, _| proto::BufferSaved {
project_id, project_id,
buffer_id: buffer_id.into(), buffer_id: buffer_id.into(),
@ -10414,6 +10444,7 @@ pub struct PathMatchCandidateSet {
pub snapshot: Snapshot, pub snapshot: Snapshot,
pub include_ignored: bool, pub include_ignored: bool,
pub include_root_name: bool, pub include_root_name: bool,
pub directories_only: bool,
} }
impl<'a> fuzzy::PathMatchCandidateSet<'a> for PathMatchCandidateSet { 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 { fn candidates(&'a self, start: usize) -> Self::Candidates {
PathMatchCandidateSetIter { 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>; type Item = fuzzy::PathMatchCandidate<'a>;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
self.traversal.next().map(|entry| { self.traversal.next().map(|entry| match entry.kind {
if let EntryKind::File(char_bag) = entry.kind { EntryKind::Dir => fuzzy::PathMatchCandidate {
fuzzy::PathMatchCandidate { path: &entry.path,
path: &entry.path, char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()),
char_bag, },
} EntryKind::File(char_bag) => fuzzy::PathMatchCandidate {
} else { path: &entry.path,
unreachable!() char_bag,
} },
EntryKind::UnloadedDir | EntryKind::PendingDir => unreachable!(),
}) })
} }
} }

View File

@ -2942,7 +2942,12 @@ async fn test_save_as(cx: &mut gpui::TestAppContext) {
}); });
project project
.update(cx, |project, cx| { .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 .await
.unwrap(); .unwrap();

View File

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

View File

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

View File

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

View File

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

View File

@ -50,38 +50,49 @@ impl LabelCommon for HighlightedLabel {
} }
} }
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;
}
highlights.push((start_ix..end_ix, style));
}
highlights
}
impl RenderOnce for HighlightedLabel { impl RenderOnce for HighlightedLabel {
fn render(self, cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let highlight_color = cx.theme().colors().text_accent; let highlight_color = cx.theme().colors().text_accent;
let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); let highlights = highlight_ranges(
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new(); &self.label,
&self.highlight_indices,
HighlightStyle {
color: Some(highlight_color),
..Default::default()
},
);
while let Some(start_ix) = highlight_indices.next() { let mut text_style = cx.text_style();
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();
text_style.color = self.base.color.color(cx); text_style.color = self.base.color.color(cx);
self.base self.base

View File

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

View File

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

View File

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

View File

@ -544,6 +544,10 @@ pub enum OpenVisible {
OnlyDirectories, OnlyDirectories,
} }
type PromptForNewPath = Box<
dyn Fn(&mut Workspace, &mut ViewContext<Workspace>) -> oneshot::Receiver<Option<ProjectPath>>,
>;
/// Collects everything project-related for a certain window opened. /// 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`. /// 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>, bounds: Bounds<Pixels>,
centered_layout: bool, centered_layout: bool,
bounds_save_task_queued: Option<Task<()>>, bounds_save_task_queued: Option<Task<()>>,
on_prompt_for_new_path: Option<PromptForNewPath>,
} }
impl EventEmitter<Event> for Workspace {} impl EventEmitter<Event> for Workspace {}
@ -875,6 +880,7 @@ impl Workspace {
bounds: Default::default(), bounds: Default::default(),
centered_layout: false, centered_layout: false,
bounds_save_task_queued: None, bounds_save_task_queued: None,
on_prompt_for_new_path: None,
} }
} }
@ -1223,6 +1229,59 @@ impl Workspace {
cx.notify(); 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> { pub fn titlebar_item(&self) -> Option<AnyView> {
self.titlebar_item.clone() self.titlebar_item.clone()
} }

View File

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