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",
"editor",
"env_logger",
"futures 0.3.28",
"fuzzy",
"gpui",
"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
));
}
#[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
.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();

View File

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

View File

@ -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};

View File

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

View File

@ -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"

View File

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

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
#[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)

View File

@ -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();

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -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"],

View File

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

View File

@ -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 {

View File

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

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 {
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<usize>, 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

View File

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

View File

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

View File

@ -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?;

View File

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

View File

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