project_panel: Show error when file or directory already exists while renaming or creating new one (#28177)

Closes #14425

<img width="289" alt="image"
src="https://github.com/user-attachments/assets/2994c401-23e3-419a-90fc-1a83959fdf21"
/>

Release Notes:

- Improved the project panel to show an error when a file or directory
already exists while renaming or creating a new one.
This commit is contained in:
Smit Barmase 2025-04-07 08:14:22 +05:30 committed by GitHub
parent 8cfb9beb17
commit d6d9c383cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 143 additions and 16 deletions

View file

@ -22,7 +22,7 @@ use gpui::{
Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
anchored, deferred, div, impl_actions, point, px, size, transparent_black, uniform_list,
};
use indexmap::IndexMap;
use language::DiagnosticSeverity;
@ -53,9 +53,9 @@ use std::{
};
use theme::ThemeSettings;
use ui::{
ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
IndentGuideLayout, KeyBinding, Label, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
Tooltip, prelude::*, v_flex,
Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar,
ScrollbarState, Tooltip, prelude::*, v_flex,
};
use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
use workspace::{
@ -128,6 +128,7 @@ struct EditState {
depth: usize,
processing_filename: Option<String>,
previously_focused: Option<SelectedEntry>,
validation_error: bool,
}
impl EditState {
@ -408,7 +409,11 @@ impl ProjectPanel {
cx.subscribe(
&filename_editor,
|project_panel, _, editor_event, cx| match editor_event {
EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
EditorEvent::BufferEdited => {
project_panel.populate_validation_error(cx);
project_panel.autoscroll(cx);
}
EditorEvent::SelectionsChanged { .. } => {
project_panel.autoscroll(cx);
}
EditorEvent::Blurred => {
@ -1133,14 +1138,58 @@ impl ProjectPanel {
}
}
fn populate_validation_error(&mut self, cx: &mut Context<Self>) {
let edit_state = match self.edit_state.as_mut() {
Some(state) => state,
None => return,
};
let filename = self.filename_editor.read(cx).text(cx);
if !filename.is_empty() {
if let Some(worktree) = self
.project
.read(cx)
.worktree_for_id(edit_state.worktree_id, cx)
{
if let Some(entry) = worktree.read(cx).entry_for_id(edit_state.entry_id) {
if edit_state.is_new_entry() {
let new_path = entry.path.join(filename.trim_start_matches('/'));
if worktree
.read(cx)
.entry_for_path(new_path.as_path())
.is_some()
{
edit_state.validation_error = true;
cx.notify();
return;
}
} else {
let new_path = if let Some(parent) = entry.path.clone().parent() {
parent.join(&filename)
} else {
filename.clone().into()
};
if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path())
{
if existing.id != entry.id {
edit_state.validation_error = true;
cx.notify();
return;
}
}
};
}
}
}
edit_state.validation_error = false;
cx.notify();
}
fn confirm_edit(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let edit_state = self.edit_state.as_mut()?;
window.focus(&self.focus_handle);
let worktree_id = edit_state.worktree_id;
let is_new_entry = edit_state.is_new_entry();
let filename = self.filename_editor.read(cx).text(cx);
@ -1155,7 +1204,6 @@ impl ProjectPanel {
let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
let edit_task;
let edited_entry_id;
if is_new_entry {
@ -1164,7 +1212,11 @@ impl ProjectPanel {
entry_id: NEW_ENTRY_ID,
});
let new_path = entry.path.join(filename.trim_start_matches('/'));
if path_already_exists(new_path.as_path()) {
if worktree
.read(cx)
.entry_for_path(new_path.as_path())
.is_some()
{
return None;
}
@ -1178,7 +1230,10 @@ impl ProjectPanel {
} else {
filename.clone().into()
};
if path_already_exists(new_path.as_path()) {
if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path()) {
if existing.id == entry.id {
window.focus(&self.focus_handle);
}
return None;
}
edited_entry_id = entry.id;
@ -1187,6 +1242,7 @@ impl ProjectPanel {
});
};
window.focus(&self.focus_handle);
edit_state.processing_filename = Some(filename);
cx.notify();
@ -1347,6 +1403,7 @@ impl ProjectPanel {
processing_filename: None,
previously_focused: self.selection,
depth: 0,
validation_error: false,
});
self.filename_editor.update(cx, |editor, cx| {
editor.clear(window, cx);
@ -1396,6 +1453,7 @@ impl ProjectPanel {
processing_filename: None,
previously_focused: None,
depth: 0,
validation_error: false,
});
let file_name = entry
.path
@ -3629,16 +3687,27 @@ impl ProjectPanel {
item_colors.hover
};
let validation_error =
show_editor && self.edit_state.as_ref().is_some_and(|e| e.validation_error);
let border_color =
if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
item_colors.focused
if validation_error {
Color::Error.color(cx)
} else {
item_colors.focused
}
} else {
bg_color
};
let border_hover_color =
if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
item_colors.focused
if validation_error {
Color::Error.color(cx)
} else {
item_colors.focused
}
} else {
bg_hover_color
};
@ -4108,6 +4177,40 @@ impl ProjectPanel {
))
.overflow_x(),
)
.when(
validation_error, |el| {
el
.relative()
.child(
deferred(
// Wizardry of highest order to make error border align with entry border
div()
.occlude()
.absolute()
.top_full()
.left_neg_0p5()
.right_neg_1()
.border_x_1()
.border_color(transparent_black())
.child(
div()
.py_1()
.px_2()
.border_1()
.border_color(Color::Error.color(cx))
.bg(cx.theme().colors().background)
.child(
Label::new(format!("{} already exists", self.filename_editor.read(cx).text(cx)))
.color(Color::Error)
.size(LabelSize::Small)
)
)
)
)
}
)
}
fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {

View file

@ -1835,13 +1835,21 @@ async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
assert!(
panel.confirm_edit(window, cx).is_none(),
"Should not allow to confirm on conflicting new directory name"
)
);
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting new directory name"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src",
"v src <== selected",
" > test"
],
"File list should be unchanged after failed folder create confirmation"
@ -1880,13 +1888,21 @@ async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
assert!(
panel.confirm_edit(window, cx).is_none(),
"Should not allow to confirm on conflicting new file name"
)
);
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting new file name"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" v test <== selected",
" first.rs",
" second.rs",
" third.rs"
@ -1930,6 +1946,14 @@ async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
"Should not allow to confirm on conflicting file rename"
)
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting file rename"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[