Cargo.lock 🔗
@@ -5035,6 +5035,7 @@ dependencies = [
"language",
"menu",
"postage",
+ "pretty_assertions",
"project",
"schemars",
"serde",
Piotr Osiewicz created
Cargo.lock | 1
Cargo.toml | 1
assets/keymaps/atom.json | 4
assets/keymaps/sublime_text.json | 4
assets/keymaps/textmate.json | 6
crates/collab/Cargo.toml | 2
crates/editor/src/display_map/inlay_map.rs | 89 ++++
crates/editor/src/editor.rs | 82 +++-
crates/editor/src/inlay_hint_cache.rs | 432 ++++++++++++++++++-----
crates/fs/src/fs.rs | 6
crates/project/Cargo.toml | 2
crates/project/src/worktree.rs | 123 ++++--
crates/project/src/worktree_tests.rs | 163 +++++++-
crates/project_panel/Cargo.toml | 1
crates/project_panel/src/project_panel.rs | 151 ++++++++
crates/settings/Cargo.toml | 2
crates/text/src/text.rs | 7
17 files changed, 881 insertions(+), 195 deletions(-)
@@ -5035,6 +5035,7 @@ dependencies = [
"language",
"menu",
"postage",
+ "pretty_assertions",
"project",
"schemars",
"serde",
@@ -101,6 +101,7 @@ time = { version = "0.3", features = ["serde", "serde-well-known"] }
toml = { version = "0.5" }
tree-sitter = "0.20"
unindent = { version = "0.1.7" }
+pretty_assertions = "1.3.0"
[patch.crates-io]
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "49226023693107fba9a1191136a4f47f38cdca73" }
@@ -24,9 +24,7 @@
],
"ctrl-shift-down": "editor::AddSelectionBelow",
"ctrl-shift-up": "editor::AddSelectionAbove",
- "cmd-shift-backspace": "editor::DeleteToBeginningOfLine",
- "cmd-shift-enter": "editor::NewlineAbove",
- "cmd-enter": "editor::NewlineBelow"
+ "cmd-shift-backspace": "editor::DeleteToBeginningOfLine"
}
},
{
@@ -24,9 +24,7 @@
"ctrl-.": "editor::GoToHunk",
"ctrl-,": "editor::GoToPrevHunk",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
- "ctrl-delete": "editor::DeleteToNextWordEnd",
- "cmd-shift-enter": "editor::NewlineAbove",
- "cmd-enter": "editor::NewlineBelow"
+ "ctrl-delete": "editor::DeleteToNextWordEnd"
}
},
{
@@ -12,8 +12,6 @@
"ctrl-shift-d": "editor::DuplicateLine",
"cmd-b": "editor::GoToDefinition",
"cmd-j": "editor::ScrollCursorCenter",
- "cmd-alt-enter": "editor::NewlineAbove",
- "cmd-enter": "editor::NewlineBelow",
"cmd-shift-l": "editor::SelectLine",
"cmd-shift-t": "outline::Toggle",
"alt-backspace": "editor::DeleteToPreviousWordStart",
@@ -56,7 +54,9 @@
},
{
"context": "Editor && mode == full",
- "bindings": {}
+ "bindings": {
+ "cmd-alt-enter": "editor::NewlineAbove"
+ }
},
{
"context": "BufferSearchBar",
@@ -67,7 +67,7 @@ fs = { path = "../fs", features = ["test-support"] }
git = { path = "../git", features = ["test-support"] }
live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] }
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
project = { path = "../project", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
@@ -49,6 +49,19 @@ pub struct InlayProperties<T> {
pub text: T,
}
+impl InlayProperties<String> {
+ pub fn new(position: Anchor, hint: &project::InlayHint) -> Self {
+ let mut text = hint.text();
+ if hint.padding_right && !text.ends_with(' ') {
+ text.push(' ');
+ }
+ if hint.padding_left && !text.starts_with(' ') {
+ text.insert(0, ' ');
+ }
+ Self { position, text }
+ }
+}
+
impl sum_tree::Item for Transform {
type Summary = TransformSummary;
@@ -1095,6 +1108,7 @@ mod tests {
use super::*;
use crate::{InlayId, MultiBuffer};
use gpui::AppContext;
+ use project::{InlayHint, InlayHintLabel};
use rand::prelude::*;
use settings::SettingsStore;
use std::{cmp::Reverse, env, sync::Arc};
@@ -1102,6 +1116,81 @@ mod tests {
use text::Patch;
use util::post_inc;
+ #[test]
+ fn test_inlay_properties_label_padding() {
+ assert_eq!(
+ InlayProperties::new(
+ Anchor::min(),
+ &InlayHint {
+ label: InlayHintLabel::String("a".to_string()),
+ buffer_id: 0,
+ position: text::Anchor::default(),
+ padding_left: false,
+ padding_right: false,
+ tooltip: None,
+ kind: None,
+ },
+ )
+ .text,
+ "a",
+ "Should not pad label if not requested"
+ );
+
+ assert_eq!(
+ InlayProperties::new(
+ Anchor::min(),
+ &InlayHint {
+ label: InlayHintLabel::String("a".to_string()),
+ buffer_id: 0,
+ position: text::Anchor::default(),
+ padding_left: true,
+ padding_right: true,
+ tooltip: None,
+ kind: None,
+ },
+ )
+ .text,
+ " a ",
+ "Should pad label for every side requested"
+ );
+
+ assert_eq!(
+ InlayProperties::new(
+ Anchor::min(),
+ &InlayHint {
+ label: InlayHintLabel::String(" a ".to_string()),
+ buffer_id: 0,
+ position: text::Anchor::default(),
+ padding_left: false,
+ padding_right: false,
+ tooltip: None,
+ kind: None,
+ },
+ )
+ .text,
+ " a ",
+ "Should not change already padded label"
+ );
+
+ assert_eq!(
+ InlayProperties::new(
+ Anchor::min(),
+ &InlayHint {
+ label: InlayHintLabel::String(" a ".to_string()),
+ buffer_id: 0,
+ position: text::Anchor::default(),
+ padding_left: true,
+ padding_right: true,
+ tooltip: None,
+ kind: None,
+ },
+ )
+ .text,
+ " a ",
+ "Should not change already padded label"
+ );
+ }
+
#[gpui::test]
fn test_basic_inlays(cx: &mut AppContext) {
let buffer = MultiBuffer::build_simple("abcdefghi", cx);
@@ -26,7 +26,7 @@ use aho_corasick::AhoCorasick;
use anyhow::{anyhow, Result};
use blink_manager::BlinkManager;
use client::{ClickhouseEvent, TelemetrySettings};
-use clock::ReplicaId;
+use clock::{Global, ReplicaId};
use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
use copilot::Copilot;
pub use display_map::DisplayPoint;
@@ -1195,11 +1195,11 @@ enum GotoDefinitionKind {
Type,
}
-#[derive(Debug, Copy, Clone)]
+#[derive(Debug, Clone)]
enum InlayRefreshReason {
SettingsChange(InlayHintSettings),
NewLinesShown,
- ExcerptEdited,
+ BufferEdited(HashSet<Arc<Language>>),
RefreshRequested,
}
@@ -2617,7 +2617,7 @@ impl Editor {
return;
}
- let invalidate_cache = match reason {
+ let (invalidate_cache, required_languages) = match reason {
InlayRefreshReason::SettingsChange(new_settings) => {
match self.inlay_hint_cache.update_settings(
&self.buffer,
@@ -2633,16 +2633,18 @@ impl Editor {
return;
}
ControlFlow::Break(None) => return,
- ControlFlow::Continue(()) => InvalidationStrategy::RefreshRequested,
+ ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None),
}
}
- InlayRefreshReason::NewLinesShown => InvalidationStrategy::None,
- InlayRefreshReason::ExcerptEdited => InvalidationStrategy::ExcerptEdited,
- InlayRefreshReason::RefreshRequested => InvalidationStrategy::RefreshRequested,
+ InlayRefreshReason::NewLinesShown => (InvalidationStrategy::None, None),
+ InlayRefreshReason::BufferEdited(buffer_languages) => {
+ (InvalidationStrategy::BufferEdited, Some(buffer_languages))
+ }
+ InlayRefreshReason::RefreshRequested => (InvalidationStrategy::RefreshRequested, None),
};
self.inlay_hint_cache.refresh_inlay_hints(
- self.excerpt_visible_offsets(cx),
+ self.excerpt_visible_offsets(required_languages.as_ref(), cx),
invalidate_cache,
cx,
)
@@ -2661,8 +2663,9 @@ impl Editor {
fn excerpt_visible_offsets(
&self,
+ restrict_to_languages: Option<&HashSet<Arc<Language>>>,
cx: &mut ViewContext<'_, '_, Editor>,
- ) -> HashMap<ExcerptId, (ModelHandle<Buffer>, Range<usize>)> {
+ ) -> HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)> {
let multi_buffer = self.buffer().read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
let multi_buffer_visible_start = self
@@ -2680,8 +2683,22 @@ impl Editor {
.range_to_buffer_ranges(multi_buffer_visible_range, cx)
.into_iter()
.filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty())
- .map(|(buffer, excerpt_visible_range, excerpt_id)| {
- (excerpt_id, (buffer, excerpt_visible_range))
+ .filter_map(|(buffer_handle, excerpt_visible_range, excerpt_id)| {
+ let buffer = buffer_handle.read(cx);
+ let language = buffer.language()?;
+ if let Some(restrict_to_languages) = restrict_to_languages {
+ if !restrict_to_languages.contains(language) {
+ return None;
+ }
+ }
+ Some((
+ excerpt_id,
+ (
+ buffer_handle,
+ buffer.version().clone(),
+ excerpt_visible_range,
+ ),
+ ))
})
.collect()
}
@@ -2695,16 +2712,7 @@ impl Editor {
let buffer = self.buffer.read(cx).read(cx);
let new_inlays = to_insert
.into_iter()
- .map(|(position, id, hint)| {
- let mut text = hint.text();
- if hint.padding_right {
- text.push(' ');
- }
- if hint.padding_left {
- text.insert(0, ' ');
- }
- (id, InlayProperties { position, text })
- })
+ .map(|(position, id, hint)| (id, InlayProperties::new(position, &hint)))
.collect();
drop(buffer);
self.display_map.update(cx, |display_map, cx| {
@@ -7256,7 +7264,7 @@ impl Editor {
fn on_buffer_event(
&mut self,
- _: ModelHandle<MultiBuffer>,
+ multibuffer: ModelHandle<MultiBuffer>,
event: &multi_buffer::Event,
cx: &mut ViewContext<Self>,
) {
@@ -7268,7 +7276,33 @@ impl Editor {
self.update_visible_copilot_suggestion(cx);
}
cx.emit(Event::BufferEdited);
- self.refresh_inlays(InlayRefreshReason::ExcerptEdited, cx);
+
+ if let Some(project) = &self.project {
+ let project = project.read(cx);
+ let languages_affected = multibuffer
+ .read(cx)
+ .all_buffers()
+ .into_iter()
+ .filter_map(|buffer| {
+ let buffer = buffer.read(cx);
+ let language = buffer.language()?;
+ if project.is_local()
+ && project.language_servers_for_buffer(buffer, cx).count() == 0
+ {
+ None
+ } else {
+ Some(language)
+ }
+ })
+ .cloned()
+ .collect::<HashSet<_>>();
+ if !languages_affected.is_empty() {
+ self.refresh_inlays(
+ InlayRefreshReason::BufferEdited(languages_affected),
+ cx,
+ );
+ }
+ }
}
multi_buffer::Event::ExcerptsAdded {
buffer,
@@ -38,7 +38,7 @@ pub struct CachedExcerptHints {
#[derive(Debug, Clone, Copy)]
pub enum InvalidationStrategy {
RefreshRequested,
- ExcerptEdited,
+ BufferEdited,
None,
}
@@ -94,7 +94,7 @@ impl InvalidationStrategy {
fn should_invalidate(&self) -> bool {
matches!(
self,
- InvalidationStrategy::RefreshRequested | InvalidationStrategy::ExcerptEdited
+ InvalidationStrategy::RefreshRequested | InvalidationStrategy::BufferEdited
)
}
}
@@ -197,7 +197,7 @@ impl InlayHintCache {
pub fn refresh_inlay_hints(
&mut self,
- mut excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Range<usize>)>,
+ mut excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
invalidate: InvalidationStrategy,
cx: &mut ViewContext<Editor>,
) {
@@ -342,105 +342,114 @@ impl InlayHintCache {
fn spawn_new_update_tasks(
editor: &mut Editor,
- excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Range<usize>)>,
+ excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
invalidate: InvalidationStrategy,
update_cache_version: usize,
cx: &mut ViewContext<'_, '_, Editor>,
) {
let visible_hints = Arc::new(editor.visible_inlay_hints(cx));
- for (excerpt_id, (buffer_handle, excerpt_visible_range)) in excerpts_to_query {
- if !excerpt_visible_range.is_empty() {
- let buffer = buffer_handle.read(cx);
- let buffer_snapshot = buffer.snapshot();
- let cached_excerpt_hints = editor.inlay_hint_cache.hints.get(&excerpt_id).cloned();
- if let Some(cached_excerpt_hints) = &cached_excerpt_hints {
- let new_task_buffer_version = buffer_snapshot.version();
- let cached_excerpt_hints = cached_excerpt_hints.read();
- let cached_buffer_version = &cached_excerpt_hints.buffer_version;
- if cached_excerpt_hints.version > update_cache_version
- || cached_buffer_version.changed_since(new_task_buffer_version)
- {
- return;
- }
- if !new_task_buffer_version.changed_since(&cached_buffer_version)
- && !matches!(invalidate, InvalidationStrategy::RefreshRequested)
- {
- return;
- }
- };
+ for (excerpt_id, (buffer_handle, new_task_buffer_version, excerpt_visible_range)) in
+ excerpts_to_query
+ {
+ if excerpt_visible_range.is_empty() {
+ continue;
+ }
+ let buffer = buffer_handle.read(cx);
+ let buffer_snapshot = buffer.snapshot();
+ if buffer_snapshot
+ .version()
+ .changed_since(&new_task_buffer_version)
+ {
+ continue;
+ }
- let buffer_id = buffer.remote_id();
- let excerpt_visible_range_start = buffer.anchor_before(excerpt_visible_range.start);
- let excerpt_visible_range_end = buffer.anchor_after(excerpt_visible_range.end);
-
- let (multi_buffer_snapshot, full_excerpt_range) =
- editor.buffer.update(cx, |multi_buffer, cx| {
- let multi_buffer_snapshot = multi_buffer.snapshot(cx);
- (
- multi_buffer_snapshot,
- multi_buffer
- .excerpts_for_buffer(&buffer_handle, cx)
- .into_iter()
- .find(|(id, _)| id == &excerpt_id)
- .map(|(_, range)| range.context),
- )
- });
+ let cached_excerpt_hints = editor.inlay_hint_cache.hints.get(&excerpt_id).cloned();
+ if let Some(cached_excerpt_hints) = &cached_excerpt_hints {
+ let cached_excerpt_hints = cached_excerpt_hints.read();
+ let cached_buffer_version = &cached_excerpt_hints.buffer_version;
+ if cached_excerpt_hints.version > update_cache_version
+ || cached_buffer_version.changed_since(&new_task_buffer_version)
+ {
+ continue;
+ }
+ if !new_task_buffer_version.changed_since(&cached_buffer_version)
+ && !matches!(invalidate, InvalidationStrategy::RefreshRequested)
+ {
+ continue;
+ }
+ };
- if let Some(full_excerpt_range) = full_excerpt_range {
- let query = ExcerptQuery {
- buffer_id,
- excerpt_id,
- dimensions: ExcerptDimensions {
- excerpt_range_start: full_excerpt_range.start,
- excerpt_range_end: full_excerpt_range.end,
- excerpt_visible_range_start,
- excerpt_visible_range_end,
- },
- cache_version: update_cache_version,
- invalidate,
- };
+ let buffer_id = buffer.remote_id();
+ let excerpt_visible_range_start = buffer.anchor_before(excerpt_visible_range.start);
+ let excerpt_visible_range_end = buffer.anchor_after(excerpt_visible_range.end);
+
+ let (multi_buffer_snapshot, full_excerpt_range) =
+ editor.buffer.update(cx, |multi_buffer, cx| {
+ let multi_buffer_snapshot = multi_buffer.snapshot(cx);
+ (
+ multi_buffer_snapshot,
+ multi_buffer
+ .excerpts_for_buffer(&buffer_handle, cx)
+ .into_iter()
+ .find(|(id, _)| id == &excerpt_id)
+ .map(|(_, range)| range.context),
+ )
+ });
- let new_update_task = |is_refresh_after_regular_task| {
- new_update_task(
- query,
- multi_buffer_snapshot,
- buffer_snapshot,
- Arc::clone(&visible_hints),
- cached_excerpt_hints,
- is_refresh_after_regular_task,
- cx,
- )
- };
- match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
- hash_map::Entry::Occupied(mut o) => {
- let update_task = o.get_mut();
- match (update_task.invalidate, invalidate) {
- (_, InvalidationStrategy::None) => {}
- (
- InvalidationStrategy::ExcerptEdited,
- InvalidationStrategy::RefreshRequested,
- ) if !update_task.task.is_running_rx.is_closed() => {
- update_task.pending_refresh = Some(query);
- }
- _ => {
- o.insert(UpdateTask {
- invalidate,
- cache_version: query.cache_version,
- task: new_update_task(false),
- pending_refresh: None,
- });
- }
+ if let Some(full_excerpt_range) = full_excerpt_range {
+ let query = ExcerptQuery {
+ buffer_id,
+ excerpt_id,
+ dimensions: ExcerptDimensions {
+ excerpt_range_start: full_excerpt_range.start,
+ excerpt_range_end: full_excerpt_range.end,
+ excerpt_visible_range_start,
+ excerpt_visible_range_end,
+ },
+ cache_version: update_cache_version,
+ invalidate,
+ };
+
+ let new_update_task = |is_refresh_after_regular_task| {
+ new_update_task(
+ query,
+ multi_buffer_snapshot,
+ buffer_snapshot,
+ Arc::clone(&visible_hints),
+ cached_excerpt_hints,
+ is_refresh_after_regular_task,
+ cx,
+ )
+ };
+ match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
+ hash_map::Entry::Occupied(mut o) => {
+ let update_task = o.get_mut();
+ match (update_task.invalidate, invalidate) {
+ (_, InvalidationStrategy::None) => {}
+ (
+ InvalidationStrategy::BufferEdited,
+ InvalidationStrategy::RefreshRequested,
+ ) if !update_task.task.is_running_rx.is_closed() => {
+ update_task.pending_refresh = Some(query);
+ }
+ _ => {
+ o.insert(UpdateTask {
+ invalidate,
+ cache_version: query.cache_version,
+ task: new_update_task(false),
+ pending_refresh: None,
+ });
}
- }
- hash_map::Entry::Vacant(v) => {
- v.insert(UpdateTask {
- invalidate,
- cache_version: query.cache_version,
- task: new_update_task(false),
- pending_refresh: None,
- });
}
}
+ hash_map::Entry::Vacant(v) => {
+ v.insert(UpdateTask {
+ invalidate,
+ cache_version: query.cache_version,
+ task: new_update_task(false),
+ pending_refresh: None,
+ });
+ }
}
}
}
@@ -961,6 +970,247 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_no_hint_updates_for_unrelated_language_files(cx: &mut gpui::TestAppContext) {
+ let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]);
+ init_test(cx, |settings| {
+ settings.defaults.inlay_hints = Some(InlayHintSettings {
+ enabled: true,
+ show_type_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Type)),
+ show_parameter_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)),
+ show_other_hints: allowed_hint_kinds.contains(&None),
+ })
+ });
+
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/a",
+ json!({
+ "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out",
+ "other.md": "Test md file with some text",
+ }),
+ )
+ .await;
+ let project = Project::test(fs, ["/a".as_ref()], cx).await;
+ let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+ let worktree_id = workspace.update(cx, |workspace, cx| {
+ workspace.project().read_with(cx, |project, cx| {
+ project.worktrees(cx).next().unwrap().read(cx).id()
+ })
+ });
+
+ let mut rs_fake_servers = None;
+ let mut md_fake_servers = None;
+ for (name, path_suffix) in [("Rust", "rs"), ("Markdown", "md")] {
+ let mut language = Language::new(
+ LanguageConfig {
+ name: name.into(),
+ path_suffixes: vec![path_suffix.to_string()],
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::language()),
+ );
+ let fake_servers = language
+ .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+ name,
+ capabilities: lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..Default::default()
+ },
+ ..Default::default()
+ }))
+ .await;
+ match name {
+ "Rust" => rs_fake_servers = Some(fake_servers),
+ "Markdown" => md_fake_servers = Some(fake_servers),
+ _ => unreachable!(),
+ }
+ project.update(cx, |project, _| {
+ project.languages().add(Arc::new(language));
+ });
+ }
+
+ let _rs_buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer("/a/main.rs", cx)
+ })
+ .await
+ .unwrap();
+ cx.foreground().run_until_parked();
+ cx.foreground().start_waiting();
+ let rs_fake_server = rs_fake_servers.unwrap().next().await.unwrap();
+ let rs_editor = workspace
+ .update(cx, |workspace, cx| {
+ workspace.open_path((worktree_id, "main.rs"), None, true, cx)
+ })
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+ let rs_lsp_request_count = Arc::new(AtomicU32::new(0));
+ rs_fake_server
+ .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
+ let task_lsp_request_count = Arc::clone(&rs_lsp_request_count);
+ async move {
+ assert_eq!(
+ params.text_document.uri,
+ lsp::Url::from_file_path("/a/main.rs").unwrap(),
+ );
+ let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst);
+ Ok(Some(vec![lsp::InlayHint {
+ position: lsp::Position::new(0, i),
+ label: lsp::InlayHintLabel::String(i.to_string()),
+ kind: None,
+ text_edits: None,
+ tooltip: None,
+ padding_left: None,
+ padding_right: None,
+ data: None,
+ }]))
+ }
+ })
+ .next()
+ .await;
+ cx.foreground().run_until_parked();
+ rs_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["0".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Should get its first hints when opening the editor"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(
+ inlay_cache.allowed_hint_kinds, allowed_hint_kinds,
+ "Cache should use editor settings to get the allowed hint kinds"
+ );
+ assert_eq!(
+ inlay_cache.version, 1,
+ "Rust editor update the cache version after every cache/view change"
+ );
+ });
+
+ cx.foreground().run_until_parked();
+ let _md_buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer("/a/other.md", cx)
+ })
+ .await
+ .unwrap();
+ cx.foreground().run_until_parked();
+ cx.foreground().start_waiting();
+ let md_fake_server = md_fake_servers.unwrap().next().await.unwrap();
+ let md_editor = workspace
+ .update(cx, |workspace, cx| {
+ workspace.open_path((worktree_id, "other.md"), None, true, cx)
+ })
+ .await
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap();
+ let md_lsp_request_count = Arc::new(AtomicU32::new(0));
+ md_fake_server
+ .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
+ let task_lsp_request_count = Arc::clone(&md_lsp_request_count);
+ async move {
+ assert_eq!(
+ params.text_document.uri,
+ lsp::Url::from_file_path("/a/other.md").unwrap(),
+ );
+ let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst);
+ Ok(Some(vec![lsp::InlayHint {
+ position: lsp::Position::new(0, i),
+ label: lsp::InlayHintLabel::String(i.to_string()),
+ kind: None,
+ text_edits: None,
+ tooltip: None,
+ padding_left: None,
+ padding_right: None,
+ data: None,
+ }]))
+ }
+ })
+ .next()
+ .await;
+ cx.foreground().run_until_parked();
+ md_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["0".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Markdown editor should have a separate verison, repeating Rust editor rules"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(inlay_cache.allowed_hint_kinds, allowed_hint_kinds);
+ assert_eq!(inlay_cache.version, 1);
+ });
+
+ rs_editor.update(cx, |editor, cx| {
+ editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
+ editor.handle_input("some rs change", cx);
+ });
+ cx.foreground().run_until_parked();
+ rs_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["1".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Rust inlay cache should change after the edit"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(inlay_cache.allowed_hint_kinds, allowed_hint_kinds);
+ assert_eq!(
+ inlay_cache.version, 2,
+ "Every time hint cache changes, cache version should be incremented"
+ );
+ });
+ md_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["0".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Markdown editor should not be affected by Rust editor changes"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(inlay_cache.allowed_hint_kinds, allowed_hint_kinds);
+ assert_eq!(inlay_cache.version, 1);
+ });
+
+ md_editor.update(cx, |editor, cx| {
+ editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
+ editor.handle_input("some md change", cx);
+ });
+ cx.foreground().run_until_parked();
+ md_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["1".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Rust editor should not be affected by Markdown editor changes"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(inlay_cache.allowed_hint_kinds, allowed_hint_kinds);
+ assert_eq!(inlay_cache.version, 2);
+ });
+ rs_editor.update(cx, |editor, cx| {
+ let expected_layers = vec!["1".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Markdown editor should also change independently"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ let inlay_cache = editor.inlay_hint_cache();
+ assert_eq!(inlay_cache.allowed_hint_kinds, allowed_hint_kinds);
+ assert_eq!(inlay_cache.version, 2);
+ });
+ }
+
#[gpui::test]
async fn test_hint_setting_changes(cx: &mut gpui::TestAppContext) {
let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]);
@@ -279,6 +279,9 @@ impl Fs for RealFs {
async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
let buffer_size = text.summary().len.min(10 * 1024);
+ if let Some(path) = path.parent() {
+ self.create_dir(path).await?;
+ }
let file = smol::fs::File::create(path).await?;
let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file);
for chunk in chunks(text, line_ending) {
@@ -1077,6 +1080,9 @@ impl Fs for FakeFs {
self.simulate_random_delay().await;
let path = normalize_path(path);
let content = chunks(text, line_ending).collect();
+ if let Some(path) = path.parent() {
+ self.create_dir(path).await?;
+ }
self.write_file_internal(path, content)?;
Ok(())
}
@@ -64,7 +64,7 @@ itertools = "0.10"
[dev-dependencies]
ctor.workspace = true
env_logger.workspace = true
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
client = { path = "../client", features = ["test-support"] }
collections = { path = "../collections", features = ["test-support"] }
db = { path = "../db", features = ["test-support"] }
@@ -981,6 +981,19 @@ impl LocalWorktree {
})
}
+ /// Find the lowest path in the worktree's datastructures that is an ancestor
+ fn lowest_ancestor(&self, path: &Path) -> PathBuf {
+ let mut lowest_ancestor = None;
+ for path in path.ancestors() {
+ if self.entry_for_path(path).is_some() {
+ lowest_ancestor = Some(path.to_path_buf());
+ break;
+ }
+ }
+
+ lowest_ancestor.unwrap_or_else(|| PathBuf::from(""))
+ }
+
pub fn create_entry(
&self,
path: impl Into<Arc<Path>>,
@@ -988,6 +1001,7 @@ impl LocalWorktree {
cx: &mut ModelContext<Worktree>,
) -> Task<Result<Entry>> {
let path = path.into();
+ let lowest_ancestor = self.lowest_ancestor(&path);
let abs_path = self.absolutize(&path);
let fs = self.fs.clone();
let write = cx.background().spawn(async move {
@@ -1001,10 +1015,31 @@ impl LocalWorktree {
cx.spawn(|this, mut cx| async move {
write.await?;
- this.update(&mut cx, |this, cx| {
- this.as_local_mut().unwrap().refresh_entry(path, None, cx)
- })
- .await
+ let (result, refreshes) = this.update(&mut cx, |this, cx| {
+ let mut refreshes = Vec::<Task<anyhow::Result<Entry>>>::new();
+ let refresh_paths = path.strip_prefix(&lowest_ancestor).unwrap();
+ for refresh_path in refresh_paths.ancestors() {
+ if refresh_path == Path::new("") {
+ continue;
+ }
+ let refresh_full_path = lowest_ancestor.join(refresh_path);
+
+ refreshes.push(this.as_local_mut().unwrap().refresh_entry(
+ refresh_full_path.into(),
+ None,
+ cx,
+ ));
+ }
+ (
+ this.as_local_mut().unwrap().refresh_entry(path, None, cx),
+ refreshes,
+ )
+ });
+ for refresh in refreshes {
+ refresh.await.log_err();
+ }
+
+ result.await
})
}
@@ -2140,6 +2175,7 @@ impl LocalSnapshot {
impl BackgroundScannerState {
fn should_scan_directory(&self, entry: &Entry) -> bool {
(!entry.is_external && !entry.is_ignored)
+ || entry.path.file_name() == Some(&*DOT_GIT)
|| self.scanned_dirs.contains(&entry.id) // If we've ever scanned it, keep scanning
|| self
.paths_to_scan
@@ -2319,6 +2355,7 @@ impl BackgroundScannerState {
.entry_for_id(entry_id)
.map(|entry| RepositoryWorkDirectory(entry.path.clone())) else { continue };
+ log::info!("reload git repository {:?}", dot_git_dir);
let repository = repository.repo_ptr.lock();
let branch = repository.branch_name();
repository.reload_index();
@@ -2359,6 +2396,8 @@ impl BackgroundScannerState {
}
fn build_repository(&mut self, dot_git_path: Arc<Path>, fs: &dyn Fs) -> Option<()> {
+ log::info!("build git repository {:?}", dot_git_path);
+
let work_dir_path: Arc<Path> = dot_git_path.parent().unwrap().into();
// Guard against repositories inside the repository metadata
@@ -3138,8 +3177,6 @@ impl BackgroundScanner {
}
async fn process_events(&mut self, mut abs_paths: Vec<PathBuf>) {
- log::debug!("received fs events {:?}", abs_paths);
-
let root_path = self.state.lock().snapshot.abs_path.clone();
let root_canonical_path = match self.fs.canonicalize(&root_path).await {
Ok(path) => path,
@@ -3150,7 +3187,6 @@ impl BackgroundScanner {
};
let mut relative_paths = Vec::with_capacity(abs_paths.len());
- let mut unloaded_relative_paths = Vec::new();
abs_paths.sort_unstable();
abs_paths.dedup_by(|a, b| a.starts_with(&b));
abs_paths.retain(|abs_path| {
@@ -3173,7 +3209,6 @@ impl BackgroundScanner {
});
if !parent_dir_is_loaded {
log::debug!("ignoring event {relative_path:?} within unloaded directory");
- unloaded_relative_paths.push(relative_path);
return false;
}
@@ -3182,27 +3217,30 @@ impl BackgroundScanner {
}
});
- if !relative_paths.is_empty() {
- let (scan_job_tx, scan_job_rx) = channel::unbounded();
- self.reload_entries_for_paths(
- root_path,
- root_canonical_path,
- &relative_paths,
- abs_paths,
- Some(scan_job_tx.clone()),
- )
- .await;
- drop(scan_job_tx);
- self.scan_dirs(false, scan_job_rx).await;
-
- let (scan_job_tx, scan_job_rx) = channel::unbounded();
- self.update_ignore_statuses(scan_job_tx).await;
- self.scan_dirs(false, scan_job_rx).await;
+ if relative_paths.is_empty() {
+ return;
}
+ log::debug!("received fs events {:?}", relative_paths);
+
+ let (scan_job_tx, scan_job_rx) = channel::unbounded();
+ self.reload_entries_for_paths(
+ root_path,
+ root_canonical_path,
+ &relative_paths,
+ abs_paths,
+ Some(scan_job_tx.clone()),
+ )
+ .await;
+ drop(scan_job_tx);
+ self.scan_dirs(false, scan_job_rx).await;
+
+ let (scan_job_tx, scan_job_rx) = channel::unbounded();
+ self.update_ignore_statuses(scan_job_tx).await;
+ self.scan_dirs(false, scan_job_rx).await;
+
{
let mut state = self.state.lock();
- relative_paths.extend(unloaded_relative_paths);
state.reload_repositories(&relative_paths, self.fs.as_ref());
state.snapshot.completed_scan_id = state.snapshot.scan_id;
for (_, entry_id) in mem::take(&mut state.removed_entry_ids) {
@@ -3610,23 +3648,28 @@ impl BackgroundScanner {
}
}
- let fs_entry = state.insert_entry(fs_entry, self.fs.as_ref());
-
- if let Some(scan_queue_tx) = &scan_queue_tx {
- let mut ancestor_inodes = state.snapshot.ancestor_inodes_for_path(&path);
- if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) {
- ancestor_inodes.insert(metadata.inode);
- smol::block_on(scan_queue_tx.send(ScanJob {
- abs_path,
- path: path.clone(),
- ignore_stack,
- ancestor_inodes,
- is_external: fs_entry.is_external,
- scan_queue: scan_queue_tx.clone(),
- }))
- .unwrap();
+ if let (Some(scan_queue_tx), true) = (&scan_queue_tx, fs_entry.is_dir()) {
+ if state.should_scan_directory(&fs_entry) {
+ let mut ancestor_inodes =
+ state.snapshot.ancestor_inodes_for_path(&path);
+ if !ancestor_inodes.contains(&metadata.inode) {
+ ancestor_inodes.insert(metadata.inode);
+ smol::block_on(scan_queue_tx.send(ScanJob {
+ abs_path,
+ path: path.clone(),
+ ignore_stack,
+ ancestor_inodes,
+ is_external: fs_entry.is_external,
+ scan_queue: scan_queue_tx.clone(),
+ }))
+ .unwrap();
+ }
+ } else {
+ fs_entry.kind = EntryKind::UnloadedDir;
}
}
+
+ state.insert_entry(fs_entry, self.fs.as_ref());
}
Ok(None) => {
self.remove_repo_path(&path, &mut state.snapshot);
@@ -936,6 +936,119 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
);
}
+#[gpui::test]
+async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
+ let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+ let fs_fake = FakeFs::new(cx.background());
+ fs_fake
+ .insert_tree(
+ "/root",
+ json!({
+ "a": {},
+ }),
+ )
+ .await;
+
+ let tree_fake = Worktree::local(
+ client_fake,
+ "/root".as_ref(),
+ true,
+ fs_fake,
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ let entry = tree_fake
+ .update(cx, |tree, cx| {
+ tree.as_local_mut()
+ .unwrap()
+ .create_entry("a/b/c/d.txt".as_ref(), false, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.is_file());
+
+ cx.foreground().run_until_parked();
+ tree_fake.read_with(cx, |tree, _| {
+ assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
+ assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
+ assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
+ });
+
+ let client_real = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+ let fs_real = Arc::new(RealFs);
+ let temp_root = temp_tree(json!({
+ "a": {}
+ }));
+
+ let tree_real = Worktree::local(
+ client_real,
+ temp_root.path(),
+ true,
+ fs_real,
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ let entry = tree_real
+ .update(cx, |tree, cx| {
+ tree.as_local_mut()
+ .unwrap()
+ .create_entry("a/b/c/d.txt".as_ref(), false, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.is_file());
+
+ cx.foreground().run_until_parked();
+ tree_real.read_with(cx, |tree, _| {
+ assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
+ assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
+ assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
+ });
+
+ // Test smallest change
+ let entry = tree_real
+ .update(cx, |tree, cx| {
+ tree.as_local_mut()
+ .unwrap()
+ .create_entry("a/b/c/e.txt".as_ref(), false, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.is_file());
+
+ cx.foreground().run_until_parked();
+ tree_real.read_with(cx, |tree, _| {
+ assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file());
+ });
+
+ // Test largest change
+ let entry = tree_real
+ .update(cx, |tree, cx| {
+ tree.as_local_mut()
+ .unwrap()
+ .create_entry("d/e/f/g.txt".as_ref(), false, cx)
+ })
+ .await
+ .unwrap();
+ assert!(entry.is_file());
+
+ cx.foreground().run_until_parked();
+ tree_real.read_with(cx, |tree, _| {
+ assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file());
+ assert!(tree.entry_for_path("d/e/f").unwrap().is_dir());
+ assert!(tree.entry_for_path("d/e/").unwrap().is_dir());
+ assert!(tree.entry_for_path("d/").unwrap().is_dir());
+ });
+}
+
#[gpui::test(iterations = 100)]
async fn test_random_worktree_operations_during_initial_scan(
cx: &mut TestAppContext,
@@ -1654,37 +1767,37 @@ async fn test_git_status(deterministic: Arc<Deterministic>, cx: &mut TestAppCont
}));
- let tree = Worktree::local(
- build_client(cx),
- root.path(),
- true,
- Arc::new(RealFs),
- Default::default(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
-
- cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
- .await;
-
const A_TXT: &'static str = "a.txt";
const B_TXT: &'static str = "b.txt";
const E_TXT: &'static str = "c/d/e.txt";
const F_TXT: &'static str = "f.txt";
const DOTGITIGNORE: &'static str = ".gitignore";
const BUILD_FILE: &'static str = "target/build_file";
- let project_path: &Path = &Path::new("project");
+ let project_path = Path::new("project");
+ // Set up git repository before creating the worktree.
let work_dir = root.path().join("project");
let mut repo = git_init(work_dir.as_path());
repo.add_ignore_rule(IGNORE_RULE).unwrap();
- git_add(Path::new(A_TXT), &repo);
- git_add(Path::new(E_TXT), &repo);
- git_add(Path::new(DOTGITIGNORE), &repo);
+ git_add(A_TXT, &repo);
+ git_add(E_TXT, &repo);
+ git_add(DOTGITIGNORE, &repo);
git_commit("Initial commit", &repo);
+ let tree = Worktree::local(
+ build_client(cx),
+ root.path(),
+ true,
+ Arc::new(RealFs),
+ Default::default(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
tree.flush_fs_events(cx).await;
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
deterministic.run_until_parked();
// Check that the right git state is observed on startup
@@ -1704,39 +1817,39 @@ async fn test_git_status(deterministic: Arc<Deterministic>, cx: &mut TestAppCont
);
});
+ // Modify a file in the working copy.
std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
-
tree.flush_fs_events(cx).await;
deterministic.run_until_parked();
+ // The worktree detects that the file's git status has changed.
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
-
assert_eq!(
snapshot.status_for_file(project_path.join(A_TXT)),
Some(GitFileStatus::Modified)
);
});
- git_add(Path::new(A_TXT), &repo);
- git_add(Path::new(B_TXT), &repo);
+ // Create a commit in the git repository.
+ git_add(A_TXT, &repo);
+ git_add(B_TXT, &repo);
git_commit("Committing modified and added", &repo);
tree.flush_fs_events(cx).await;
deterministic.run_until_parked();
- // Check that repo only changes are tracked
+ // The worktree detects that the files' git status have changed.
tree.read_with(cx, |tree, _cx| {
let snapshot = tree.snapshot();
-
assert_eq!(
snapshot.status_for_file(project_path.join(F_TXT)),
Some(GitFileStatus::Added)
);
-
assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
});
+ // Modify files in the working copy and perform git operations on other files.
git_reset(0, &repo);
git_remove_index(Path::new(B_TXT), &repo);
git_stash(&mut repo);
@@ -27,6 +27,7 @@ serde_derive.workspace = true
serde_json.workspace = true
anyhow.workspace = true
schemars.workspace = true
+pretty_assertions.workspace = true
unicase = "2.6"
[dev-dependencies]
@@ -64,7 +64,7 @@ pub struct ProjectPanel {
pending_serialization: Task<Option<()>>,
}
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Debug)]
struct Selection {
worktree_id: WorktreeId,
entry_id: ProjectEntryId,
@@ -547,7 +547,7 @@ impl ProjectPanel {
worktree_id,
entry_id: NEW_ENTRY_ID,
});
- let new_path = entry.path.join(&filename);
+ let new_path = entry.path.join(&filename.trim_start_matches("/"));
if path_already_exists(new_path.as_path()) {
return None;
}
@@ -588,6 +588,7 @@ impl ProjectPanel {
if selection.entry_id == edited_entry_id {
selection.worktree_id = worktree_id;
selection.entry_id = new_entry.id;
+ this.expand_to_selection(cx);
}
}
this.update_visible_entries(None, cx);
@@ -965,6 +966,24 @@ impl ProjectPanel {
Some((worktree, entry))
}
+ fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
+ let (worktree, entry) = self.selected_entry(cx)?;
+ let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
+
+ for path in entry.path.ancestors() {
+ let Some(entry) = worktree.entry_for_path(path) else {
+ continue;
+ };
+ if entry.is_dir() {
+ if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
+ expanded_dir_ids.insert(idx, entry.id);
+ }
+ }
+ }
+
+ Some(())
+ }
+
fn update_visible_entries(
&mut self,
new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
@@ -1592,6 +1611,7 @@ impl ClipboardEntry {
mod tests {
use super::*;
use gpui::{TestAppContext, ViewHandle};
+ use pretty_assertions::assert_eq;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
@@ -2002,6 +2022,133 @@ mod tests {
);
}
+ #[gpui::test(iterations = 30)]
+ async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.background());
+ fs.insert_tree(
+ "/root1",
+ json!({
+ ".dockerignore": "",
+ ".git": {
+ "HEAD": "",
+ },
+ "a": {
+ "0": { "q": "", "r": "", "s": "" },
+ "1": { "t": "", "u": "" },
+ "2": { "v": "", "w": "", "x": "", "y": "" },
+ },
+ "b": {
+ "3": { "Q": "" },
+ "4": { "R": "", "S": "", "T": "", "U": "" },
+ },
+ "C": {
+ "5": {},
+ "6": { "V": "", "W": "" },
+ "7": { "X": "" },
+ "8": { "Y": {}, "Z": "" }
+ }
+ }),
+ )
+ .await;
+ fs.insert_tree(
+ "/root2",
+ json!({
+ "d": {
+ "9": ""
+ },
+ "e": {}
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+ let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+ let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+ select_path(&panel, "root1", cx);
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..10, cx),
+ &[
+ "v root1 <== selected",
+ " > .git",
+ " > a",
+ " > b",
+ " > C",
+ " .dockerignore",
+ "v root2",
+ " > d",
+ " > e",
+ ]
+ );
+
+ // Add a file with the root folder selected. The filename editor is placed
+ // before the first file in the root folder.
+ panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
+ cx.read_window(window_id, |cx| {
+ let panel = panel.read(cx);
+ assert!(panel.filename_editor.is_focused(cx));
+ });
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..10, cx),
+ &[
+ "v root1",
+ " > .git",
+ " > a",
+ " > b",
+ " > C",
+ " [EDITOR: ''] <== selected",
+ " .dockerignore",
+ "v root2",
+ " > d",
+ " > e",
+ ]
+ );
+
+ let confirm = panel.update(cx, |panel, cx| {
+ panel.filename_editor.update(cx, |editor, cx| {
+ editor.set_text("/bdir1/dir2/the-new-filename", cx)
+ });
+ panel.confirm(&Confirm, cx).unwrap()
+ });
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..10, cx),
+ &[
+ "v root1",
+ " > .git",
+ " > a",
+ " > b",
+ " > C",
+ " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
+ " .dockerignore",
+ "v root2",
+ " > d",
+ " > e",
+ ]
+ );
+
+ confirm.await.unwrap();
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..13, cx),
+ &[
+ "v root1",
+ " > .git",
+ " > a",
+ " > b",
+ " v bdir1",
+ " v dir2",
+ " the-new-filename <== selected",
+ " > C",
+ " .dockerignore",
+ "v root2",
+ " > d",
+ " > e",
+ ]
+ );
+ }
+
#[gpui::test]
async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -38,5 +38,5 @@ tree-sitter-json = "*"
gpui = { path = "../gpui", features = ["test-support"] }
fs = { path = "../fs", features = ["test-support"] }
indoc.workspace = true
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
unindent.workspace = true
@@ -2489,7 +2489,12 @@ impl ToOffset for Point {
impl ToOffset for usize {
fn to_offset(&self, snapshot: &BufferSnapshot) -> usize {
- assert!(*self <= snapshot.len(), "offset {self} is out of range");
+ assert!(
+ *self <= snapshot.len(),
+ "offset {} is out of range, max allowed is {}",
+ self,
+ snapshot.len()
+ );
*self
}
}