mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-26 07:12:03 +03:00
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:
parent
314b723292
commit
664f779eb4
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3815,6 +3815,7 @@ dependencies = [
|
||||
"ctor",
|
||||
"editor",
|
||||
"env_logger",
|
||||
"futures 0.3.28",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"itertools 0.11.0",
|
||||
|
51
README.md
51
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).
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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!()
|
||||
|
@ -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};
|
||||
|
||||
|
@ -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<()>> {
|
||||
|
@ -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"
|
||||
|
@ -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<_>>();
|
||||
|
463
crates/file_finder/src/new_path_prompt.rs
Normal file
463
crates/file_finder/src/new_path_prompt.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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!(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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"],
|
||||
|
@ -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"],
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -263,7 +263,7 @@ impl Render for LanguageServerPrompt {
|
||||
PromptLevel::Warning => {
|
||||
Some(DiagnosticSeverity::WARNING)
|
||||
}
|
||||
PromptLevel::Critical => {
|
||||
PromptLevel::Critical | PromptLevel::Destructive => {
|
||||
Some(DiagnosticSeverity::ERROR)
|
||||
}
|
||||
}
|
||||
|
@ -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?;
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user