1mod project_panel_settings;
2mod utils;
3
4use anyhow::{anyhow, Context as _, Result};
5use client::{ErrorCode, ErrorExt};
6use collections::{hash_map, BTreeSet, HashMap};
7use command_palette_hooks::CommandPaletteFilter;
8use db::kvp::KEY_VALUE_STORE;
9use editor::{
10 items::{
11 entry_diagnostic_aware_icon_decoration_and_color,
12 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
13 },
14 scroll::{Autoscroll, ScrollbarAutoHide},
15 Editor, EditorEvent, EditorSettings, ShowScrollbar,
16};
17use file_icons::FileIcons;
18use git::status::GitSummary;
19use gpui::{
20 actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
21 AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent, Div,
22 DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla,
23 InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton,
24 MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful,
25 Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
26};
27use indexmap::IndexMap;
28use language::DiagnosticSeverity;
29use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
30use project::{
31 relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
32 WorktreeId,
33};
34use project_panel_settings::{
35 ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
36};
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39use settings::{Settings, SettingsStore};
40use smallvec::SmallVec;
41use std::any::TypeId;
42use std::{
43 cell::OnceCell,
44 cmp,
45 collections::HashSet,
46 ffi::OsStr,
47 ops::Range,
48 path::{Path, PathBuf},
49 sync::Arc,
50 time::Duration,
51};
52use theme::ThemeSettings;
53use ui::{
54 prelude::*, v_flex, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind,
55 IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, ListItemSpacing, Scrollbar,
56 ScrollbarState, Tooltip,
57};
58use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
59use workspace::{
60 dock::{DockPosition, Panel, PanelEvent},
61 notifications::{DetachAndPromptErr, NotifyTaskExt},
62 DraggedSelection, OpenInTerminal, PreviewTabsSettings, SelectedEntry, Workspace,
63};
64use worktree::{CreatedEntry, GitEntry, GitEntryRef};
65
66const PROJECT_PANEL_KEY: &str = "ProjectPanel";
67const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
68
69pub struct ProjectPanel {
70 project: Entity<Project>,
71 fs: Arc<dyn Fs>,
72 focus_handle: FocusHandle,
73 scroll_handle: UniformListScrollHandle,
74 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
75 // hovered over the start/end of a list.
76 hover_scroll_task: Option<Task<()>>,
77 visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
78 /// Maps from leaf project entry ID to the currently selected ancestor.
79 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
80 /// project entries (and all non-leaf nodes are guaranteed to be directories).
81 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
82 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
83 last_worktree_root_id: Option<ProjectEntryId>,
84 last_selection_drag_over_entry: Option<ProjectEntryId>,
85 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
86 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
87 unfolded_dir_ids: HashSet<ProjectEntryId>,
88 // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
89 selection: Option<SelectedEntry>,
90 marked_entries: BTreeSet<SelectedEntry>,
91 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
92 edit_state: Option<EditState>,
93 filename_editor: Entity<Editor>,
94 clipboard: Option<ClipboardEntry>,
95 _dragged_entry_destination: Option<Arc<Path>>,
96 workspace: WeakEntity<Workspace>,
97 width: Option<Pixels>,
98 pending_serialization: Task<Option<()>>,
99 show_scrollbar: bool,
100 vertical_scrollbar_state: ScrollbarState,
101 horizontal_scrollbar_state: ScrollbarState,
102 hide_scrollbar_task: Option<Task<()>>,
103 diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
104 max_width_item_index: Option<usize>,
105 // We keep track of the mouse down state on entries so we don't flash the UI
106 // in case a user clicks to open a file.
107 mouse_down: bool,
108 hover_expand_task: Option<Task<()>>,
109}
110
111#[derive(Copy, Clone, Debug)]
112struct FoldedDirectoryDragTarget {
113 entry_id: ProjectEntryId,
114 index: usize,
115 /// Whether we are dragging over the delimiter rather than the component itself.
116 is_delimiter_target: bool,
117}
118
119#[derive(Clone, Debug)]
120struct EditState {
121 worktree_id: WorktreeId,
122 entry_id: ProjectEntryId,
123 leaf_entry_id: Option<ProjectEntryId>,
124 is_dir: bool,
125 depth: usize,
126 processing_filename: Option<String>,
127 previously_focused: Option<SelectedEntry>,
128}
129
130impl EditState {
131 fn is_new_entry(&self) -> bool {
132 self.leaf_entry_id.is_none()
133 }
134}
135
136#[derive(Clone, Debug)]
137enum ClipboardEntry {
138 Copied(BTreeSet<SelectedEntry>),
139 Cut(BTreeSet<SelectedEntry>),
140}
141
142#[derive(Debug, PartialEq, Eq, Clone)]
143struct EntryDetails {
144 filename: String,
145 icon: Option<SharedString>,
146 path: Arc<Path>,
147 depth: usize,
148 kind: EntryKind,
149 is_ignored: bool,
150 is_expanded: bool,
151 is_selected: bool,
152 is_marked: bool,
153 is_editing: bool,
154 is_processing: bool,
155 is_cut: bool,
156 filename_text_color: Color,
157 diagnostic_severity: Option<DiagnosticSeverity>,
158 git_status: GitSummary,
159 is_private: bool,
160 worktree_id: WorktreeId,
161 canonical_path: Option<Box<Path>>,
162}
163
164#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
165#[serde(deny_unknown_fields)]
166struct Delete {
167 #[serde(default)]
168 pub skip_prompt: bool,
169}
170
171#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
172#[serde(deny_unknown_fields)]
173struct Trash {
174 #[serde(default)]
175 pub skip_prompt: bool,
176}
177
178impl_actions!(project_panel, [Delete, Trash]);
179
180actions!(
181 project_panel,
182 [
183 ExpandSelectedEntry,
184 CollapseSelectedEntry,
185 CollapseAllEntries,
186 NewDirectory,
187 NewFile,
188 Copy,
189 Duplicate,
190 RevealInFileManager,
191 RemoveFromProject,
192 OpenWithSystem,
193 Cut,
194 Paste,
195 Rename,
196 Open,
197 OpenPermanent,
198 ToggleFocus,
199 NewSearchInDirectory,
200 UnfoldDirectory,
201 FoldDirectory,
202 SelectParent,
203 SelectNextGitEntry,
204 SelectPrevGitEntry,
205 SelectNextDiagnostic,
206 SelectPrevDiagnostic,
207 SelectNextDirectory,
208 SelectPrevDirectory,
209 ]
210);
211
212#[derive(Debug, Default)]
213struct FoldedAncestors {
214 current_ancestor_depth: usize,
215 ancestors: Vec<ProjectEntryId>,
216}
217
218impl FoldedAncestors {
219 fn max_ancestor_depth(&self) -> usize {
220 self.ancestors.len()
221 }
222}
223
224pub fn init_settings(cx: &mut App) {
225 ProjectPanelSettings::register(cx);
226}
227
228pub fn init(cx: &mut App) {
229 init_settings(cx);
230
231 cx.observe_new(|workspace: &mut Workspace, _, _| {
232 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
233 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
234 });
235 })
236 .detach();
237}
238
239#[derive(Debug)]
240pub enum Event {
241 OpenedEntry {
242 entry_id: ProjectEntryId,
243 focus_opened_item: bool,
244 allow_preview: bool,
245 },
246 SplitEntry {
247 entry_id: ProjectEntryId,
248 },
249 Focus,
250}
251
252#[derive(Serialize, Deserialize)]
253struct SerializedProjectPanel {
254 width: Option<Pixels>,
255}
256
257struct DraggedProjectEntryView {
258 selection: SelectedEntry,
259 details: EntryDetails,
260 click_offset: Point<Pixels>,
261 selections: Arc<BTreeSet<SelectedEntry>>,
262}
263
264struct ItemColors {
265 default: Hsla,
266 hover: Hsla,
267 drag_over: Hsla,
268 marked: Hsla,
269 focused: Hsla,
270}
271
272fn get_item_color(cx: &App) -> ItemColors {
273 let colors = cx.theme().colors();
274
275 ItemColors {
276 default: colors.panel_background,
277 hover: colors.element_hover,
278 marked: colors.element_selected,
279 focused: colors.panel_focused_border,
280 drag_over: colors.drop_target_background,
281 }
282}
283
284impl ProjectPanel {
285 fn new(
286 workspace: &mut Workspace,
287 window: &mut Window,
288 cx: &mut Context<Workspace>,
289 ) -> Entity<Self> {
290 let project = workspace.project().clone();
291 let project_panel = cx.new(|cx| {
292 let focus_handle = cx.focus_handle();
293 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
294 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
295 this.focus_out(window, cx);
296 this.hide_scrollbar(window, cx);
297 })
298 .detach();
299 cx.subscribe(&project, |this, project, event, cx| match event {
300 project::Event::ActiveEntryChanged(Some(entry_id)) => {
301 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
302 this.reveal_entry(project.clone(), *entry_id, true, cx);
303 }
304 }
305 project::Event::ActiveEntryChanged(None) => {
306 this.marked_entries.clear();
307 }
308 project::Event::RevealInProjectPanel(entry_id) => {
309 this.reveal_entry(project.clone(), *entry_id, false, cx);
310 cx.emit(PanelEvent::Activate);
311 }
312 project::Event::ActivateProjectPanel => {
313 cx.emit(PanelEvent::Activate);
314 }
315 project::Event::DiskBasedDiagnosticsFinished { .. }
316 | project::Event::DiagnosticsUpdated { .. } => {
317 if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
318 {
319 this.update_diagnostics(cx);
320 cx.notify();
321 }
322 }
323 project::Event::WorktreeRemoved(id) => {
324 this.expanded_dir_ids.remove(id);
325 this.update_visible_entries(None, cx);
326 cx.notify();
327 }
328 project::Event::WorktreeUpdatedGitRepositories(_)
329 | project::Event::WorktreeUpdatedEntries(_, _)
330 | project::Event::WorktreeAdded(_)
331 | project::Event::WorktreeOrderChanged => {
332 this.update_visible_entries(None, cx);
333 cx.notify();
334 }
335 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
336 if let Some((worktree, expanded_dir_ids)) = project
337 .read(cx)
338 .worktree_for_id(*worktree_id, cx)
339 .zip(this.expanded_dir_ids.get_mut(&worktree_id))
340 {
341 let worktree = worktree.read(cx);
342
343 let Some(entry) = worktree.entry_for_id(*entry_id) else {
344 return;
345 };
346 let include_ignored_dirs = !entry.is_ignored;
347
348 let mut dirs_to_expand = vec![*entry_id];
349 while let Some(current_id) = dirs_to_expand.pop() {
350 let Some(current_entry) = worktree.entry_for_id(current_id) else {
351 continue;
352 };
353 for child in worktree.child_entries(¤t_entry.path) {
354 if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
355 continue;
356 }
357
358 dirs_to_expand.push(child.id);
359
360 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
361 expanded_dir_ids.insert(ix, child.id);
362 }
363 this.unfolded_dir_ids.insert(child.id);
364 }
365 }
366 this.update_visible_entries(None, cx);
367 cx.notify();
368 }
369 }
370 _ => {}
371 })
372 .detach();
373
374 let trash_action = [TypeId::of::<Trash>()];
375 let is_remote = project.read(cx).is_via_collab();
376
377 if is_remote {
378 CommandPaletteFilter::update_global(cx, |filter, _cx| {
379 filter.hide_action_types(&trash_action);
380 });
381 }
382
383 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
384
385 cx.subscribe(
386 &filename_editor,
387 |project_panel, _, editor_event, cx| match editor_event {
388 EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
389 project_panel.autoscroll(cx);
390 }
391 EditorEvent::Blurred => {
392 if project_panel
393 .edit_state
394 .as_ref()
395 .map_or(false, |state| state.processing_filename.is_none())
396 {
397 project_panel.edit_state = None;
398 project_panel.update_visible_entries(None, cx);
399 cx.notify();
400 }
401 }
402 _ => {}
403 },
404 )
405 .detach();
406
407 cx.observe_global::<FileIcons>(|_, cx| {
408 cx.notify();
409 })
410 .detach();
411
412 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
413 cx.observe_global::<SettingsStore>(move |this, cx| {
414 let new_settings = *ProjectPanelSettings::get_global(cx);
415 if project_panel_settings != new_settings {
416 project_panel_settings = new_settings;
417 this.update_diagnostics(cx);
418 cx.notify();
419 }
420 })
421 .detach();
422
423 let scroll_handle = UniformListScrollHandle::new();
424 let mut this = Self {
425 project: project.clone(),
426 hover_scroll_task: None,
427 fs: workspace.app_state().fs.clone(),
428 focus_handle,
429 visible_entries: Default::default(),
430 ancestors: Default::default(),
431 folded_directory_drag_target: None,
432 last_worktree_root_id: Default::default(),
433 last_external_paths_drag_over_entry: None,
434 last_selection_drag_over_entry: None,
435 expanded_dir_ids: Default::default(),
436 unfolded_dir_ids: Default::default(),
437 selection: None,
438 marked_entries: Default::default(),
439 edit_state: None,
440 context_menu: None,
441 filename_editor,
442 clipboard: None,
443 _dragged_entry_destination: None,
444 workspace: workspace.weak_handle(),
445 width: None,
446 pending_serialization: Task::ready(None),
447 show_scrollbar: !Self::should_autohide_scrollbar(cx),
448 hide_scrollbar_task: None,
449 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
450 .parent_entity(&cx.entity()),
451 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
452 .parent_entity(&cx.entity()),
453 max_width_item_index: None,
454 diagnostics: Default::default(),
455 scroll_handle,
456 mouse_down: false,
457 hover_expand_task: None,
458 };
459 this.update_visible_entries(None, cx);
460
461 this
462 });
463
464 cx.subscribe_in(&project_panel, window, {
465 let project_panel = project_panel.downgrade();
466 move |workspace, _, event, window, cx| match event {
467 &Event::OpenedEntry {
468 entry_id,
469 focus_opened_item,
470 allow_preview,
471 } => {
472 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
473 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
474 let file_path = entry.path.clone();
475 let worktree_id = worktree.read(cx).id();
476 let entry_id = entry.id;
477 let is_via_ssh = project.read(cx).is_via_ssh();
478
479 workspace
480 .open_path_preview(
481 ProjectPath {
482 worktree_id,
483 path: file_path.clone(),
484 },
485 None,
486 focus_opened_item,
487 allow_preview,
488 true,
489 window, cx,
490 )
491 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
492 match e.error_code() {
493 ErrorCode::Disconnected => if is_via_ssh {
494 Some("Disconnected from SSH host".to_string())
495 } else {
496 Some("Disconnected from remote project".to_string())
497 },
498 ErrorCode::UnsharedItem => Some(format!(
499 "{} is not shared by the host. This could be because it has been marked as `private`",
500 file_path.display()
501 )),
502 _ => None,
503 }
504 });
505
506 if let Some(project_panel) = project_panel.upgrade() {
507 // Always select and mark the entry, regardless of whether it is opened or not.
508 project_panel.update(cx, |project_panel, _| {
509 let entry = SelectedEntry { worktree_id, entry_id };
510 project_panel.marked_entries.clear();
511 project_panel.marked_entries.insert(entry);
512 project_panel.selection = Some(entry);
513 });
514 if !focus_opened_item {
515 let focus_handle = project_panel.read(cx).focus_handle.clone();
516 window.focus(&focus_handle);
517 }
518 }
519 }
520 }
521 }
522 &Event::SplitEntry { entry_id } => {
523 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
524 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
525 workspace
526 .split_path(
527 ProjectPath {
528 worktree_id: worktree.read(cx).id(),
529 path: entry.path.clone(),
530 },
531 window, cx,
532 )
533 .detach_and_log_err(cx);
534 }
535 }
536 }
537
538 _ => {}
539 }
540 })
541 .detach();
542
543 project_panel
544 }
545
546 pub async fn load(
547 workspace: WeakEntity<Workspace>,
548 mut cx: AsyncWindowContext,
549 ) -> Result<Entity<Self>> {
550 let serialized_panel = cx
551 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
552 .await
553 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
554 .log_err()
555 .flatten()
556 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
557 .transpose()
558 .log_err()
559 .flatten();
560
561 workspace.update_in(&mut cx, |workspace, window, cx| {
562 let panel = ProjectPanel::new(workspace, window, cx);
563 if let Some(serialized_panel) = serialized_panel {
564 panel.update(cx, |panel, cx| {
565 panel.width = serialized_panel.width.map(|px| px.round());
566 cx.notify();
567 });
568 }
569 panel
570 })
571 }
572
573 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
574 let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
575 Default::default();
576 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
577
578 if show_diagnostics_setting != ShowDiagnostics::Off {
579 self.project
580 .read(cx)
581 .diagnostic_summaries(false, cx)
582 .filter_map(|(path, _, diagnostic_summary)| {
583 if diagnostic_summary.error_count > 0 {
584 Some((path, DiagnosticSeverity::ERROR))
585 } else if show_diagnostics_setting == ShowDiagnostics::All
586 && diagnostic_summary.warning_count > 0
587 {
588 Some((path, DiagnosticSeverity::WARNING))
589 } else {
590 None
591 }
592 })
593 .for_each(|(project_path, diagnostic_severity)| {
594 let mut path_buffer = PathBuf::new();
595 Self::update_strongest_diagnostic_severity(
596 &mut diagnostics,
597 &project_path,
598 path_buffer.clone(),
599 diagnostic_severity,
600 );
601
602 for component in project_path.path.components() {
603 path_buffer.push(component);
604 Self::update_strongest_diagnostic_severity(
605 &mut diagnostics,
606 &project_path,
607 path_buffer.clone(),
608 diagnostic_severity,
609 );
610 }
611 });
612 }
613 self.diagnostics = diagnostics;
614 }
615
616 fn update_strongest_diagnostic_severity(
617 diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
618 project_path: &ProjectPath,
619 path_buffer: PathBuf,
620 diagnostic_severity: DiagnosticSeverity,
621 ) {
622 diagnostics
623 .entry((project_path.worktree_id, path_buffer.clone()))
624 .and_modify(|strongest_diagnostic_severity| {
625 *strongest_diagnostic_severity =
626 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
627 })
628 .or_insert(diagnostic_severity);
629 }
630
631 fn serialize(&mut self, cx: &mut Context<Self>) {
632 let width = self.width;
633 self.pending_serialization = cx.background_spawn(
634 async move {
635 KEY_VALUE_STORE
636 .write_kvp(
637 PROJECT_PANEL_KEY.into(),
638 serde_json::to_string(&SerializedProjectPanel { width })?,
639 )
640 .await?;
641 anyhow::Ok(())
642 }
643 .log_err(),
644 );
645 }
646
647 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
648 if !self.focus_handle.contains_focused(window, cx) {
649 cx.emit(Event::Focus);
650 }
651 }
652
653 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
654 if !self.focus_handle.is_focused(window) {
655 self.confirm(&Confirm, window, cx);
656 }
657 }
658
659 fn deploy_context_menu(
660 &mut self,
661 position: Point<Pixels>,
662 entry_id: ProjectEntryId,
663 window: &mut Window,
664 cx: &mut Context<Self>,
665 ) {
666 let project = self.project.read(cx);
667
668 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
669 id
670 } else {
671 return;
672 };
673
674 self.selection = Some(SelectedEntry {
675 worktree_id,
676 entry_id,
677 });
678
679 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
680 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
681 let worktree = worktree.read(cx);
682 let is_root = Some(entry) == worktree.root_entry();
683 let is_dir = entry.is_dir();
684 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
685 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
686 let is_read_only = project.is_read_only(cx);
687 let is_remote = project.is_via_collab();
688 let is_local = project.is_local();
689
690 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
691 menu.context(self.focus_handle.clone()).map(|menu| {
692 if is_read_only {
693 menu.when(is_dir, |menu| {
694 menu.action("Search Inside", Box::new(NewSearchInDirectory))
695 })
696 } else {
697 menu.action("New File", Box::new(NewFile))
698 .action("New Folder", Box::new(NewDirectory))
699 .separator()
700 .when(is_local && cfg!(target_os = "macos"), |menu| {
701 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
702 })
703 .when(is_local && cfg!(not(target_os = "macos")), |menu| {
704 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
705 })
706 .when(is_local, |menu| {
707 menu.action("Open in Default App", Box::new(OpenWithSystem))
708 })
709 .action("Open in Terminal", Box::new(OpenInTerminal))
710 .when(is_dir, |menu| {
711 menu.separator()
712 .action("Find in Folder…", Box::new(NewSearchInDirectory))
713 })
714 .when(is_unfoldable, |menu| {
715 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
716 })
717 .when(is_foldable, |menu| {
718 menu.action("Fold Directory", Box::new(FoldDirectory))
719 })
720 .separator()
721 .action("Cut", Box::new(Cut))
722 .action("Copy", Box::new(Copy))
723 .action("Duplicate", Box::new(Duplicate))
724 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
725 .map(|menu| {
726 if self.clipboard.as_ref().is_some() {
727 menu.action("Paste", Box::new(Paste))
728 } else {
729 menu.disabled_action("Paste", Box::new(Paste))
730 }
731 })
732 .separator()
733 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
734 .action(
735 "Copy Relative Path",
736 Box::new(zed_actions::workspace::CopyRelativePath),
737 )
738 .separator()
739 .when(!is_root || !cfg!(target_os = "windows"), |menu| {
740 menu.action("Rename", Box::new(Rename))
741 })
742 .when(!is_root & !is_remote, |menu| {
743 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
744 })
745 .when(!is_root, |menu| {
746 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
747 })
748 .when(!is_remote & is_root, |menu| {
749 menu.separator()
750 .action(
751 "Add Folder to Project…",
752 Box::new(workspace::AddFolderToProject),
753 )
754 .action("Remove from Project", Box::new(RemoveFromProject))
755 })
756 .when(is_root, |menu| {
757 menu.separator()
758 .action("Collapse All", Box::new(CollapseAllEntries))
759 })
760 }
761 })
762 });
763
764 window.focus(&context_menu.focus_handle(cx));
765 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
766 this.context_menu.take();
767 cx.notify();
768 });
769 self.context_menu = Some((context_menu, position, subscription));
770 }
771
772 cx.notify();
773 }
774
775 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
776 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
777 return false;
778 }
779
780 if let Some(parent_path) = entry.path.parent() {
781 let snapshot = worktree.snapshot();
782 let mut child_entries = snapshot.child_entries(parent_path);
783 if let Some(child) = child_entries.next() {
784 if child_entries.next().is_none() {
785 return child.kind.is_dir();
786 }
787 }
788 };
789 false
790 }
791
792 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
793 if entry.is_dir() {
794 let snapshot = worktree.snapshot();
795
796 let mut child_entries = snapshot.child_entries(&entry.path);
797 if let Some(child) = child_entries.next() {
798 if child_entries.next().is_none() {
799 return child.kind.is_dir();
800 }
801 }
802 }
803 false
804 }
805
806 fn expand_selected_entry(
807 &mut self,
808 _: &ExpandSelectedEntry,
809 window: &mut Window,
810 cx: &mut Context<Self>,
811 ) {
812 if let Some((worktree, entry)) = self.selected_entry(cx) {
813 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
814 if folded_ancestors.current_ancestor_depth > 0 {
815 folded_ancestors.current_ancestor_depth -= 1;
816 cx.notify();
817 return;
818 }
819 }
820 if entry.is_dir() {
821 let worktree_id = worktree.id();
822 let entry_id = entry.id;
823 let expanded_dir_ids =
824 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
825 expanded_dir_ids
826 } else {
827 return;
828 };
829
830 match expanded_dir_ids.binary_search(&entry_id) {
831 Ok(_) => self.select_next(&SelectNext, window, cx),
832 Err(ix) => {
833 self.project.update(cx, |project, cx| {
834 project.expand_entry(worktree_id, entry_id, cx);
835 });
836
837 expanded_dir_ids.insert(ix, entry_id);
838 self.update_visible_entries(None, cx);
839 cx.notify();
840 }
841 }
842 }
843 }
844 }
845
846 fn collapse_selected_entry(
847 &mut self,
848 _: &CollapseSelectedEntry,
849 _: &mut Window,
850 cx: &mut Context<Self>,
851 ) {
852 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
853 return;
854 };
855 self.collapse_entry(entry.clone(), worktree, cx)
856 }
857
858 fn collapse_entry(&mut self, entry: Entry, worktree: Entity<Worktree>, cx: &mut Context<Self>) {
859 let worktree = worktree.read(cx);
860 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
861 if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
862 folded_ancestors.current_ancestor_depth += 1;
863 cx.notify();
864 return;
865 }
866 }
867 let worktree_id = worktree.id();
868 let expanded_dir_ids =
869 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
870 expanded_dir_ids
871 } else {
872 return;
873 };
874
875 let mut entry = &entry;
876 loop {
877 let entry_id = entry.id;
878 match expanded_dir_ids.binary_search(&entry_id) {
879 Ok(ix) => {
880 expanded_dir_ids.remove(ix);
881 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
882 cx.notify();
883 break;
884 }
885 Err(_) => {
886 if let Some(parent_entry) =
887 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
888 {
889 entry = parent_entry;
890 } else {
891 break;
892 }
893 }
894 }
895 }
896 }
897
898 pub fn collapse_all_entries(
899 &mut self,
900 _: &CollapseAllEntries,
901 _: &mut Window,
902 cx: &mut Context<Self>,
903 ) {
904 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
905 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
906 self.expanded_dir_ids
907 .retain(|_, expanded_entries| expanded_entries.is_empty());
908 self.update_visible_entries(None, cx);
909 cx.notify();
910 }
911
912 fn toggle_expanded(
913 &mut self,
914 entry_id: ProjectEntryId,
915 window: &mut Window,
916 cx: &mut Context<Self>,
917 ) {
918 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
919 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
920 self.project.update(cx, |project, cx| {
921 match expanded_dir_ids.binary_search(&entry_id) {
922 Ok(ix) => {
923 expanded_dir_ids.remove(ix);
924 }
925 Err(ix) => {
926 project.expand_entry(worktree_id, entry_id, cx);
927 expanded_dir_ids.insert(ix, entry_id);
928 }
929 }
930 });
931 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
932 window.focus(&self.focus_handle);
933 cx.notify();
934 }
935 }
936 }
937
938 fn toggle_expand_all(
939 &mut self,
940 entry_id: ProjectEntryId,
941 window: &mut Window,
942 cx: &mut Context<Self>,
943 ) {
944 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
945 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
946 match expanded_dir_ids.binary_search(&entry_id) {
947 Ok(_ix) => {
948 self.collapse_all_for_entry(worktree_id, entry_id, cx);
949 }
950 Err(_ix) => {
951 self.expand_all_for_entry(worktree_id, entry_id, cx);
952 }
953 }
954 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
955 window.focus(&self.focus_handle);
956 cx.notify();
957 }
958 }
959 }
960
961 fn expand_all_for_entry(
962 &mut self,
963 worktree_id: WorktreeId,
964 entry_id: ProjectEntryId,
965 cx: &mut Context<Self>,
966 ) {
967 self.project.update(cx, |project, cx| {
968 if let Some((worktree, expanded_dir_ids)) = project
969 .worktree_for_id(worktree_id, cx)
970 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
971 {
972 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
973 task.detach();
974 }
975
976 let worktree = worktree.read(cx);
977
978 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
979 loop {
980 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
981 expanded_dir_ids.insert(ix, entry.id);
982 }
983
984 if let Some(parent_entry) =
985 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
986 {
987 entry = parent_entry;
988 } else {
989 break;
990 }
991 }
992 }
993 }
994 });
995 }
996
997 fn collapse_all_for_entry(
998 &mut self,
999 worktree_id: WorktreeId,
1000 entry_id: ProjectEntryId,
1001 cx: &mut Context<Self>,
1002 ) {
1003 self.project.update(cx, |project, cx| {
1004 if let Some((worktree, expanded_dir_ids)) = project
1005 .worktree_for_id(worktree_id, cx)
1006 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1007 {
1008 let worktree = worktree.read(cx);
1009 let mut dirs_to_collapse = vec![entry_id];
1010 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1011 while let Some(current_id) = dirs_to_collapse.pop() {
1012 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1013 continue;
1014 };
1015 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1016 expanded_dir_ids.remove(ix);
1017 }
1018 if auto_fold_enabled {
1019 self.unfolded_dir_ids.remove(¤t_id);
1020 }
1021 for child in worktree.child_entries(¤t_entry.path) {
1022 if child.is_dir() {
1023 dirs_to_collapse.push(child.id);
1024 }
1025 }
1026 }
1027 }
1028 });
1029 }
1030
1031 fn select_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
1032 if let Some(edit_state) = &self.edit_state {
1033 if edit_state.processing_filename.is_none() {
1034 self.filename_editor.update(cx, |editor, cx| {
1035 editor.move_to_beginning_of_line(
1036 &editor::actions::MoveToBeginningOfLine {
1037 stop_at_soft_wraps: false,
1038 stop_at_indent: false,
1039 },
1040 window,
1041 cx,
1042 );
1043 });
1044 return;
1045 }
1046 }
1047 if let Some(selection) = self.selection {
1048 let (mut worktree_ix, mut entry_ix, _) =
1049 self.index_for_selection(selection).unwrap_or_default();
1050 if entry_ix > 0 {
1051 entry_ix -= 1;
1052 } else if worktree_ix > 0 {
1053 worktree_ix -= 1;
1054 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
1055 } else {
1056 return;
1057 }
1058
1059 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
1060 let selection = SelectedEntry {
1061 worktree_id: *worktree_id,
1062 entry_id: worktree_entries[entry_ix].id,
1063 };
1064 self.selection = Some(selection);
1065 if window.modifiers().shift {
1066 self.marked_entries.insert(selection);
1067 }
1068 self.autoscroll(cx);
1069 cx.notify();
1070 } else {
1071 self.select_first(&SelectFirst {}, window, cx);
1072 }
1073 }
1074
1075 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1076 if let Some(task) = self.confirm_edit(window, cx) {
1077 task.detach_and_notify_err(window, cx);
1078 }
1079 }
1080
1081 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1082 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
1083 self.open_internal(true, !preview_tabs_enabled, window, cx);
1084 }
1085
1086 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1087 self.open_internal(false, true, window, cx);
1088 }
1089
1090 fn open_internal(
1091 &mut self,
1092 allow_preview: bool,
1093 focus_opened_item: bool,
1094 window: &mut Window,
1095 cx: &mut Context<Self>,
1096 ) {
1097 if let Some((_, entry)) = self.selected_entry(cx) {
1098 if entry.is_file() {
1099 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1100 cx.notify();
1101 } else {
1102 self.toggle_expanded(entry.id, window, cx);
1103 }
1104 }
1105 }
1106
1107 fn confirm_edit(
1108 &mut self,
1109 window: &mut Window,
1110 cx: &mut Context<Self>,
1111 ) -> Option<Task<Result<()>>> {
1112 let edit_state = self.edit_state.as_mut()?;
1113 window.focus(&self.focus_handle);
1114
1115 let worktree_id = edit_state.worktree_id;
1116 let is_new_entry = edit_state.is_new_entry();
1117 let filename = self.filename_editor.read(cx).text(cx);
1118 #[cfg(not(target_os = "windows"))]
1119 let filename_indicates_dir = filename.ends_with("/");
1120 // On Windows, path separator could be either `/` or `\`.
1121 #[cfg(target_os = "windows")]
1122 let filename_indicates_dir = filename.ends_with("/") || filename.ends_with("\\");
1123 edit_state.is_dir =
1124 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1125 let is_dir = edit_state.is_dir;
1126 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1127 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1128
1129 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
1130 let edit_task;
1131 let edited_entry_id;
1132 if is_new_entry {
1133 self.selection = Some(SelectedEntry {
1134 worktree_id,
1135 entry_id: NEW_ENTRY_ID,
1136 });
1137 let new_path = entry.path.join(filename.trim_start_matches('/'));
1138 if path_already_exists(new_path.as_path()) {
1139 return None;
1140 }
1141
1142 edited_entry_id = NEW_ENTRY_ID;
1143 edit_task = self.project.update(cx, |project, cx| {
1144 project.create_entry((worktree_id, &new_path), is_dir, cx)
1145 });
1146 } else {
1147 let new_path = if let Some(parent) = entry.path.clone().parent() {
1148 parent.join(&filename)
1149 } else {
1150 filename.clone().into()
1151 };
1152 if path_already_exists(new_path.as_path()) {
1153 return None;
1154 }
1155 edited_entry_id = entry.id;
1156 edit_task = self.project.update(cx, |project, cx| {
1157 project.rename_entry(entry.id, new_path.as_path(), cx)
1158 });
1159 };
1160
1161 edit_state.processing_filename = Some(filename);
1162 cx.notify();
1163
1164 Some(cx.spawn_in(window, |project_panel, mut cx| async move {
1165 let new_entry = edit_task.await;
1166 project_panel.update(&mut cx, |project_panel, cx| {
1167 project_panel.edit_state = None;
1168 cx.notify();
1169 })?;
1170
1171 match new_entry {
1172 Err(e) => {
1173 project_panel.update(&mut cx, |project_panel, cx| {
1174 project_panel.marked_entries.clear();
1175 project_panel.update_visible_entries(None, cx);
1176 }).ok();
1177 Err(e)?;
1178 }
1179 Ok(CreatedEntry::Included(new_entry)) => {
1180 project_panel.update(&mut cx, |project_panel, cx| {
1181 if let Some(selection) = &mut project_panel.selection {
1182 if selection.entry_id == edited_entry_id {
1183 selection.worktree_id = worktree_id;
1184 selection.entry_id = new_entry.id;
1185 project_panel.marked_entries.clear();
1186 project_panel.expand_to_selection(cx);
1187 }
1188 }
1189 project_panel.update_visible_entries(None, cx);
1190 if is_new_entry && !is_dir {
1191 project_panel.open_entry(new_entry.id, true, false, cx);
1192 }
1193 cx.notify();
1194 })?;
1195 }
1196 Ok(CreatedEntry::Excluded { abs_path }) => {
1197 if let Some(open_task) = project_panel
1198 .update_in(&mut cx, |project_panel, window, cx| {
1199 project_panel.marked_entries.clear();
1200 project_panel.update_visible_entries(None, cx);
1201
1202 if is_dir {
1203 project_panel.project.update(cx, |_, cx| {
1204 cx.emit(project::Event::Toast {
1205 notification_id: "excluded-directory".into(),
1206 message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1207 })
1208 });
1209 None
1210 } else {
1211 project_panel
1212 .workspace
1213 .update(cx, |workspace, cx| {
1214 workspace.open_abs_path(abs_path, true, window, cx)
1215 })
1216 .ok()
1217 }
1218 })
1219 .ok()
1220 .flatten()
1221 {
1222 let _ = open_task.await?;
1223 }
1224 }
1225 }
1226 Ok(())
1227 }))
1228 }
1229
1230 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1231 let previous_edit_state = self.edit_state.take();
1232 self.update_visible_entries(None, cx);
1233 self.marked_entries.clear();
1234
1235 if let Some(previously_focused) =
1236 previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1237 {
1238 self.selection = Some(previously_focused);
1239 self.autoscroll(cx);
1240 }
1241
1242 window.focus(&self.focus_handle);
1243 cx.notify();
1244 }
1245
1246 fn open_entry(
1247 &mut self,
1248 entry_id: ProjectEntryId,
1249 focus_opened_item: bool,
1250 allow_preview: bool,
1251
1252 cx: &mut Context<Self>,
1253 ) {
1254 cx.emit(Event::OpenedEntry {
1255 entry_id,
1256 focus_opened_item,
1257 allow_preview,
1258 });
1259 }
1260
1261 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut Context<Self>) {
1262 cx.emit(Event::SplitEntry { entry_id });
1263 }
1264
1265 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
1266 self.add_entry(false, window, cx)
1267 }
1268
1269 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
1270 self.add_entry(true, window, cx)
1271 }
1272
1273 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
1274 if let Some(SelectedEntry {
1275 worktree_id,
1276 entry_id,
1277 }) = self.selection
1278 {
1279 let directory_id;
1280 let new_entry_id = self.resolve_entry(entry_id);
1281 if let Some((worktree, expanded_dir_ids)) = self
1282 .project
1283 .read(cx)
1284 .worktree_for_id(worktree_id, cx)
1285 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1286 {
1287 let worktree = worktree.read(cx);
1288 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1289 loop {
1290 if entry.is_dir() {
1291 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1292 expanded_dir_ids.insert(ix, entry.id);
1293 }
1294 directory_id = entry.id;
1295 break;
1296 } else {
1297 if let Some(parent_path) = entry.path.parent() {
1298 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1299 entry = parent_entry;
1300 continue;
1301 }
1302 }
1303 return;
1304 }
1305 }
1306 } else {
1307 return;
1308 };
1309 } else {
1310 return;
1311 };
1312 self.marked_entries.clear();
1313 self.edit_state = Some(EditState {
1314 worktree_id,
1315 entry_id: directory_id,
1316 leaf_entry_id: None,
1317 is_dir,
1318 processing_filename: None,
1319 previously_focused: self.selection,
1320 depth: 0,
1321 });
1322 self.filename_editor.update(cx, |editor, cx| {
1323 editor.clear(window, cx);
1324 window.focus(&editor.focus_handle(cx));
1325 });
1326 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1327 self.autoscroll(cx);
1328 cx.notify();
1329 }
1330 }
1331
1332 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1333 if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1334 ancestors
1335 .ancestors
1336 .get(ancestors.current_ancestor_depth)
1337 .copied()
1338 .unwrap_or(leaf_entry_id)
1339 } else {
1340 leaf_entry_id
1341 }
1342 }
1343
1344 fn rename_impl(
1345 &mut self,
1346 selection: Option<Range<usize>>,
1347 window: &mut Window,
1348 cx: &mut Context<Self>,
1349 ) {
1350 if let Some(SelectedEntry {
1351 worktree_id,
1352 entry_id,
1353 }) = self.selection
1354 {
1355 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1356 let sub_entry_id = self.unflatten_entry_id(entry_id);
1357 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1358 #[cfg(target_os = "windows")]
1359 if Some(entry) == worktree.read(cx).root_entry() {
1360 return;
1361 }
1362 self.edit_state = Some(EditState {
1363 worktree_id,
1364 entry_id: sub_entry_id,
1365 leaf_entry_id: Some(entry_id),
1366 is_dir: entry.is_dir(),
1367 processing_filename: None,
1368 previously_focused: None,
1369 depth: 0,
1370 });
1371 let file_name = entry
1372 .path
1373 .file_name()
1374 .map(|s| s.to_string_lossy())
1375 .unwrap_or_default()
1376 .to_string();
1377 let selection = selection.unwrap_or_else(|| {
1378 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1379 let selection_end =
1380 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1381 0..selection_end
1382 });
1383 self.filename_editor.update(cx, |editor, cx| {
1384 editor.set_text(file_name, window, cx);
1385 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1386 s.select_ranges([selection])
1387 });
1388 window.focus(&editor.focus_handle(cx));
1389 });
1390 self.update_visible_entries(None, cx);
1391 self.autoscroll(cx);
1392 cx.notify();
1393 }
1394 }
1395 }
1396 }
1397
1398 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
1399 self.rename_impl(None, window, cx);
1400 }
1401
1402 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
1403 self.remove(true, action.skip_prompt, window, cx);
1404 }
1405
1406 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
1407 self.remove(false, action.skip_prompt, window, cx);
1408 }
1409
1410 fn remove(
1411 &mut self,
1412 trash: bool,
1413 skip_prompt: bool,
1414 window: &mut Window,
1415 cx: &mut Context<ProjectPanel>,
1416 ) {
1417 maybe!({
1418 let items_to_delete = self.disjoint_entries(cx);
1419 if items_to_delete.is_empty() {
1420 return None;
1421 }
1422 let project = self.project.read(cx);
1423
1424 let mut dirty_buffers = 0;
1425 let file_paths = items_to_delete
1426 .iter()
1427 .filter_map(|selection| {
1428 let project_path = project.path_for_entry(selection.entry_id, cx)?;
1429 dirty_buffers +=
1430 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1431 Some((
1432 selection.entry_id,
1433 project_path
1434 .path
1435 .file_name()?
1436 .to_string_lossy()
1437 .into_owned(),
1438 ))
1439 })
1440 .collect::<Vec<_>>();
1441 if file_paths.is_empty() {
1442 return None;
1443 }
1444 let answer = if !skip_prompt {
1445 let operation = if trash { "Trash" } else { "Delete" };
1446 let prompt = match file_paths.first() {
1447 Some((_, path)) if file_paths.len() == 1 => {
1448 let unsaved_warning = if dirty_buffers > 0 {
1449 "\n\nIt has unsaved changes, which will be lost."
1450 } else {
1451 ""
1452 };
1453
1454 format!("{operation} {path}?{unsaved_warning}")
1455 }
1456 _ => {
1457 const CUTOFF_POINT: usize = 10;
1458 let names = if file_paths.len() > CUTOFF_POINT {
1459 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1460 let mut paths = file_paths
1461 .iter()
1462 .map(|(_, path)| path.clone())
1463 .take(CUTOFF_POINT)
1464 .collect::<Vec<_>>();
1465 paths.truncate(CUTOFF_POINT);
1466 if truncated_path_counts == 1 {
1467 paths.push(".. 1 file not shown".into());
1468 } else {
1469 paths.push(format!(".. {} files not shown", truncated_path_counts));
1470 }
1471 paths
1472 } else {
1473 file_paths.iter().map(|(_, path)| path.clone()).collect()
1474 };
1475 let unsaved_warning = if dirty_buffers == 0 {
1476 String::new()
1477 } else if dirty_buffers == 1 {
1478 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1479 } else {
1480 format!("\n\n{dirty_buffers} of these have unsaved changes, which will be lost.")
1481 };
1482
1483 format!(
1484 "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1485 operation.to_lowercase(),
1486 file_paths.len(),
1487 names.join("\n")
1488 )
1489 }
1490 };
1491 Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
1492 } else {
1493 None
1494 };
1495 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1496 cx.spawn_in(window, |panel, mut cx| async move {
1497 if let Some(answer) = answer {
1498 if answer.await != Ok(0) {
1499 return anyhow::Ok(());
1500 }
1501 }
1502 for (entry_id, _) in file_paths {
1503 panel
1504 .update(&mut cx, |panel, cx| {
1505 panel
1506 .project
1507 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1508 .context("no such entry")
1509 })??
1510 .await?;
1511 }
1512 panel.update_in(&mut cx, |panel, window, cx| {
1513 if let Some(next_selection) = next_selection {
1514 panel.selection = Some(next_selection);
1515 panel.autoscroll(cx);
1516 } else {
1517 panel.select_last(&SelectLast {}, window, cx);
1518 }
1519 })?;
1520 Ok(())
1521 })
1522 .detach_and_log_err(cx);
1523 Some(())
1524 });
1525 }
1526
1527 fn find_next_selection_after_deletion(
1528 &self,
1529 sanitized_entries: BTreeSet<SelectedEntry>,
1530 cx: &mut Context<Self>,
1531 ) -> Option<SelectedEntry> {
1532 if sanitized_entries.is_empty() {
1533 return None;
1534 }
1535
1536 let project = self.project.read(cx);
1537 let (worktree_id, worktree) = sanitized_entries
1538 .iter()
1539 .map(|entry| entry.worktree_id)
1540 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1541 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1542
1543 let marked_entries_in_worktree = sanitized_entries
1544 .iter()
1545 .filter(|e| e.worktree_id == worktree_id)
1546 .collect::<HashSet<_>>();
1547 let latest_entry = marked_entries_in_worktree
1548 .iter()
1549 .max_by(|a, b| {
1550 match (
1551 worktree.entry_for_id(a.entry_id),
1552 worktree.entry_for_id(b.entry_id),
1553 ) {
1554 (Some(a), Some(b)) => {
1555 compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1556 }
1557 _ => cmp::Ordering::Equal,
1558 }
1559 })
1560 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1561
1562 let parent_path = latest_entry.path.parent()?;
1563 let parent_entry = worktree.entry_for_path(parent_path)?;
1564
1565 // Remove all siblings that are being deleted except the last marked entry
1566 let mut siblings: Vec<_> = worktree
1567 .snapshot()
1568 .child_entries(parent_path)
1569 .with_git_statuses()
1570 .filter(|sibling| {
1571 sibling.id == latest_entry.id
1572 || !marked_entries_in_worktree.contains(&&SelectedEntry {
1573 worktree_id,
1574 entry_id: sibling.id,
1575 })
1576 })
1577 .map(|entry| entry.to_owned())
1578 .collect();
1579
1580 project::sort_worktree_entries(&mut siblings);
1581 let sibling_entry_index = siblings
1582 .iter()
1583 .position(|sibling| sibling.id == latest_entry.id)?;
1584
1585 if let Some(next_sibling) = sibling_entry_index
1586 .checked_add(1)
1587 .and_then(|i| siblings.get(i))
1588 {
1589 return Some(SelectedEntry {
1590 worktree_id,
1591 entry_id: next_sibling.id,
1592 });
1593 }
1594 if let Some(prev_sibling) = sibling_entry_index
1595 .checked_sub(1)
1596 .and_then(|i| siblings.get(i))
1597 {
1598 return Some(SelectedEntry {
1599 worktree_id,
1600 entry_id: prev_sibling.id,
1601 });
1602 }
1603 // No neighbour sibling found, fall back to parent
1604 Some(SelectedEntry {
1605 worktree_id,
1606 entry_id: parent_entry.id,
1607 })
1608 }
1609
1610 fn unfold_directory(&mut self, _: &UnfoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1611 if let Some((worktree, entry)) = self.selected_entry(cx) {
1612 self.unfolded_dir_ids.insert(entry.id);
1613
1614 let snapshot = worktree.snapshot();
1615 let mut parent_path = entry.path.parent();
1616 while let Some(path) = parent_path {
1617 if let Some(parent_entry) = worktree.entry_for_path(path) {
1618 let mut children_iter = snapshot.child_entries(path);
1619
1620 if children_iter.by_ref().take(2).count() > 1 {
1621 break;
1622 }
1623
1624 self.unfolded_dir_ids.insert(parent_entry.id);
1625 parent_path = path.parent();
1626 } else {
1627 break;
1628 }
1629 }
1630
1631 self.update_visible_entries(None, cx);
1632 self.autoscroll(cx);
1633 cx.notify();
1634 }
1635 }
1636
1637 fn fold_directory(&mut self, _: &FoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1638 if let Some((worktree, entry)) = self.selected_entry(cx) {
1639 self.unfolded_dir_ids.remove(&entry.id);
1640
1641 let snapshot = worktree.snapshot();
1642 let mut path = &*entry.path;
1643 loop {
1644 let mut child_entries_iter = snapshot.child_entries(path);
1645 if let Some(child) = child_entries_iter.next() {
1646 if child_entries_iter.next().is_none() && child.is_dir() {
1647 self.unfolded_dir_ids.remove(&child.id);
1648 path = &*child.path;
1649 } else {
1650 break;
1651 }
1652 } else {
1653 break;
1654 }
1655 }
1656
1657 self.update_visible_entries(None, cx);
1658 self.autoscroll(cx);
1659 cx.notify();
1660 }
1661 }
1662
1663 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1664 if let Some(edit_state) = &self.edit_state {
1665 if edit_state.processing_filename.is_none() {
1666 self.filename_editor.update(cx, |editor, cx| {
1667 editor.move_to_end_of_line(
1668 &editor::actions::MoveToEndOfLine {
1669 stop_at_soft_wraps: false,
1670 },
1671 window,
1672 cx,
1673 );
1674 });
1675 return;
1676 }
1677 }
1678 if let Some(selection) = self.selection {
1679 let (mut worktree_ix, mut entry_ix, _) =
1680 self.index_for_selection(selection).unwrap_or_default();
1681 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1682 if entry_ix + 1 < worktree_entries.len() {
1683 entry_ix += 1;
1684 } else {
1685 worktree_ix += 1;
1686 entry_ix = 0;
1687 }
1688 }
1689
1690 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1691 {
1692 if let Some(entry) = worktree_entries.get(entry_ix) {
1693 let selection = SelectedEntry {
1694 worktree_id: *worktree_id,
1695 entry_id: entry.id,
1696 };
1697 self.selection = Some(selection);
1698 if window.modifiers().shift {
1699 self.marked_entries.insert(selection);
1700 }
1701
1702 self.autoscroll(cx);
1703 cx.notify();
1704 }
1705 }
1706 } else {
1707 self.select_first(&SelectFirst {}, window, cx);
1708 }
1709 }
1710
1711 fn select_prev_diagnostic(
1712 &mut self,
1713 _: &SelectPrevDiagnostic,
1714 _: &mut Window,
1715 cx: &mut Context<Self>,
1716 ) {
1717 let selection = self.find_entry(
1718 self.selection.as_ref(),
1719 true,
1720 |entry, worktree_id| {
1721 (self.selection.is_none()
1722 || self.selection.is_some_and(|selection| {
1723 if selection.worktree_id == worktree_id {
1724 selection.entry_id != entry.id
1725 } else {
1726 true
1727 }
1728 }))
1729 && entry.is_file()
1730 && self
1731 .diagnostics
1732 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1733 },
1734 cx,
1735 );
1736
1737 if let Some(selection) = selection {
1738 self.selection = Some(selection);
1739 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1740 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1741 self.autoscroll(cx);
1742 cx.notify();
1743 }
1744 }
1745
1746 fn select_next_diagnostic(
1747 &mut self,
1748 _: &SelectNextDiagnostic,
1749 _: &mut Window,
1750 cx: &mut Context<Self>,
1751 ) {
1752 let selection = self.find_entry(
1753 self.selection.as_ref(),
1754 false,
1755 |entry, worktree_id| {
1756 (self.selection.is_none()
1757 || self.selection.is_some_and(|selection| {
1758 if selection.worktree_id == worktree_id {
1759 selection.entry_id != entry.id
1760 } else {
1761 true
1762 }
1763 }))
1764 && entry.is_file()
1765 && self
1766 .diagnostics
1767 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1768 },
1769 cx,
1770 );
1771
1772 if let Some(selection) = selection {
1773 self.selection = Some(selection);
1774 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1775 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1776 self.autoscroll(cx);
1777 cx.notify();
1778 }
1779 }
1780
1781 fn select_prev_git_entry(
1782 &mut self,
1783 _: &SelectPrevGitEntry,
1784 _: &mut Window,
1785 cx: &mut Context<Self>,
1786 ) {
1787 let selection = self.find_entry(
1788 self.selection.as_ref(),
1789 true,
1790 |entry, worktree_id| {
1791 (self.selection.is_none()
1792 || self.selection.is_some_and(|selection| {
1793 if selection.worktree_id == worktree_id {
1794 selection.entry_id != entry.id
1795 } else {
1796 true
1797 }
1798 }))
1799 && entry.is_file()
1800 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1801 },
1802 cx,
1803 );
1804
1805 if let Some(selection) = selection {
1806 self.selection = Some(selection);
1807 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1808 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1809 self.autoscroll(cx);
1810 cx.notify();
1811 }
1812 }
1813
1814 fn select_prev_directory(
1815 &mut self,
1816 _: &SelectPrevDirectory,
1817 _: &mut Window,
1818 cx: &mut Context<Self>,
1819 ) {
1820 let selection = self.find_visible_entry(
1821 self.selection.as_ref(),
1822 true,
1823 |entry, worktree_id| {
1824 (self.selection.is_none()
1825 || self.selection.is_some_and(|selection| {
1826 if selection.worktree_id == worktree_id {
1827 selection.entry_id != entry.id
1828 } else {
1829 true
1830 }
1831 }))
1832 && entry.is_dir()
1833 },
1834 cx,
1835 );
1836
1837 if let Some(selection) = selection {
1838 self.selection = Some(selection);
1839 self.autoscroll(cx);
1840 cx.notify();
1841 }
1842 }
1843
1844 fn select_next_directory(
1845 &mut self,
1846 _: &SelectNextDirectory,
1847 _: &mut Window,
1848 cx: &mut Context<Self>,
1849 ) {
1850 let selection = self.find_visible_entry(
1851 self.selection.as_ref(),
1852 false,
1853 |entry, worktree_id| {
1854 (self.selection.is_none()
1855 || self.selection.is_some_and(|selection| {
1856 if selection.worktree_id == worktree_id {
1857 selection.entry_id != entry.id
1858 } else {
1859 true
1860 }
1861 }))
1862 && entry.is_dir()
1863 },
1864 cx,
1865 );
1866
1867 if let Some(selection) = selection {
1868 self.selection = Some(selection);
1869 self.autoscroll(cx);
1870 cx.notify();
1871 }
1872 }
1873
1874 fn select_next_git_entry(
1875 &mut self,
1876 _: &SelectNextGitEntry,
1877 _: &mut Window,
1878 cx: &mut Context<Self>,
1879 ) {
1880 let selection = self.find_entry(
1881 self.selection.as_ref(),
1882 false,
1883 |entry, worktree_id| {
1884 (self.selection.is_none()
1885 || self.selection.is_some_and(|selection| {
1886 if selection.worktree_id == worktree_id {
1887 selection.entry_id != entry.id
1888 } else {
1889 true
1890 }
1891 }))
1892 && entry.is_file()
1893 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1894 },
1895 cx,
1896 );
1897
1898 if let Some(selection) = selection {
1899 self.selection = Some(selection);
1900 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1901 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1902 self.autoscroll(cx);
1903 cx.notify();
1904 }
1905 }
1906
1907 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
1908 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1909 if let Some(parent) = entry.path.parent() {
1910 let worktree = worktree.read(cx);
1911 if let Some(parent_entry) = worktree.entry_for_path(parent) {
1912 self.selection = Some(SelectedEntry {
1913 worktree_id: worktree.id(),
1914 entry_id: parent_entry.id,
1915 });
1916 self.autoscroll(cx);
1917 cx.notify();
1918 }
1919 }
1920 } else {
1921 self.select_first(&SelectFirst {}, window, cx);
1922 }
1923 }
1924
1925 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
1926 let worktree = self
1927 .visible_entries
1928 .first()
1929 .and_then(|(worktree_id, _, _)| {
1930 self.project.read(cx).worktree_for_id(*worktree_id, cx)
1931 });
1932 if let Some(worktree) = worktree {
1933 let worktree = worktree.read(cx);
1934 let worktree_id = worktree.id();
1935 if let Some(root_entry) = worktree.root_entry() {
1936 let selection = SelectedEntry {
1937 worktree_id,
1938 entry_id: root_entry.id,
1939 };
1940 self.selection = Some(selection);
1941 if window.modifiers().shift {
1942 self.marked_entries.insert(selection);
1943 }
1944 self.autoscroll(cx);
1945 cx.notify();
1946 }
1947 }
1948 }
1949
1950 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
1951 if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.last() {
1952 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
1953 if let (Some(worktree), Some(entry)) = (worktree, visible_worktree_entries.last()) {
1954 let worktree = worktree.read(cx);
1955 if let Some(entry) = worktree.entry_for_id(entry.id) {
1956 let selection = SelectedEntry {
1957 worktree_id: *worktree_id,
1958 entry_id: entry.id,
1959 };
1960 self.selection = Some(selection);
1961 self.autoscroll(cx);
1962 cx.notify();
1963 }
1964 }
1965 }
1966 }
1967
1968 fn autoscroll(&mut self, cx: &mut Context<Self>) {
1969 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1970 self.scroll_handle
1971 .scroll_to_item(index, ScrollStrategy::Center);
1972 cx.notify();
1973 }
1974 }
1975
1976 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
1977 let entries = self.disjoint_entries(cx);
1978 if !entries.is_empty() {
1979 self.clipboard = Some(ClipboardEntry::Cut(entries));
1980 cx.notify();
1981 }
1982 }
1983
1984 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
1985 let entries = self.disjoint_entries(cx);
1986 if !entries.is_empty() {
1987 self.clipboard = Some(ClipboardEntry::Copied(entries));
1988 cx.notify();
1989 }
1990 }
1991
1992 fn create_paste_path(
1993 &self,
1994 source: &SelectedEntry,
1995 (worktree, target_entry): (Entity<Worktree>, &Entry),
1996 cx: &App,
1997 ) -> Option<(PathBuf, Option<Range<usize>>)> {
1998 let mut new_path = target_entry.path.to_path_buf();
1999 // If we're pasting into a file, or a directory into itself, go up one level.
2000 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
2001 new_path.pop();
2002 }
2003 let clipboard_entry_file_name = self
2004 .project
2005 .read(cx)
2006 .path_for_entry(source.entry_id, cx)?
2007 .path
2008 .file_name()?
2009 .to_os_string();
2010 new_path.push(&clipboard_entry_file_name);
2011 let extension = new_path.extension().map(|e| e.to_os_string());
2012 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
2013 let file_name_len = file_name_without_extension.to_string_lossy().len();
2014 let mut disambiguation_range = None;
2015 let mut ix = 0;
2016 {
2017 let worktree = worktree.read(cx);
2018 while worktree.entry_for_path(&new_path).is_some() {
2019 new_path.pop();
2020
2021 let mut new_file_name = file_name_without_extension.to_os_string();
2022
2023 let disambiguation = " copy";
2024 let mut disambiguation_len = disambiguation.len();
2025
2026 new_file_name.push(disambiguation);
2027
2028 if ix > 0 {
2029 let extra_disambiguation = format!(" {}", ix);
2030 disambiguation_len += extra_disambiguation.len();
2031
2032 new_file_name.push(extra_disambiguation);
2033 }
2034 if let Some(extension) = extension.as_ref() {
2035 new_file_name.push(".");
2036 new_file_name.push(extension);
2037 }
2038
2039 new_path.push(new_file_name);
2040 disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
2041 ix += 1;
2042 }
2043 }
2044 Some((new_path, disambiguation_range))
2045 }
2046
2047 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2048 maybe!({
2049 let (worktree, entry) = self.selected_entry_handle(cx)?;
2050 let entry = entry.clone();
2051 let worktree_id = worktree.read(cx).id();
2052 let clipboard_entries = self
2053 .clipboard
2054 .as_ref()
2055 .filter(|clipboard| !clipboard.items().is_empty())?;
2056 enum PasteTask {
2057 Rename(Task<Result<CreatedEntry>>),
2058 Copy(Task<Result<Option<Entry>>>),
2059 }
2060 let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
2061 IndexMap::default();
2062 let mut disambiguation_range = None;
2063 let clip_is_cut = clipboard_entries.is_cut();
2064 for clipboard_entry in clipboard_entries.items() {
2065 let (new_path, new_disambiguation_range) =
2066 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
2067 let clip_entry_id = clipboard_entry.entry_id;
2068 let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
2069 let relative_worktree_source_path = if !is_same_worktree {
2070 let target_base_path = worktree.read(cx).abs_path();
2071 let clipboard_project_path =
2072 self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
2073 let clipboard_abs_path = self
2074 .project
2075 .read(cx)
2076 .absolute_path(&clipboard_project_path, cx)?;
2077 Some(relativize_path(
2078 &target_base_path,
2079 clipboard_abs_path.as_path(),
2080 ))
2081 } else {
2082 None
2083 };
2084 let task = if clip_is_cut && is_same_worktree {
2085 let task = self.project.update(cx, |project, cx| {
2086 project.rename_entry(clip_entry_id, new_path, cx)
2087 });
2088 PasteTask::Rename(task)
2089 } else {
2090 let entry_id = if is_same_worktree {
2091 clip_entry_id
2092 } else {
2093 entry.id
2094 };
2095 let task = self.project.update(cx, |project, cx| {
2096 project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
2097 });
2098 PasteTask::Copy(task)
2099 };
2100 let needs_delete = !is_same_worktree && clip_is_cut;
2101 paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
2102 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2103 }
2104
2105 let item_count = paste_entry_tasks.len();
2106
2107 cx.spawn_in(window, |project_panel, mut cx| async move {
2108 let mut last_succeed = None;
2109 let mut need_delete_ids = Vec::new();
2110 for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
2111 match task {
2112 PasteTask::Rename(task) => {
2113 if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
2114 last_succeed = Some(entry);
2115 }
2116 }
2117 PasteTask::Copy(task) => {
2118 if let Some(Some(entry)) = task.await.log_err() {
2119 last_succeed = Some(entry);
2120 if need_delete {
2121 need_delete_ids.push(entry_id);
2122 }
2123 }
2124 }
2125 }
2126 }
2127 // remove entry for cut in difference worktree
2128 for entry_id in need_delete_ids {
2129 project_panel
2130 .update(&mut cx, |project_panel, cx| {
2131 project_panel
2132 .project
2133 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
2134 .ok_or_else(|| anyhow!("no such entry"))
2135 })??
2136 .await?;
2137 }
2138 // update selection
2139 if let Some(entry) = last_succeed {
2140 project_panel
2141 .update_in(&mut cx, |project_panel, window, cx| {
2142 project_panel.selection = Some(SelectedEntry {
2143 worktree_id,
2144 entry_id: entry.id,
2145 });
2146
2147 if item_count == 1 {
2148 // open entry if not dir, and only focus if rename is not pending
2149 if !entry.is_dir() {
2150 project_panel.open_entry(
2151 entry.id,
2152 disambiguation_range.is_none(),
2153 false,
2154 cx,
2155 );
2156 }
2157
2158 // if only one entry was pasted and it was disambiguated, open the rename editor
2159 if disambiguation_range.is_some() {
2160 cx.defer_in(window, |this, window, cx| {
2161 this.rename_impl(disambiguation_range, window, cx);
2162 });
2163 }
2164 }
2165 })
2166 .ok();
2167 }
2168
2169 anyhow::Ok(())
2170 })
2171 .detach_and_log_err(cx);
2172
2173 self.expand_entry(worktree_id, entry.id, cx);
2174 Some(())
2175 });
2176 }
2177
2178 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
2179 self.copy(&Copy {}, window, cx);
2180 self.paste(&Paste {}, window, cx);
2181 }
2182
2183 fn copy_path(
2184 &mut self,
2185 _: &zed_actions::workspace::CopyPath,
2186 _: &mut Window,
2187 cx: &mut Context<Self>,
2188 ) {
2189 let abs_file_paths = {
2190 let project = self.project.read(cx);
2191 self.effective_entries()
2192 .into_iter()
2193 .filter_map(|entry| {
2194 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2195 Some(
2196 project
2197 .worktree_for_id(entry.worktree_id, cx)?
2198 .read(cx)
2199 .abs_path()
2200 .join(entry_path)
2201 .to_string_lossy()
2202 .to_string(),
2203 )
2204 })
2205 .collect::<Vec<_>>()
2206 };
2207 if !abs_file_paths.is_empty() {
2208 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
2209 }
2210 }
2211
2212 fn copy_relative_path(
2213 &mut self,
2214 _: &zed_actions::workspace::CopyRelativePath,
2215 _: &mut Window,
2216 cx: &mut Context<Self>,
2217 ) {
2218 let file_paths = {
2219 let project = self.project.read(cx);
2220 self.effective_entries()
2221 .into_iter()
2222 .filter_map(|entry| {
2223 Some(
2224 project
2225 .path_for_entry(entry.entry_id, cx)?
2226 .path
2227 .to_string_lossy()
2228 .to_string(),
2229 )
2230 })
2231 .collect::<Vec<_>>()
2232 };
2233 if !file_paths.is_empty() {
2234 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
2235 }
2236 }
2237
2238 fn reveal_in_finder(
2239 &mut self,
2240 _: &RevealInFileManager,
2241 _: &mut Window,
2242 cx: &mut Context<Self>,
2243 ) {
2244 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2245 cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
2246 }
2247 }
2248
2249 fn remove_from_project(
2250 &mut self,
2251 _: &RemoveFromProject,
2252 _window: &mut Window,
2253 cx: &mut Context<Self>,
2254 ) {
2255 for entry in self.effective_entries().iter() {
2256 let worktree_id = entry.worktree_id;
2257 self.project
2258 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2259 }
2260 }
2261
2262 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
2263 if let Some((worktree, entry)) = self.selected_entry(cx) {
2264 let abs_path = worktree.abs_path().join(&entry.path);
2265 cx.open_with_system(&abs_path);
2266 }
2267 }
2268
2269 fn open_in_terminal(
2270 &mut self,
2271 _: &OpenInTerminal,
2272 window: &mut Window,
2273 cx: &mut Context<Self>,
2274 ) {
2275 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2276 let abs_path = match &entry.canonical_path {
2277 Some(canonical_path) => Some(canonical_path.to_path_buf()),
2278 None => worktree.read(cx).absolutize(&entry.path).ok(),
2279 };
2280
2281 let working_directory = if entry.is_dir() {
2282 abs_path
2283 } else {
2284 abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2285 };
2286 if let Some(working_directory) = working_directory {
2287 window.dispatch_action(
2288 workspace::OpenTerminal { working_directory }.boxed_clone(),
2289 cx,
2290 )
2291 }
2292 }
2293 }
2294
2295 pub fn new_search_in_directory(
2296 &mut self,
2297 _: &NewSearchInDirectory,
2298 window: &mut Window,
2299 cx: &mut Context<Self>,
2300 ) {
2301 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2302 let dir_path = if entry.is_dir() {
2303 entry.path.clone()
2304 } else {
2305 // entry is a file, use its parent directory
2306 match entry.path.parent() {
2307 Some(parent) => Arc::from(parent),
2308 None => {
2309 // File at root, open search with empty filter
2310 self.workspace
2311 .update(cx, |workspace, cx| {
2312 search::ProjectSearchView::new_search_in_directory(
2313 workspace,
2314 Path::new(""),
2315 window,
2316 cx,
2317 );
2318 })
2319 .ok();
2320 return;
2321 }
2322 }
2323 };
2324
2325 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2326 let dir_path = if include_root {
2327 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2328 full_path.push(&dir_path);
2329 Arc::from(full_path)
2330 } else {
2331 dir_path
2332 };
2333
2334 self.workspace
2335 .update(cx, |workspace, cx| {
2336 search::ProjectSearchView::new_search_in_directory(
2337 workspace, &dir_path, window, cx,
2338 );
2339 })
2340 .ok();
2341 }
2342 }
2343
2344 fn move_entry(
2345 &mut self,
2346 entry_to_move: ProjectEntryId,
2347 destination: ProjectEntryId,
2348 destination_is_file: bool,
2349 cx: &mut Context<Self>,
2350 ) {
2351 if self
2352 .project
2353 .read(cx)
2354 .entry_is_worktree_root(entry_to_move, cx)
2355 {
2356 self.move_worktree_root(entry_to_move, destination, cx)
2357 } else {
2358 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2359 }
2360 }
2361
2362 fn move_worktree_root(
2363 &mut self,
2364 entry_to_move: ProjectEntryId,
2365 destination: ProjectEntryId,
2366 cx: &mut Context<Self>,
2367 ) {
2368 self.project.update(cx, |project, cx| {
2369 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2370 return;
2371 };
2372 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2373 return;
2374 };
2375
2376 let worktree_id = worktree_to_move.read(cx).id();
2377 let destination_id = destination_worktree.read(cx).id();
2378
2379 project
2380 .move_worktree(worktree_id, destination_id, cx)
2381 .log_err();
2382 });
2383 }
2384
2385 fn move_worktree_entry(
2386 &mut self,
2387 entry_to_move: ProjectEntryId,
2388 destination: ProjectEntryId,
2389 destination_is_file: bool,
2390 cx: &mut Context<Self>,
2391 ) {
2392 if entry_to_move == destination {
2393 return;
2394 }
2395
2396 let destination_worktree = self.project.update(cx, |project, cx| {
2397 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2398 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2399
2400 let mut destination_path = destination_entry_path.as_ref();
2401 if destination_is_file {
2402 destination_path = destination_path.parent()?;
2403 }
2404
2405 let mut new_path = destination_path.to_path_buf();
2406 new_path.push(entry_path.path.file_name()?);
2407 if new_path != entry_path.path.as_ref() {
2408 let task = project.rename_entry(entry_to_move, new_path, cx);
2409 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2410 }
2411
2412 project.worktree_id_for_entry(destination, cx)
2413 });
2414
2415 if let Some(destination_worktree) = destination_worktree {
2416 self.expand_entry(destination_worktree, destination, cx);
2417 }
2418 }
2419
2420 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2421 let mut entry_index = 0;
2422 let mut visible_entries_index = 0;
2423 for (worktree_index, (worktree_id, worktree_entries, _)) in
2424 self.visible_entries.iter().enumerate()
2425 {
2426 if *worktree_id == selection.worktree_id {
2427 for entry in worktree_entries {
2428 if entry.id == selection.entry_id {
2429 return Some((worktree_index, entry_index, visible_entries_index));
2430 } else {
2431 visible_entries_index += 1;
2432 entry_index += 1;
2433 }
2434 }
2435 break;
2436 } else {
2437 visible_entries_index += worktree_entries.len();
2438 }
2439 }
2440 None
2441 }
2442
2443 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2444 let marked_entries = self.effective_entries();
2445 let mut sanitized_entries = BTreeSet::new();
2446 if marked_entries.is_empty() {
2447 return sanitized_entries;
2448 }
2449
2450 let project = self.project.read(cx);
2451 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2452 .into_iter()
2453 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2454 .fold(HashMap::default(), |mut map, entry| {
2455 map.entry(entry.worktree_id).or_default().push(entry);
2456 map
2457 });
2458
2459 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2460 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2461 let worktree = worktree.read(cx);
2462 let marked_dir_paths = marked_entries
2463 .iter()
2464 .filter_map(|entry| {
2465 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2466 if entry.is_dir() {
2467 Some(entry.path.as_ref())
2468 } else {
2469 None
2470 }
2471 })
2472 })
2473 .collect::<BTreeSet<_>>();
2474
2475 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2476 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2477 return false;
2478 };
2479 let entry_path = entry_info.path.as_ref();
2480 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2481 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2482 });
2483 !inside_marked_dir
2484 }));
2485 }
2486 }
2487
2488 sanitized_entries
2489 }
2490
2491 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2492 if let Some(selection) = self.selection {
2493 let selection = SelectedEntry {
2494 entry_id: self.resolve_entry(selection.entry_id),
2495 worktree_id: selection.worktree_id,
2496 };
2497
2498 // Default to using just the selected item when nothing is marked.
2499 if self.marked_entries.is_empty() {
2500 return BTreeSet::from([selection]);
2501 }
2502
2503 // Allow operating on the selected item even when something else is marked,
2504 // making it easier to perform one-off actions without clearing a mark.
2505 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2506 return BTreeSet::from([selection]);
2507 }
2508 }
2509
2510 // Return only marked entries since we've already handled special cases where
2511 // only selection should take precedence. At this point, marked entries may or
2512 // may not include the current selection, which is intentional.
2513 self.marked_entries
2514 .iter()
2515 .map(|entry| SelectedEntry {
2516 entry_id: self.resolve_entry(entry.entry_id),
2517 worktree_id: entry.worktree_id,
2518 })
2519 .collect::<BTreeSet<_>>()
2520 }
2521
2522 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2523 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2524 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2525 self.ancestors
2526 .get(&id)
2527 .and_then(|ancestors| {
2528 if ancestors.current_ancestor_depth == 0 {
2529 return None;
2530 }
2531 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2532 })
2533 .copied()
2534 .unwrap_or(id)
2535 }
2536
2537 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2538 let (worktree, entry) = self.selected_entry_handle(cx)?;
2539 Some((worktree.read(cx), entry))
2540 }
2541
2542 /// Compared to selected_entry, this function resolves to the currently
2543 /// selected subentry if dir auto-folding is enabled.
2544 fn selected_sub_entry<'a>(
2545 &self,
2546 cx: &'a App,
2547 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2548 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2549
2550 let resolved_id = self.resolve_entry(entry.id);
2551 if resolved_id != entry.id {
2552 let worktree = worktree.read(cx);
2553 entry = worktree.entry_for_id(resolved_id)?;
2554 }
2555 Some((worktree, entry))
2556 }
2557 fn selected_entry_handle<'a>(
2558 &self,
2559 cx: &'a App,
2560 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2561 let selection = self.selection?;
2562 let project = self.project.read(cx);
2563 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2564 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2565 Some((worktree, entry))
2566 }
2567
2568 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2569 let (worktree, entry) = self.selected_entry(cx)?;
2570 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2571
2572 for path in entry.path.ancestors() {
2573 let Some(entry) = worktree.entry_for_path(path) else {
2574 continue;
2575 };
2576 if entry.is_dir() {
2577 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2578 expanded_dir_ids.insert(idx, entry.id);
2579 }
2580 }
2581 }
2582
2583 Some(())
2584 }
2585
2586 fn update_visible_entries(
2587 &mut self,
2588 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2589 cx: &mut Context<Self>,
2590 ) {
2591 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
2592 let project = self.project.read(cx);
2593 self.last_worktree_root_id = project
2594 .visible_worktrees(cx)
2595 .next_back()
2596 .and_then(|worktree| worktree.read(cx).root_entry())
2597 .map(|entry| entry.id);
2598
2599 let old_ancestors = std::mem::take(&mut self.ancestors);
2600 self.visible_entries.clear();
2601 let mut max_width_item = None;
2602 for worktree in project.visible_worktrees(cx) {
2603 let snapshot = worktree.read(cx).snapshot();
2604 let worktree_id = snapshot.id();
2605
2606 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2607 hash_map::Entry::Occupied(e) => e.into_mut(),
2608 hash_map::Entry::Vacant(e) => {
2609 // The first time a worktree's root entry becomes available,
2610 // mark that root entry as expanded.
2611 if let Some(entry) = snapshot.root_entry() {
2612 e.insert(vec![entry.id]).as_slice()
2613 } else {
2614 &[]
2615 }
2616 }
2617 };
2618
2619 let mut new_entry_parent_id = None;
2620 let mut new_entry_kind = EntryKind::Dir;
2621 if let Some(edit_state) = &self.edit_state {
2622 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2623 new_entry_parent_id = Some(edit_state.entry_id);
2624 new_entry_kind = if edit_state.is_dir {
2625 EntryKind::Dir
2626 } else {
2627 EntryKind::File
2628 };
2629 }
2630 }
2631
2632 let mut visible_worktree_entries = Vec::new();
2633 let mut entry_iter = snapshot.entries(true, 0).with_git_statuses();
2634 let mut auto_folded_ancestors = vec![];
2635 while let Some(entry) = entry_iter.entry() {
2636 if auto_collapse_dirs && entry.kind.is_dir() {
2637 auto_folded_ancestors.push(entry.id);
2638 if !self.unfolded_dir_ids.contains(&entry.id) {
2639 if let Some(root_path) = snapshot.root_entry() {
2640 let mut child_entries = snapshot.child_entries(&entry.path);
2641 if let Some(child) = child_entries.next() {
2642 if entry.path != root_path.path
2643 && child_entries.next().is_none()
2644 && child.kind.is_dir()
2645 {
2646 entry_iter.advance();
2647
2648 continue;
2649 }
2650 }
2651 }
2652 }
2653 let depth = old_ancestors
2654 .get(&entry.id)
2655 .map(|ancestor| ancestor.current_ancestor_depth)
2656 .unwrap_or_default()
2657 .min(auto_folded_ancestors.len());
2658 if let Some(edit_state) = &mut self.edit_state {
2659 if edit_state.entry_id == entry.id {
2660 edit_state.depth = depth;
2661 }
2662 }
2663 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2664 if ancestors.len() > 1 {
2665 ancestors.reverse();
2666 self.ancestors.insert(
2667 entry.id,
2668 FoldedAncestors {
2669 current_ancestor_depth: depth,
2670 ancestors,
2671 },
2672 );
2673 }
2674 }
2675 auto_folded_ancestors.clear();
2676 visible_worktree_entries.push(entry.to_owned());
2677 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2678 entry.id == new_entry_id || {
2679 self.ancestors.get(&entry.id).map_or(false, |entries| {
2680 entries
2681 .ancestors
2682 .iter()
2683 .any(|entry_id| *entry_id == new_entry_id)
2684 })
2685 }
2686 } else {
2687 false
2688 };
2689 if precedes_new_entry {
2690 visible_worktree_entries.push(GitEntry {
2691 entry: Entry {
2692 id: NEW_ENTRY_ID,
2693 kind: new_entry_kind,
2694 path: entry.path.join("\0").into(),
2695 inode: 0,
2696 mtime: entry.mtime,
2697 size: entry.size,
2698 is_ignored: entry.is_ignored,
2699 is_external: false,
2700 is_private: false,
2701 is_always_included: entry.is_always_included,
2702 canonical_path: entry.canonical_path.clone(),
2703 char_bag: entry.char_bag,
2704 is_fifo: entry.is_fifo,
2705 },
2706 git_summary: entry.git_summary,
2707 });
2708 }
2709 let worktree_abs_path = worktree.read(cx).abs_path();
2710 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2711 let Some(path_name) = worktree_abs_path
2712 .file_name()
2713 .with_context(|| {
2714 format!("Worktree abs path has no file name, root entry: {entry:?}")
2715 })
2716 .log_err()
2717 else {
2718 continue;
2719 };
2720 let path = ArcCow::Borrowed(Path::new(path_name));
2721 let depth = 0;
2722 (depth, path)
2723 } else if entry.is_file() {
2724 let Some(path_name) = entry
2725 .path
2726 .file_name()
2727 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2728 .log_err()
2729 else {
2730 continue;
2731 };
2732 let path = ArcCow::Borrowed(Path::new(path_name));
2733 let depth = entry.path.ancestors().count() - 1;
2734 (depth, path)
2735 } else {
2736 let path = self
2737 .ancestors
2738 .get(&entry.id)
2739 .and_then(|ancestors| {
2740 let outermost_ancestor = ancestors.ancestors.last()?;
2741 let root_folded_entry = worktree
2742 .read(cx)
2743 .entry_for_id(*outermost_ancestor)?
2744 .path
2745 .as_ref();
2746 entry
2747 .path
2748 .strip_prefix(root_folded_entry)
2749 .ok()
2750 .and_then(|suffix| {
2751 let full_path = Path::new(root_folded_entry.file_name()?);
2752 Some(ArcCow::Owned(Arc::<Path>::from(full_path.join(suffix))))
2753 })
2754 })
2755 .or_else(|| entry.path.file_name().map(Path::new).map(ArcCow::Borrowed))
2756 .unwrap_or_else(|| ArcCow::Owned(entry.path.clone()));
2757 let depth = path.components().count();
2758 (depth, path)
2759 };
2760 let width_estimate = item_width_estimate(
2761 depth,
2762 path.to_string_lossy().chars().count(),
2763 entry.canonical_path.is_some(),
2764 );
2765
2766 match max_width_item.as_mut() {
2767 Some((id, worktree_id, width)) => {
2768 if *width < width_estimate {
2769 *id = entry.id;
2770 *worktree_id = worktree.read(cx).id();
2771 *width = width_estimate;
2772 }
2773 }
2774 None => {
2775 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2776 }
2777 }
2778
2779 if expanded_dir_ids.binary_search(&entry.id).is_err()
2780 && entry_iter.advance_to_sibling()
2781 {
2782 continue;
2783 }
2784 entry_iter.advance();
2785 }
2786
2787 project::sort_worktree_entries(&mut visible_worktree_entries);
2788
2789 self.visible_entries
2790 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2791 }
2792
2793 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2794 let mut visited_worktrees_length = 0;
2795 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2796 if worktree_id == *id {
2797 entries
2798 .iter()
2799 .position(|entry| entry.id == project_entry_id)
2800 } else {
2801 visited_worktrees_length += entries.len();
2802 None
2803 }
2804 });
2805 if let Some(index) = index {
2806 self.max_width_item_index = Some(visited_worktrees_length + index);
2807 }
2808 }
2809 if let Some((worktree_id, entry_id)) = new_selected_entry {
2810 self.selection = Some(SelectedEntry {
2811 worktree_id,
2812 entry_id,
2813 });
2814 }
2815 }
2816
2817 fn expand_entry(
2818 &mut self,
2819 worktree_id: WorktreeId,
2820 entry_id: ProjectEntryId,
2821 cx: &mut Context<Self>,
2822 ) {
2823 self.project.update(cx, |project, cx| {
2824 if let Some((worktree, expanded_dir_ids)) = project
2825 .worktree_for_id(worktree_id, cx)
2826 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2827 {
2828 project.expand_entry(worktree_id, entry_id, cx);
2829 let worktree = worktree.read(cx);
2830
2831 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2832 loop {
2833 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2834 expanded_dir_ids.insert(ix, entry.id);
2835 }
2836
2837 if let Some(parent_entry) =
2838 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2839 {
2840 entry = parent_entry;
2841 } else {
2842 break;
2843 }
2844 }
2845 }
2846 }
2847 });
2848 }
2849
2850 fn drop_external_files(
2851 &mut self,
2852 paths: &[PathBuf],
2853 entry_id: ProjectEntryId,
2854 window: &mut Window,
2855 cx: &mut Context<Self>,
2856 ) {
2857 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2858
2859 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2860
2861 let Some((target_directory, worktree)) = maybe!({
2862 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2863 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2864 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2865 let target_directory = if path.is_dir() {
2866 path
2867 } else {
2868 path.parent()?.to_path_buf()
2869 };
2870 Some((target_directory, worktree))
2871 }) else {
2872 return;
2873 };
2874
2875 let mut paths_to_replace = Vec::new();
2876 for path in &paths {
2877 if let Some(name) = path.file_name() {
2878 let mut target_path = target_directory.clone();
2879 target_path.push(name);
2880 if target_path.exists() {
2881 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2882 }
2883 }
2884 }
2885
2886 cx.spawn_in(window, |this, mut cx| {
2887 async move {
2888 for (filename, original_path) in &paths_to_replace {
2889 let answer = cx.update(|window, cx| {
2890 window
2891 .prompt(
2892 PromptLevel::Info,
2893 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2894 None,
2895 &["Replace", "Cancel"],
2896 cx,
2897 )
2898 })?.await?;
2899
2900 if answer == 1 {
2901 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2902 paths.remove(item_idx);
2903 }
2904 }
2905 }
2906
2907 if paths.is_empty() {
2908 return Ok(());
2909 }
2910
2911 let task = worktree.update(&mut cx, |worktree, cx| {
2912 worktree.copy_external_entries(target_directory, paths, true, cx)
2913 })?;
2914
2915 let opened_entries = task.await?;
2916 this.update(&mut cx, |this, cx| {
2917 if open_file_after_drop && !opened_entries.is_empty() {
2918 this.open_entry(opened_entries[0], true, false, cx);
2919 }
2920 })
2921 }
2922 .log_err()
2923 })
2924 .detach();
2925 }
2926
2927 fn drag_onto(
2928 &mut self,
2929 selections: &DraggedSelection,
2930 target_entry_id: ProjectEntryId,
2931 is_file: bool,
2932 window: &mut Window,
2933 cx: &mut Context<Self>,
2934 ) {
2935 let should_copy = window.modifiers().alt;
2936 if should_copy {
2937 let _ = maybe!({
2938 let project = self.project.read(cx);
2939 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2940 let worktree_id = target_worktree.read(cx).id();
2941 let target_entry = target_worktree
2942 .read(cx)
2943 .entry_for_id(target_entry_id)?
2944 .clone();
2945
2946 let mut copy_tasks = Vec::new();
2947 let mut disambiguation_range = None;
2948 for selection in selections.items() {
2949 let (new_path, new_disambiguation_range) = self.create_paste_path(
2950 selection,
2951 (target_worktree.clone(), &target_entry),
2952 cx,
2953 )?;
2954
2955 let task = self.project.update(cx, |project, cx| {
2956 project.copy_entry(selection.entry_id, None, new_path, cx)
2957 });
2958 copy_tasks.push(task);
2959 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2960 }
2961
2962 let item_count = copy_tasks.len();
2963
2964 cx.spawn_in(window, |project_panel, mut cx| async move {
2965 let mut last_succeed = None;
2966 for task in copy_tasks.into_iter() {
2967 if let Some(Some(entry)) = task.await.log_err() {
2968 last_succeed = Some(entry.id);
2969 }
2970 }
2971 // update selection
2972 if let Some(entry_id) = last_succeed {
2973 project_panel
2974 .update_in(&mut cx, |project_panel, window, cx| {
2975 project_panel.selection = Some(SelectedEntry {
2976 worktree_id,
2977 entry_id,
2978 });
2979
2980 // if only one entry was dragged and it was disambiguated, open the rename editor
2981 if item_count == 1 && disambiguation_range.is_some() {
2982 project_panel.rename_impl(disambiguation_range, window, cx);
2983 }
2984 })
2985 .ok();
2986 }
2987 })
2988 .detach();
2989 Some(())
2990 });
2991 } else {
2992 for selection in selections.items() {
2993 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2994 }
2995 }
2996 }
2997
2998 fn index_for_entry(
2999 &self,
3000 entry_id: ProjectEntryId,
3001 worktree_id: WorktreeId,
3002 ) -> Option<(usize, usize, usize)> {
3003 let mut worktree_ix = 0;
3004 let mut total_ix = 0;
3005 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3006 if worktree_id != *current_worktree_id {
3007 total_ix += visible_worktree_entries.len();
3008 worktree_ix += 1;
3009 continue;
3010 }
3011
3012 return visible_worktree_entries
3013 .iter()
3014 .enumerate()
3015 .find(|(_, entry)| entry.id == entry_id)
3016 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3017 }
3018 None
3019 }
3020
3021 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
3022 let mut offset = 0;
3023 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3024 if visible_worktree_entries.len() > offset + index {
3025 return visible_worktree_entries
3026 .get(index)
3027 .map(|entry| (*worktree_id, entry.to_ref()));
3028 }
3029 offset += visible_worktree_entries.len();
3030 }
3031 None
3032 }
3033
3034 fn iter_visible_entries(
3035 &self,
3036 range: Range<usize>,
3037 window: &mut Window,
3038 cx: &mut Context<ProjectPanel>,
3039 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3040 ) {
3041 let mut ix = 0;
3042 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3043 if ix >= range.end {
3044 return;
3045 }
3046
3047 if ix + visible_worktree_entries.len() <= range.start {
3048 ix += visible_worktree_entries.len();
3049 continue;
3050 }
3051
3052 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3053 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3054 let entries = entries_paths.get_or_init(|| {
3055 visible_worktree_entries
3056 .iter()
3057 .map(|e| (e.path.clone()))
3058 .collect()
3059 });
3060 for entry in visible_worktree_entries[entry_range].iter() {
3061 callback(&entry, entries, window, cx);
3062 }
3063 ix = end_ix;
3064 }
3065 }
3066
3067 fn for_each_visible_entry(
3068 &self,
3069 range: Range<usize>,
3070 window: &mut Window,
3071 cx: &mut Context<ProjectPanel>,
3072 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3073 ) {
3074 let mut ix = 0;
3075 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3076 if ix >= range.end {
3077 return;
3078 }
3079
3080 if ix + visible_worktree_entries.len() <= range.start {
3081 ix += visible_worktree_entries.len();
3082 continue;
3083 }
3084
3085 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3086 let (git_status_setting, show_file_icons, show_folder_icons) = {
3087 let settings = ProjectPanelSettings::get_global(cx);
3088 (
3089 settings.git_status,
3090 settings.file_icons,
3091 settings.folder_icons,
3092 )
3093 };
3094 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3095 let snapshot = worktree.read(cx).snapshot();
3096 let root_name = OsStr::new(snapshot.root_name());
3097 let expanded_entry_ids = self
3098 .expanded_dir_ids
3099 .get(&snapshot.id())
3100 .map(Vec::as_slice)
3101 .unwrap_or(&[]);
3102
3103 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3104 let entries = entries_paths.get_or_init(|| {
3105 visible_worktree_entries
3106 .iter()
3107 .map(|e| (e.path.clone()))
3108 .collect()
3109 });
3110 for entry in visible_worktree_entries[entry_range].iter() {
3111 let status = git_status_setting
3112 .then_some(entry.git_summary)
3113 .unwrap_or_default();
3114 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3115 let icon = match entry.kind {
3116 EntryKind::File => {
3117 if show_file_icons {
3118 FileIcons::get_icon(&entry.path, cx)
3119 } else {
3120 None
3121 }
3122 }
3123 _ => {
3124 if show_folder_icons {
3125 FileIcons::get_folder_icon(is_expanded, cx)
3126 } else {
3127 FileIcons::get_chevron_icon(is_expanded, cx)
3128 }
3129 }
3130 };
3131
3132 let (depth, difference) =
3133 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3134
3135 let filename = match difference {
3136 diff if diff > 1 => entry
3137 .path
3138 .iter()
3139 .skip(entry.path.components().count() - diff)
3140 .collect::<PathBuf>()
3141 .to_str()
3142 .unwrap_or_default()
3143 .to_string(),
3144 _ => entry
3145 .path
3146 .file_name()
3147 .map(|name| name.to_string_lossy().into_owned())
3148 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3149 };
3150 let selection = SelectedEntry {
3151 worktree_id: snapshot.id(),
3152 entry_id: entry.id,
3153 };
3154
3155 let is_marked = self.marked_entries.contains(&selection);
3156
3157 let diagnostic_severity = self
3158 .diagnostics
3159 .get(&(*worktree_id, entry.path.to_path_buf()))
3160 .cloned();
3161
3162 let filename_text_color =
3163 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3164
3165 let mut details = EntryDetails {
3166 filename,
3167 icon,
3168 path: entry.path.clone(),
3169 depth,
3170 kind: entry.kind,
3171 is_ignored: entry.is_ignored,
3172 is_expanded,
3173 is_selected: self.selection == Some(selection),
3174 is_marked,
3175 is_editing: false,
3176 is_processing: false,
3177 is_cut: self
3178 .clipboard
3179 .as_ref()
3180 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3181 filename_text_color,
3182 diagnostic_severity,
3183 git_status: status,
3184 is_private: entry.is_private,
3185 worktree_id: *worktree_id,
3186 canonical_path: entry.canonical_path.clone(),
3187 };
3188
3189 if let Some(edit_state) = &self.edit_state {
3190 let is_edited_entry = if edit_state.is_new_entry() {
3191 entry.id == NEW_ENTRY_ID
3192 } else {
3193 entry.id == edit_state.entry_id
3194 || self
3195 .ancestors
3196 .get(&entry.id)
3197 .is_some_and(|auto_folded_dirs| {
3198 auto_folded_dirs
3199 .ancestors
3200 .iter()
3201 .any(|entry_id| *entry_id == edit_state.entry_id)
3202 })
3203 };
3204
3205 if is_edited_entry {
3206 if let Some(processing_filename) = &edit_state.processing_filename {
3207 details.is_processing = true;
3208 if let Some(ancestors) = edit_state
3209 .leaf_entry_id
3210 .and_then(|entry| self.ancestors.get(&entry))
3211 {
3212 let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
3213 let all_components = ancestors.ancestors.len();
3214
3215 let prefix_components = all_components - position;
3216 let suffix_components = position.checked_sub(1);
3217 let mut previous_components =
3218 Path::new(&details.filename).components();
3219 let mut new_path = previous_components
3220 .by_ref()
3221 .take(prefix_components)
3222 .collect::<PathBuf>();
3223 if let Some(last_component) =
3224 Path::new(processing_filename).components().last()
3225 {
3226 new_path.push(last_component);
3227 previous_components.next();
3228 }
3229
3230 if let Some(_) = suffix_components {
3231 new_path.push(previous_components);
3232 }
3233 if let Some(str) = new_path.to_str() {
3234 details.filename.clear();
3235 details.filename.push_str(str);
3236 }
3237 } else {
3238 details.filename.clear();
3239 details.filename.push_str(processing_filename);
3240 }
3241 } else {
3242 if edit_state.is_new_entry() {
3243 details.filename.clear();
3244 }
3245 details.is_editing = true;
3246 }
3247 }
3248 }
3249
3250 callback(entry.id, details, window, cx);
3251 }
3252 }
3253 ix = end_ix;
3254 }
3255 }
3256
3257 fn find_entry_in_worktree(
3258 &self,
3259 worktree_id: WorktreeId,
3260 reverse_search: bool,
3261 only_visible_entries: bool,
3262 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3263 cx: &mut Context<Self>,
3264 ) -> Option<GitEntry> {
3265 if only_visible_entries {
3266 let entries = self
3267 .visible_entries
3268 .iter()
3269 .find_map(|(tree_id, entries, _)| {
3270 if worktree_id == *tree_id {
3271 Some(entries)
3272 } else {
3273 None
3274 }
3275 })?
3276 .clone();
3277
3278 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3279 .find(|ele| predicate(ele.to_ref(), worktree_id))
3280 .cloned();
3281 }
3282
3283 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3284 worktree.update(cx, |tree, _| {
3285 utils::ReversibleIterable::new(
3286 tree.entries(true, 0usize).with_git_statuses(),
3287 reverse_search,
3288 )
3289 .find_single_ended(|ele| predicate(*ele, worktree_id))
3290 .map(|ele| ele.to_owned())
3291 })
3292 }
3293
3294 fn find_entry(
3295 &self,
3296 start: Option<&SelectedEntry>,
3297 reverse_search: bool,
3298 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3299 cx: &mut Context<Self>,
3300 ) -> Option<SelectedEntry> {
3301 let mut worktree_ids: Vec<_> = self
3302 .visible_entries
3303 .iter()
3304 .map(|(worktree_id, _, _)| *worktree_id)
3305 .collect();
3306
3307 let mut last_found: Option<SelectedEntry> = None;
3308
3309 if let Some(start) = start {
3310 let worktree = self
3311 .project
3312 .read(cx)
3313 .worktree_for_id(start.worktree_id, cx)?;
3314
3315 let search = worktree.update(cx, |tree, _| {
3316 let entry = tree.entry_for_id(start.entry_id)?;
3317 let root_entry = tree.root_entry()?;
3318 let tree_id = tree.id();
3319
3320 let mut first_iter = tree
3321 .traverse_from_path(true, true, true, entry.path.as_ref())
3322 .with_git_statuses();
3323
3324 if reverse_search {
3325 first_iter.next();
3326 }
3327
3328 let first = first_iter
3329 .enumerate()
3330 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3331 .map(|(_, entry)| entry)
3332 .find(|ele| predicate(*ele, tree_id))
3333 .map(|ele| ele.to_owned());
3334
3335 let second_iter = tree.entries(true, 0usize).with_git_statuses();
3336
3337 let second = if reverse_search {
3338 second_iter
3339 .take_until(|ele| ele.id == start.entry_id)
3340 .filter(|ele| predicate(*ele, tree_id))
3341 .last()
3342 .map(|ele| ele.to_owned())
3343 } else {
3344 second_iter
3345 .take_while(|ele| ele.id != start.entry_id)
3346 .filter(|ele| predicate(*ele, tree_id))
3347 .last()
3348 .map(|ele| ele.to_owned())
3349 };
3350
3351 if reverse_search {
3352 Some((second, first))
3353 } else {
3354 Some((first, second))
3355 }
3356 });
3357
3358 if let Some((first, second)) = search {
3359 let first = first.map(|entry| SelectedEntry {
3360 worktree_id: start.worktree_id,
3361 entry_id: entry.id,
3362 });
3363
3364 let second = second.map(|entry| SelectedEntry {
3365 worktree_id: start.worktree_id,
3366 entry_id: entry.id,
3367 });
3368
3369 if first.is_some() {
3370 return first;
3371 }
3372 last_found = second;
3373
3374 let idx = worktree_ids
3375 .iter()
3376 .enumerate()
3377 .find(|(_, ele)| **ele == start.worktree_id)
3378 .map(|(idx, _)| idx);
3379
3380 if let Some(idx) = idx {
3381 worktree_ids.rotate_left(idx + 1usize);
3382 worktree_ids.pop();
3383 }
3384 }
3385 }
3386
3387 for tree_id in worktree_ids.into_iter() {
3388 if let Some(found) =
3389 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3390 {
3391 return Some(SelectedEntry {
3392 worktree_id: tree_id,
3393 entry_id: found.id,
3394 });
3395 }
3396 }
3397
3398 last_found
3399 }
3400
3401 fn find_visible_entry(
3402 &self,
3403 start: Option<&SelectedEntry>,
3404 reverse_search: bool,
3405 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3406 cx: &mut Context<Self>,
3407 ) -> Option<SelectedEntry> {
3408 let mut worktree_ids: Vec<_> = self
3409 .visible_entries
3410 .iter()
3411 .map(|(worktree_id, _, _)| *worktree_id)
3412 .collect();
3413
3414 let mut last_found: Option<SelectedEntry> = None;
3415
3416 if let Some(start) = start {
3417 let entries = self
3418 .visible_entries
3419 .iter()
3420 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3421 .map(|(_, entries, _)| entries)?;
3422
3423 let mut start_idx = entries
3424 .iter()
3425 .enumerate()
3426 .find(|(_, ele)| ele.id == start.entry_id)
3427 .map(|(idx, _)| idx)?;
3428
3429 if reverse_search {
3430 start_idx = start_idx.saturating_add(1usize);
3431 }
3432
3433 let (left, right) = entries.split_at_checked(start_idx)?;
3434
3435 let (first_iter, second_iter) = if reverse_search {
3436 (
3437 utils::ReversibleIterable::new(left.iter(), reverse_search),
3438 utils::ReversibleIterable::new(right.iter(), reverse_search),
3439 )
3440 } else {
3441 (
3442 utils::ReversibleIterable::new(right.iter(), reverse_search),
3443 utils::ReversibleIterable::new(left.iter(), reverse_search),
3444 )
3445 };
3446
3447 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3448 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3449
3450 if first_search.is_some() {
3451 return first_search.map(|entry| SelectedEntry {
3452 worktree_id: start.worktree_id,
3453 entry_id: entry.id,
3454 });
3455 }
3456
3457 last_found = second_search.map(|entry| SelectedEntry {
3458 worktree_id: start.worktree_id,
3459 entry_id: entry.id,
3460 });
3461
3462 let idx = worktree_ids
3463 .iter()
3464 .enumerate()
3465 .find(|(_, ele)| **ele == start.worktree_id)
3466 .map(|(idx, _)| idx);
3467
3468 if let Some(idx) = idx {
3469 worktree_ids.rotate_left(idx + 1usize);
3470 worktree_ids.pop();
3471 }
3472 }
3473
3474 for tree_id in worktree_ids.into_iter() {
3475 if let Some(found) =
3476 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3477 {
3478 return Some(SelectedEntry {
3479 worktree_id: tree_id,
3480 entry_id: found.id,
3481 });
3482 }
3483 }
3484
3485 last_found
3486 }
3487
3488 fn calculate_depth_and_difference(
3489 entry: &Entry,
3490 visible_worktree_entries: &HashSet<Arc<Path>>,
3491 ) -> (usize, usize) {
3492 let (depth, difference) = entry
3493 .path
3494 .ancestors()
3495 .skip(1) // Skip the entry itself
3496 .find_map(|ancestor| {
3497 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3498 let entry_path_components_count = entry.path.components().count();
3499 let parent_path_components_count = parent_entry.components().count();
3500 let difference = entry_path_components_count - parent_path_components_count;
3501 let depth = parent_entry
3502 .ancestors()
3503 .skip(1)
3504 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3505 .count();
3506 Some((depth + 1, difference))
3507 } else {
3508 None
3509 }
3510 })
3511 .unwrap_or((0, 0));
3512
3513 (depth, difference)
3514 }
3515
3516 fn render_entry(
3517 &self,
3518 entry_id: ProjectEntryId,
3519 details: EntryDetails,
3520 window: &mut Window,
3521 cx: &mut Context<Self>,
3522 ) -> Stateful<Div> {
3523 const GROUP_NAME: &str = "project_entry";
3524
3525 let kind = details.kind;
3526 let settings = ProjectPanelSettings::get_global(cx);
3527 let show_editor = details.is_editing && !details.is_processing;
3528
3529 let selection = SelectedEntry {
3530 worktree_id: details.worktree_id,
3531 entry_id,
3532 };
3533
3534 let is_marked = self.marked_entries.contains(&selection);
3535 let is_active = self
3536 .selection
3537 .map_or(false, |selection| selection.entry_id == entry_id);
3538
3539 let file_name = details.filename.clone();
3540
3541 let mut icon = details.icon.clone();
3542 if settings.file_icons && show_editor && details.kind.is_file() {
3543 let filename = self.filename_editor.read(cx).text(cx);
3544 if filename.len() > 2 {
3545 icon = FileIcons::get_icon(Path::new(&filename), cx);
3546 }
3547 }
3548
3549 let filename_text_color = details.filename_text_color;
3550 let diagnostic_severity = details.diagnostic_severity;
3551 let item_colors = get_item_color(cx);
3552
3553 let canonical_path = details
3554 .canonical_path
3555 .as_ref()
3556 .map(|f| f.to_string_lossy().to_string());
3557 let path = details.path.clone();
3558
3559 let depth = details.depth;
3560 let worktree_id = details.worktree_id;
3561 let selections = Arc::new(self.marked_entries.clone());
3562 let is_local = self.project.read(cx).is_local();
3563
3564 let dragged_selection = DraggedSelection {
3565 active_selection: selection,
3566 marked_selections: selections,
3567 };
3568
3569 let bg_color = if is_marked {
3570 item_colors.marked
3571 } else {
3572 item_colors.default
3573 };
3574
3575 let bg_hover_color = if is_marked {
3576 item_colors.marked
3577 } else {
3578 item_colors.hover
3579 };
3580
3581 let border_color =
3582 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3583 item_colors.focused
3584 } else {
3585 bg_color
3586 };
3587
3588 let border_hover_color =
3589 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3590 item_colors.focused
3591 } else {
3592 bg_hover_color
3593 };
3594
3595 let folded_directory_drag_target = self.folded_directory_drag_target;
3596
3597 div()
3598 .id(entry_id.to_proto() as usize)
3599 .group(GROUP_NAME)
3600 .cursor_pointer()
3601 .rounded_none()
3602 .bg(bg_color)
3603 .border_1()
3604 .border_r_2()
3605 .border_color(border_color)
3606 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3607 .when(is_local, |div| {
3608 div.on_drag_move::<ExternalPaths>(cx.listener(
3609 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3610 if event.bounds.contains(&event.event.position) {
3611 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3612 return;
3613 }
3614 this.last_external_paths_drag_over_entry = Some(entry_id);
3615 this.marked_entries.clear();
3616
3617 let Some((worktree, path, entry)) = maybe!({
3618 let worktree = this
3619 .project
3620 .read(cx)
3621 .worktree_for_id(selection.worktree_id, cx)?;
3622 let worktree = worktree.read(cx);
3623 let abs_path = worktree.absolutize(&path).log_err()?;
3624 let path = if abs_path.is_dir() {
3625 path.as_ref()
3626 } else {
3627 path.parent()?
3628 };
3629 let entry = worktree.entry_for_path(path)?;
3630 Some((worktree, path, entry))
3631 }) else {
3632 return;
3633 };
3634
3635 this.marked_entries.insert(SelectedEntry {
3636 entry_id: entry.id,
3637 worktree_id: worktree.id(),
3638 });
3639
3640 for entry in worktree.child_entries(path) {
3641 this.marked_entries.insert(SelectedEntry {
3642 entry_id: entry.id,
3643 worktree_id: worktree.id(),
3644 });
3645 }
3646
3647 cx.notify();
3648 }
3649 },
3650 ))
3651 .on_drop(cx.listener(
3652 move |this, external_paths: &ExternalPaths, window, cx| {
3653 this.hover_scroll_task.take();
3654 this.last_external_paths_drag_over_entry = None;
3655 this.marked_entries.clear();
3656 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3657 cx.stop_propagation();
3658 },
3659 ))
3660 })
3661 .on_drag_move::<DraggedSelection>(cx.listener(
3662 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3663 if event.bounds.contains(&event.event.position) {
3664 if this.last_selection_drag_over_entry == Some(entry_id) {
3665 return;
3666 }
3667 this.last_selection_drag_over_entry = Some(entry_id);
3668 this.hover_expand_task.take();
3669
3670 if !kind.is_dir()
3671 || this
3672 .expanded_dir_ids
3673 .get(&details.worktree_id)
3674 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3675 {
3676 return;
3677 }
3678
3679 let bounds = event.bounds;
3680 this.hover_expand_task =
3681 Some(cx.spawn_in(window, |this, mut cx| async move {
3682 cx.background_executor()
3683 .timer(Duration::from_millis(500))
3684 .await;
3685 this.update_in(&mut cx, |this, window, cx| {
3686 this.hover_expand_task.take();
3687 if this.last_selection_drag_over_entry == Some(entry_id)
3688 && bounds.contains(&window.mouse_position())
3689 {
3690 this.expand_entry(worktree_id, entry_id, cx);
3691 this.update_visible_entries(
3692 Some((worktree_id, entry_id)),
3693 cx,
3694 );
3695 cx.notify();
3696 }
3697 })
3698 .ok();
3699 }));
3700 }
3701 },
3702 ))
3703 .on_drag(
3704 dragged_selection,
3705 move |selection, click_offset, _window, cx| {
3706 cx.new(|_| DraggedProjectEntryView {
3707 details: details.clone(),
3708 click_offset,
3709 selection: selection.active_selection,
3710 selections: selection.marked_selections.clone(),
3711 })
3712 },
3713 )
3714 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3715 if folded_directory_drag_target.is_some() {
3716 return style;
3717 }
3718 style.bg(item_colors.drag_over)
3719 })
3720 .on_drop(
3721 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3722 this.hover_scroll_task.take();
3723 this.hover_expand_task.take();
3724 if folded_directory_drag_target.is_some() {
3725 return;
3726 }
3727 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3728 }),
3729 )
3730 .on_mouse_down(
3731 MouseButton::Left,
3732 cx.listener(move |this, _, _, cx| {
3733 this.mouse_down = true;
3734 cx.propagate();
3735 }),
3736 )
3737 .on_click(
3738 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3739 if event.down.button == MouseButton::Right
3740 || event.down.first_mouse
3741 || show_editor
3742 {
3743 return;
3744 }
3745 if event.down.button == MouseButton::Left {
3746 this.mouse_down = false;
3747 }
3748 cx.stop_propagation();
3749
3750 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3751 let current_selection = this.index_for_selection(selection);
3752 let clicked_entry = SelectedEntry {
3753 entry_id,
3754 worktree_id,
3755 };
3756 let target_selection = this.index_for_selection(clicked_entry);
3757 if let Some(((_, _, source_index), (_, _, target_index))) =
3758 current_selection.zip(target_selection)
3759 {
3760 let range_start = source_index.min(target_index);
3761 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3762 let mut new_selections = BTreeSet::new();
3763 this.for_each_visible_entry(
3764 range_start..range_end,
3765 window,
3766 cx,
3767 |entry_id, details, _, _| {
3768 new_selections.insert(SelectedEntry {
3769 entry_id,
3770 worktree_id: details.worktree_id,
3771 });
3772 },
3773 );
3774
3775 this.marked_entries = this
3776 .marked_entries
3777 .union(&new_selections)
3778 .cloned()
3779 .collect();
3780
3781 this.selection = Some(clicked_entry);
3782 this.marked_entries.insert(clicked_entry);
3783 }
3784 } else if event.modifiers().secondary() {
3785 if event.down.click_count > 1 {
3786 this.split_entry(entry_id, cx);
3787 } else {
3788 this.selection = Some(selection);
3789 if !this.marked_entries.insert(selection) {
3790 this.marked_entries.remove(&selection);
3791 }
3792 }
3793 } else if kind.is_dir() {
3794 this.marked_entries.clear();
3795 if event.modifiers().alt {
3796 this.toggle_expand_all(entry_id, window, cx);
3797 } else {
3798 this.toggle_expanded(entry_id, window, cx);
3799 }
3800 } else {
3801 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3802 let click_count = event.up.click_count;
3803 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3804 let allow_preview = preview_tabs_enabled && click_count == 1;
3805 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3806 }
3807 }),
3808 )
3809 .child(
3810 ListItem::new(entry_id.to_proto() as usize)
3811 .indent_level(depth)
3812 .indent_step_size(px(settings.indent_size))
3813 .spacing(match settings.entry_spacing {
3814 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3815 project_panel_settings::EntrySpacing::Standard => {
3816 ListItemSpacing::ExtraDense
3817 }
3818 })
3819 .selectable(false)
3820 .when_some(canonical_path, |this, path| {
3821 this.end_slot::<AnyElement>(
3822 div()
3823 .id("symlink_icon")
3824 .pr_3()
3825 .tooltip(move |window, cx| {
3826 Tooltip::with_meta(
3827 path.to_string(),
3828 None,
3829 "Symbolic Link",
3830 window,
3831 cx,
3832 )
3833 })
3834 .child(
3835 Icon::new(IconName::ArrowUpRight)
3836 .size(IconSize::Indicator)
3837 .color(filename_text_color),
3838 )
3839 .into_any_element(),
3840 )
3841 })
3842 .child(if let Some(icon) = &icon {
3843 if let Some((_, decoration_color)) =
3844 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3845 {
3846 let is_warning = diagnostic_severity
3847 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3848 .unwrap_or(false);
3849 div().child(
3850 DecoratedIcon::new(
3851 Icon::from_path(icon.clone()).color(Color::Muted),
3852 Some(
3853 IconDecoration::new(
3854 if kind.is_file() {
3855 if is_warning {
3856 IconDecorationKind::Triangle
3857 } else {
3858 IconDecorationKind::X
3859 }
3860 } else {
3861 IconDecorationKind::Dot
3862 },
3863 bg_color,
3864 cx,
3865 )
3866 .group_name(Some(GROUP_NAME.into()))
3867 .knockout_hover_color(bg_hover_color)
3868 .color(decoration_color.color(cx))
3869 .position(Point {
3870 x: px(-2.),
3871 y: px(-2.),
3872 }),
3873 ),
3874 )
3875 .into_any_element(),
3876 )
3877 } else {
3878 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3879 }
3880 } else {
3881 if let Some((icon_name, color)) =
3882 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3883 {
3884 h_flex()
3885 .size(IconSize::default().rems())
3886 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3887 } else {
3888 h_flex()
3889 .size(IconSize::default().rems())
3890 .invisible()
3891 .flex_none()
3892 }
3893 })
3894 .child(
3895 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3896 h_flex().h_6().w_full().child(editor.clone())
3897 } else {
3898 h_flex().h_6().map(|mut this| {
3899 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3900 let components = Path::new(&file_name)
3901 .components()
3902 .map(|comp| {
3903 let comp_str =
3904 comp.as_os_str().to_string_lossy().into_owned();
3905 comp_str
3906 })
3907 .collect::<Vec<_>>();
3908
3909 let components_len = components.len();
3910 let active_index = components_len
3911 - 1
3912 - folded_ancestors.current_ancestor_depth;
3913 const DELIMITER: SharedString =
3914 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3915 for (index, component) in components.into_iter().enumerate() {
3916 if index != 0 {
3917 let delimiter_target_index = index - 1;
3918 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
3919 this = this.child(
3920 div()
3921 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3922 this.hover_scroll_task.take();
3923 this.folded_directory_drag_target = None;
3924 if let Some(target_entry_id) = target_entry_id {
3925 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
3926 }
3927 }))
3928 .on_drag_move(cx.listener(
3929 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3930 if event.bounds.contains(&event.event.position) {
3931 this.folded_directory_drag_target = Some(
3932 FoldedDirectoryDragTarget {
3933 entry_id,
3934 index: delimiter_target_index,
3935 is_delimiter_target: true,
3936 }
3937 );
3938 } else {
3939 let is_current_target = this.folded_directory_drag_target
3940 .map_or(false, |target|
3941 target.entry_id == entry_id &&
3942 target.index == delimiter_target_index &&
3943 target.is_delimiter_target
3944 );
3945 if is_current_target {
3946 this.folded_directory_drag_target = None;
3947 }
3948 }
3949
3950 },
3951 ))
3952 .child(
3953 Label::new(DELIMITER.clone())
3954 .single_line()
3955 .color(filename_text_color)
3956 )
3957 );
3958 }
3959 let id = SharedString::from(format!(
3960 "project_panel_path_component_{}_{index}",
3961 entry_id.to_usize()
3962 ));
3963 let label = div()
3964 .id(id)
3965 .on_click(cx.listener(move |this, _, _, cx| {
3966 if index != active_index {
3967 if let Some(folds) =
3968 this.ancestors.get_mut(&entry_id)
3969 {
3970 folds.current_ancestor_depth =
3971 components_len - 1 - index;
3972 cx.notify();
3973 }
3974 }
3975 }))
3976 .when(index != components_len - 1, |div|{
3977 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
3978 div
3979 .on_drag_move(cx.listener(
3980 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3981 if event.bounds.contains(&event.event.position) {
3982 this.folded_directory_drag_target = Some(
3983 FoldedDirectoryDragTarget {
3984 entry_id,
3985 index,
3986 is_delimiter_target: false,
3987 }
3988 );
3989 } else {
3990 let is_current_target = this.folded_directory_drag_target
3991 .as_ref()
3992 .map_or(false, |target|
3993 target.entry_id == entry_id &&
3994 target.index == index &&
3995 !target.is_delimiter_target
3996 );
3997 if is_current_target {
3998 this.folded_directory_drag_target = None;
3999 }
4000 }
4001 },
4002 ))
4003 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4004 this.hover_scroll_task.take();
4005 this.folded_directory_drag_target = None;
4006 if let Some(target_entry_id) = target_entry_id {
4007 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4008 }
4009 }))
4010 .when(folded_directory_drag_target.map_or(false, |target|
4011 target.entry_id == entry_id &&
4012 target.index == index
4013 ), |this| {
4014 this.bg(item_colors.drag_over)
4015 })
4016 })
4017 .child(
4018 Label::new(component)
4019 .single_line()
4020 .color(filename_text_color)
4021 .when(
4022 index == active_index
4023 && (is_active || is_marked),
4024 |this| this.underline(),
4025 ),
4026 );
4027
4028 this = this.child(label);
4029 }
4030
4031 this
4032 } else {
4033 this.child(
4034 Label::new(file_name)
4035 .single_line()
4036 .color(filename_text_color),
4037 )
4038 }
4039 })
4040 }
4041 .ml_1(),
4042 )
4043 .on_secondary_mouse_down(cx.listener(
4044 move |this, event: &MouseDownEvent, window, cx| {
4045 // Stop propagation to prevent the catch-all context menu for the project
4046 // panel from being deployed.
4047 cx.stop_propagation();
4048 // Some context menu actions apply to all marked entries. If the user
4049 // right-clicks on an entry that is not marked, they may not realize the
4050 // action applies to multiple entries. To avoid inadvertent changes, all
4051 // entries are unmarked.
4052 if !this.marked_entries.contains(&selection) {
4053 this.marked_entries.clear();
4054 }
4055 this.deploy_context_menu(event.position, entry_id, window, cx);
4056 },
4057 ))
4058 .overflow_x(),
4059 )
4060 }
4061
4062 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4063 if !Self::should_show_scrollbar(cx)
4064 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4065 {
4066 return None;
4067 }
4068 Some(
4069 div()
4070 .occlude()
4071 .id("project-panel-vertical-scroll")
4072 .on_mouse_move(cx.listener(|_, _, _, cx| {
4073 cx.notify();
4074 cx.stop_propagation()
4075 }))
4076 .on_hover(|_, _, cx| {
4077 cx.stop_propagation();
4078 })
4079 .on_any_mouse_down(|_, _, cx| {
4080 cx.stop_propagation();
4081 })
4082 .on_mouse_up(
4083 MouseButton::Left,
4084 cx.listener(|this, _, window, cx| {
4085 if !this.vertical_scrollbar_state.is_dragging()
4086 && !this.focus_handle.contains_focused(window, cx)
4087 {
4088 this.hide_scrollbar(window, cx);
4089 cx.notify();
4090 }
4091
4092 cx.stop_propagation();
4093 }),
4094 )
4095 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4096 cx.notify();
4097 }))
4098 .h_full()
4099 .absolute()
4100 .right_1()
4101 .top_1()
4102 .bottom_1()
4103 .w(px(12.))
4104 .cursor_default()
4105 .children(Scrollbar::vertical(
4106 // percentage as f32..end_offset as f32,
4107 self.vertical_scrollbar_state.clone(),
4108 )),
4109 )
4110 }
4111
4112 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4113 if !Self::should_show_scrollbar(cx)
4114 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4115 {
4116 return None;
4117 }
4118
4119 let scroll_handle = self.scroll_handle.0.borrow();
4120 let longest_item_width = scroll_handle
4121 .last_item_size
4122 .filter(|size| size.contents.width > size.item.width)?
4123 .contents
4124 .width
4125 .0 as f64;
4126 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4127 return None;
4128 }
4129
4130 Some(
4131 div()
4132 .occlude()
4133 .id("project-panel-horizontal-scroll")
4134 .on_mouse_move(cx.listener(|_, _, _, cx| {
4135 cx.notify();
4136 cx.stop_propagation()
4137 }))
4138 .on_hover(|_, _, cx| {
4139 cx.stop_propagation();
4140 })
4141 .on_any_mouse_down(|_, _, cx| {
4142 cx.stop_propagation();
4143 })
4144 .on_mouse_up(
4145 MouseButton::Left,
4146 cx.listener(|this, _, window, cx| {
4147 if !this.horizontal_scrollbar_state.is_dragging()
4148 && !this.focus_handle.contains_focused(window, cx)
4149 {
4150 this.hide_scrollbar(window, cx);
4151 cx.notify();
4152 }
4153
4154 cx.stop_propagation();
4155 }),
4156 )
4157 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4158 cx.notify();
4159 }))
4160 .w_full()
4161 .absolute()
4162 .right_1()
4163 .left_1()
4164 .bottom_1()
4165 .h(px(12.))
4166 .cursor_default()
4167 .when(self.width.is_some(), |this| {
4168 this.children(Scrollbar::horizontal(
4169 self.horizontal_scrollbar_state.clone(),
4170 ))
4171 }),
4172 )
4173 }
4174
4175 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4176 let mut dispatch_context = KeyContext::new_with_defaults();
4177 dispatch_context.add("ProjectPanel");
4178 dispatch_context.add("menu");
4179
4180 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4181 "editing"
4182 } else {
4183 "not_editing"
4184 };
4185
4186 dispatch_context.add(identifier);
4187 dispatch_context
4188 }
4189
4190 fn should_show_scrollbar(cx: &App) -> bool {
4191 let show = ProjectPanelSettings::get_global(cx)
4192 .scrollbar
4193 .show
4194 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4195 match show {
4196 ShowScrollbar::Auto => true,
4197 ShowScrollbar::System => true,
4198 ShowScrollbar::Always => true,
4199 ShowScrollbar::Never => false,
4200 }
4201 }
4202
4203 fn should_autohide_scrollbar(cx: &App) -> bool {
4204 let show = ProjectPanelSettings::get_global(cx)
4205 .scrollbar
4206 .show
4207 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4208 match show {
4209 ShowScrollbar::Auto => true,
4210 ShowScrollbar::System => cx
4211 .try_global::<ScrollbarAutoHide>()
4212 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4213 ShowScrollbar::Always => false,
4214 ShowScrollbar::Never => true,
4215 }
4216 }
4217
4218 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4219 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4220 if !Self::should_autohide_scrollbar(cx) {
4221 return;
4222 }
4223 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
4224 cx.background_executor()
4225 .timer(SCROLLBAR_SHOW_INTERVAL)
4226 .await;
4227 panel
4228 .update(&mut cx, |panel, cx| {
4229 panel.show_scrollbar = false;
4230 cx.notify();
4231 })
4232 .log_err();
4233 }))
4234 }
4235
4236 fn reveal_entry(
4237 &mut self,
4238 project: Entity<Project>,
4239 entry_id: ProjectEntryId,
4240 skip_ignored: bool,
4241 cx: &mut Context<Self>,
4242 ) {
4243 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4244 let worktree = worktree.read(cx);
4245 if skip_ignored
4246 && worktree
4247 .entry_for_id(entry_id)
4248 .map_or(true, |entry| entry.is_ignored)
4249 {
4250 return;
4251 }
4252
4253 let worktree_id = worktree.id();
4254 self.expand_entry(worktree_id, entry_id, cx);
4255 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4256 self.marked_entries.clear();
4257 self.marked_entries.insert(SelectedEntry {
4258 worktree_id,
4259 entry_id,
4260 });
4261 self.autoscroll(cx);
4262 cx.notify();
4263 }
4264 }
4265
4266 fn find_active_indent_guide(
4267 &self,
4268 indent_guides: &[IndentGuideLayout],
4269 cx: &App,
4270 ) -> Option<usize> {
4271 let (worktree, entry) = self.selected_entry(cx)?;
4272
4273 // Find the parent entry of the indent guide, this will either be the
4274 // expanded folder we have selected, or the parent of the currently
4275 // selected file/collapsed directory
4276 let mut entry = entry;
4277 loop {
4278 let is_expanded_dir = entry.is_dir()
4279 && self
4280 .expanded_dir_ids
4281 .get(&worktree.id())
4282 .map(|ids| ids.binary_search(&entry.id).is_ok())
4283 .unwrap_or(false);
4284 if is_expanded_dir {
4285 break;
4286 }
4287 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4288 }
4289
4290 let (active_indent_range, depth) = {
4291 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4292 let child_paths = &self.visible_entries[worktree_ix].1;
4293 let mut child_count = 0;
4294 let depth = entry.path.ancestors().count();
4295 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4296 if entry.path.ancestors().count() <= depth {
4297 break;
4298 }
4299 child_count += 1;
4300 }
4301
4302 let start = ix + 1;
4303 let end = start + child_count;
4304
4305 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4306 let visible_worktree_entries =
4307 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4308
4309 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4310 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4311 (start..end, depth)
4312 };
4313
4314 let candidates = indent_guides
4315 .iter()
4316 .enumerate()
4317 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4318
4319 for (i, indent) in candidates {
4320 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4321 if active_indent_range.start <= indent.offset.y + indent.length
4322 && indent.offset.y <= active_indent_range.end
4323 {
4324 return Some(i);
4325 }
4326 }
4327 None
4328 }
4329}
4330
4331fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4332 const ICON_SIZE_FACTOR: usize = 2;
4333 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4334 if is_symlink {
4335 item_width += ICON_SIZE_FACTOR;
4336 }
4337 item_width
4338}
4339
4340impl Render for ProjectPanel {
4341 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4342 let has_worktree = !self.visible_entries.is_empty();
4343 let project = self.project.read(cx);
4344 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4345 let show_indent_guides =
4346 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4347 let is_local = project.is_local();
4348
4349 if has_worktree {
4350 let item_count = self
4351 .visible_entries
4352 .iter()
4353 .map(|(_, worktree_entries, _)| worktree_entries.len())
4354 .sum();
4355
4356 fn handle_drag_move_scroll<T: 'static>(
4357 this: &mut ProjectPanel,
4358 e: &DragMoveEvent<T>,
4359 window: &mut Window,
4360 cx: &mut Context<ProjectPanel>,
4361 ) {
4362 if !e.bounds.contains(&e.event.position) {
4363 return;
4364 }
4365 this.hover_scroll_task.take();
4366 let panel_height = e.bounds.size.height;
4367 if panel_height <= px(0.) {
4368 return;
4369 }
4370
4371 let event_offset = e.event.position.y - e.bounds.origin.y;
4372 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4373 let hovered_region_offset = event_offset / panel_height;
4374
4375 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4376 // These pixels offsets were picked arbitrarily.
4377 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4378 8.
4379 } else if hovered_region_offset <= 0.15 {
4380 5.
4381 } else if hovered_region_offset >= 0.95 {
4382 -8.
4383 } else if hovered_region_offset >= 0.85 {
4384 -5.
4385 } else {
4386 return;
4387 };
4388 let adjustment = point(px(0.), px(vertical_scroll_offset));
4389 this.hover_scroll_task =
4390 Some(cx.spawn_in(window, move |this, mut cx| async move {
4391 loop {
4392 let should_stop_scrolling = this
4393 .update(&mut cx, |this, cx| {
4394 this.hover_scroll_task.as_ref()?;
4395 let handle = this.scroll_handle.0.borrow_mut();
4396 let offset = handle.base_handle.offset();
4397
4398 handle.base_handle.set_offset(offset + adjustment);
4399 cx.notify();
4400 Some(())
4401 })
4402 .ok()
4403 .flatten()
4404 .is_some();
4405 if should_stop_scrolling {
4406 return;
4407 }
4408 cx.background_executor()
4409 .timer(Duration::from_millis(16))
4410 .await;
4411 }
4412 }));
4413 }
4414 h_flex()
4415 .id("project-panel")
4416 .group("project-panel")
4417 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4418 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4419 .size_full()
4420 .relative()
4421 .on_hover(cx.listener(|this, hovered, window, cx| {
4422 if *hovered {
4423 this.show_scrollbar = true;
4424 this.hide_scrollbar_task.take();
4425 cx.notify();
4426 } else if !this.focus_handle.contains_focused(window, cx) {
4427 this.hide_scrollbar(window, cx);
4428 }
4429 }))
4430 .on_click(cx.listener(|this, _event, _, cx| {
4431 cx.stop_propagation();
4432 this.selection = None;
4433 this.marked_entries.clear();
4434 }))
4435 .key_context(self.dispatch_context(window, cx))
4436 .on_action(cx.listener(Self::select_next))
4437 .on_action(cx.listener(Self::select_prev))
4438 .on_action(cx.listener(Self::select_first))
4439 .on_action(cx.listener(Self::select_last))
4440 .on_action(cx.listener(Self::select_parent))
4441 .on_action(cx.listener(Self::select_next_git_entry))
4442 .on_action(cx.listener(Self::select_prev_git_entry))
4443 .on_action(cx.listener(Self::select_next_diagnostic))
4444 .on_action(cx.listener(Self::select_prev_diagnostic))
4445 .on_action(cx.listener(Self::select_next_directory))
4446 .on_action(cx.listener(Self::select_prev_directory))
4447 .on_action(cx.listener(Self::expand_selected_entry))
4448 .on_action(cx.listener(Self::collapse_selected_entry))
4449 .on_action(cx.listener(Self::collapse_all_entries))
4450 .on_action(cx.listener(Self::open))
4451 .on_action(cx.listener(Self::open_permanent))
4452 .on_action(cx.listener(Self::confirm))
4453 .on_action(cx.listener(Self::cancel))
4454 .on_action(cx.listener(Self::copy_path))
4455 .on_action(cx.listener(Self::copy_relative_path))
4456 .on_action(cx.listener(Self::new_search_in_directory))
4457 .on_action(cx.listener(Self::unfold_directory))
4458 .on_action(cx.listener(Self::fold_directory))
4459 .on_action(cx.listener(Self::remove_from_project))
4460 .when(!project.is_read_only(cx), |el| {
4461 el.on_action(cx.listener(Self::new_file))
4462 .on_action(cx.listener(Self::new_directory))
4463 .on_action(cx.listener(Self::rename))
4464 .on_action(cx.listener(Self::delete))
4465 .on_action(cx.listener(Self::trash))
4466 .on_action(cx.listener(Self::cut))
4467 .on_action(cx.listener(Self::copy))
4468 .on_action(cx.listener(Self::paste))
4469 .on_action(cx.listener(Self::duplicate))
4470 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4471 if event.up.click_count > 1 {
4472 if let Some(entry_id) = this.last_worktree_root_id {
4473 let project = this.project.read(cx);
4474
4475 let worktree_id = if let Some(worktree) =
4476 project.worktree_for_entry(entry_id, cx)
4477 {
4478 worktree.read(cx).id()
4479 } else {
4480 return;
4481 };
4482
4483 this.selection = Some(SelectedEntry {
4484 worktree_id,
4485 entry_id,
4486 });
4487
4488 this.new_file(&NewFile, window, cx);
4489 }
4490 }
4491 }))
4492 })
4493 .when(project.is_local(), |el| {
4494 el.on_action(cx.listener(Self::reveal_in_finder))
4495 .on_action(cx.listener(Self::open_system))
4496 .on_action(cx.listener(Self::open_in_terminal))
4497 })
4498 .when(project.is_via_ssh(), |el| {
4499 el.on_action(cx.listener(Self::open_in_terminal))
4500 })
4501 .on_mouse_down(
4502 MouseButton::Right,
4503 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4504 // When deploying the context menu anywhere below the last project entry,
4505 // act as if the user clicked the root of the last worktree.
4506 if let Some(entry_id) = this.last_worktree_root_id {
4507 this.deploy_context_menu(event.position, entry_id, window, cx);
4508 }
4509 }),
4510 )
4511 .track_focus(&self.focus_handle(cx))
4512 .child(
4513 uniform_list(cx.entity().clone(), "entries", item_count, {
4514 |this, range, window, cx| {
4515 let mut items = Vec::with_capacity(range.end - range.start);
4516 this.for_each_visible_entry(
4517 range,
4518 window,
4519 cx,
4520 |id, details, window, cx| {
4521 items.push(this.render_entry(id, details, window, cx));
4522 },
4523 );
4524 items
4525 }
4526 })
4527 .when(show_indent_guides, |list| {
4528 list.with_decoration(
4529 ui::indent_guides(
4530 cx.entity().clone(),
4531 px(indent_size),
4532 IndentGuideColors::panel(cx),
4533 |this, range, window, cx| {
4534 let mut items =
4535 SmallVec::with_capacity(range.end - range.start);
4536 this.iter_visible_entries(
4537 range,
4538 window,
4539 cx,
4540 |entry, entries, _, _| {
4541 let (depth, _) = Self::calculate_depth_and_difference(
4542 entry, entries,
4543 );
4544 items.push(depth);
4545 },
4546 );
4547 items
4548 },
4549 )
4550 .on_click(cx.listener(
4551 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4552 if window.modifiers().secondary() {
4553 let ix = active_indent_guide.offset.y;
4554 let Some((target_entry, worktree)) = maybe!({
4555 let (worktree_id, entry) = this.entry_at_index(ix)?;
4556 let worktree = this
4557 .project
4558 .read(cx)
4559 .worktree_for_id(worktree_id, cx)?;
4560 let target_entry = worktree
4561 .read(cx)
4562 .entry_for_path(&entry.path.parent()?)?;
4563 Some((target_entry, worktree))
4564 }) else {
4565 return;
4566 };
4567
4568 this.collapse_entry(target_entry.clone(), worktree, cx);
4569 }
4570 },
4571 ))
4572 .with_render_fn(
4573 cx.entity().clone(),
4574 move |this, params, _, cx| {
4575 const LEFT_OFFSET: f32 = 14.;
4576 const PADDING_Y: f32 = 4.;
4577 const HITBOX_OVERDRAW: f32 = 3.;
4578
4579 let active_indent_guide_index =
4580 this.find_active_indent_guide(¶ms.indent_guides, cx);
4581
4582 let indent_size = params.indent_size;
4583 let item_height = params.item_height;
4584
4585 params
4586 .indent_guides
4587 .into_iter()
4588 .enumerate()
4589 .map(|(idx, layout)| {
4590 let offset = if layout.continues_offscreen {
4591 px(0.)
4592 } else {
4593 px(PADDING_Y)
4594 };
4595 let bounds = Bounds::new(
4596 point(
4597 px(layout.offset.x as f32) * indent_size
4598 + px(LEFT_OFFSET),
4599 px(layout.offset.y as f32) * item_height
4600 + offset,
4601 ),
4602 size(
4603 px(1.),
4604 px(layout.length as f32) * item_height
4605 - px(offset.0 * 2.),
4606 ),
4607 );
4608 ui::RenderedIndentGuide {
4609 bounds,
4610 layout,
4611 is_active: Some(idx) == active_indent_guide_index,
4612 hitbox: Some(Bounds::new(
4613 point(
4614 bounds.origin.x - px(HITBOX_OVERDRAW),
4615 bounds.origin.y,
4616 ),
4617 size(
4618 bounds.size.width
4619 + px(2. * HITBOX_OVERDRAW),
4620 bounds.size.height,
4621 ),
4622 )),
4623 }
4624 })
4625 .collect()
4626 },
4627 ),
4628 )
4629 })
4630 .size_full()
4631 .with_sizing_behavior(ListSizingBehavior::Infer)
4632 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4633 .with_width_from_item(self.max_width_item_index)
4634 .track_scroll(self.scroll_handle.clone()),
4635 )
4636 .children(self.render_vertical_scrollbar(cx))
4637 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4638 this.pb_4().child(scrollbar)
4639 })
4640 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4641 deferred(
4642 anchored()
4643 .position(*position)
4644 .anchor(gpui::Corner::TopLeft)
4645 .child(menu.clone()),
4646 )
4647 .with_priority(1)
4648 }))
4649 } else {
4650 v_flex()
4651 .id("empty-project_panel")
4652 .size_full()
4653 .p_4()
4654 .track_focus(&self.focus_handle(cx))
4655 .child(
4656 Button::new("open_project", "Open a project")
4657 .full_width()
4658 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4659 .on_click(cx.listener(|this, _, window, cx| {
4660 this.workspace
4661 .update(cx, |_, cx| {
4662 window.dispatch_action(Box::new(workspace::Open), cx)
4663 })
4664 .log_err();
4665 })),
4666 )
4667 .when(is_local, |div| {
4668 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4669 style.bg(cx.theme().colors().drop_target_background)
4670 })
4671 .on_drop(cx.listener(
4672 move |this, external_paths: &ExternalPaths, window, cx| {
4673 this.last_external_paths_drag_over_entry = None;
4674 this.marked_entries.clear();
4675 this.hover_scroll_task.take();
4676 if let Some(task) = this
4677 .workspace
4678 .update(cx, |workspace, cx| {
4679 workspace.open_workspace_for_paths(
4680 true,
4681 external_paths.paths().to_owned(),
4682 window,
4683 cx,
4684 )
4685 })
4686 .log_err()
4687 {
4688 task.detach_and_log_err(cx);
4689 }
4690 cx.stop_propagation();
4691 },
4692 ))
4693 })
4694 }
4695 }
4696}
4697
4698impl Render for DraggedProjectEntryView {
4699 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4700 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4701 h_flex()
4702 .font(ui_font)
4703 .pl(self.click_offset.x + px(12.))
4704 .pt(self.click_offset.y + px(12.))
4705 .child(
4706 div()
4707 .flex()
4708 .gap_1()
4709 .items_center()
4710 .py_1()
4711 .px_2()
4712 .rounded_lg()
4713 .bg(cx.theme().colors().background)
4714 .map(|this| {
4715 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4716 this.child(Label::new(format!("{} entries", self.selections.len())))
4717 } else {
4718 this.child(if let Some(icon) = &self.details.icon {
4719 div().child(Icon::from_path(icon.clone()))
4720 } else {
4721 div()
4722 })
4723 .child(Label::new(self.details.filename.clone()))
4724 }
4725 }),
4726 )
4727 }
4728}
4729
4730impl EventEmitter<Event> for ProjectPanel {}
4731
4732impl EventEmitter<PanelEvent> for ProjectPanel {}
4733
4734impl Panel for ProjectPanel {
4735 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4736 match ProjectPanelSettings::get_global(cx).dock {
4737 ProjectPanelDockPosition::Left => DockPosition::Left,
4738 ProjectPanelDockPosition::Right => DockPosition::Right,
4739 }
4740 }
4741
4742 fn position_is_valid(&self, position: DockPosition) -> bool {
4743 matches!(position, DockPosition::Left | DockPosition::Right)
4744 }
4745
4746 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4747 settings::update_settings_file::<ProjectPanelSettings>(
4748 self.fs.clone(),
4749 cx,
4750 move |settings, _| {
4751 let dock = match position {
4752 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4753 DockPosition::Right => ProjectPanelDockPosition::Right,
4754 };
4755 settings.dock = Some(dock);
4756 },
4757 );
4758 }
4759
4760 fn size(&self, _: &Window, cx: &App) -> Pixels {
4761 self.width
4762 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4763 }
4764
4765 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4766 self.width = size;
4767 self.serialize(cx);
4768 cx.notify();
4769 }
4770
4771 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4772 ProjectPanelSettings::get_global(cx)
4773 .button
4774 .then_some(IconName::FileTree)
4775 }
4776
4777 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4778 Some("Project Panel")
4779 }
4780
4781 fn toggle_action(&self) -> Box<dyn Action> {
4782 Box::new(ToggleFocus)
4783 }
4784
4785 fn persistent_name() -> &'static str {
4786 "Project Panel"
4787 }
4788
4789 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4790 let project = &self.project.read(cx);
4791 project.visible_worktrees(cx).any(|tree| {
4792 tree.read(cx)
4793 .root_entry()
4794 .map_or(false, |entry| entry.is_dir())
4795 })
4796 }
4797
4798 fn activation_priority(&self) -> u32 {
4799 0
4800 }
4801}
4802
4803impl Focusable for ProjectPanel {
4804 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4805 self.focus_handle.clone()
4806 }
4807}
4808
4809impl ClipboardEntry {
4810 fn is_cut(&self) -> bool {
4811 matches!(self, Self::Cut { .. })
4812 }
4813
4814 fn items(&self) -> &BTreeSet<SelectedEntry> {
4815 match self {
4816 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4817 }
4818 }
4819}
4820
4821#[cfg(test)]
4822mod tests {
4823 use super::*;
4824 use collections::HashSet;
4825 use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
4826 use pretty_assertions::assert_eq;
4827 use project::{FakeFs, WorktreeSettings};
4828 use serde_json::json;
4829 use settings::SettingsStore;
4830 use std::path::{Path, PathBuf};
4831 use util::{path, separator};
4832 use workspace::{
4833 item::{Item, ProjectItem},
4834 register_project_item, AppState,
4835 };
4836
4837 #[gpui::test]
4838 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4839 init_test(cx);
4840
4841 let fs = FakeFs::new(cx.executor().clone());
4842 fs.insert_tree(
4843 "/root1",
4844 json!({
4845 ".dockerignore": "",
4846 ".git": {
4847 "HEAD": "",
4848 },
4849 "a": {
4850 "0": { "q": "", "r": "", "s": "" },
4851 "1": { "t": "", "u": "" },
4852 "2": { "v": "", "w": "", "x": "", "y": "" },
4853 },
4854 "b": {
4855 "3": { "Q": "" },
4856 "4": { "R": "", "S": "", "T": "", "U": "" },
4857 },
4858 "C": {
4859 "5": {},
4860 "6": { "V": "", "W": "" },
4861 "7": { "X": "" },
4862 "8": { "Y": {}, "Z": "" }
4863 }
4864 }),
4865 )
4866 .await;
4867 fs.insert_tree(
4868 "/root2",
4869 json!({
4870 "d": {
4871 "9": ""
4872 },
4873 "e": {}
4874 }),
4875 )
4876 .await;
4877
4878 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4879 let workspace =
4880 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4881 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4882 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4883 assert_eq!(
4884 visible_entries_as_strings(&panel, 0..50, cx),
4885 &[
4886 "v root1",
4887 " > .git",
4888 " > a",
4889 " > b",
4890 " > C",
4891 " .dockerignore",
4892 "v root2",
4893 " > d",
4894 " > e",
4895 ]
4896 );
4897
4898 toggle_expand_dir(&panel, "root1/b", cx);
4899 assert_eq!(
4900 visible_entries_as_strings(&panel, 0..50, cx),
4901 &[
4902 "v root1",
4903 " > .git",
4904 " > a",
4905 " v b <== selected",
4906 " > 3",
4907 " > 4",
4908 " > C",
4909 " .dockerignore",
4910 "v root2",
4911 " > d",
4912 " > e",
4913 ]
4914 );
4915
4916 assert_eq!(
4917 visible_entries_as_strings(&panel, 6..9, cx),
4918 &[
4919 //
4920 " > C",
4921 " .dockerignore",
4922 "v root2",
4923 ]
4924 );
4925 }
4926
4927 #[gpui::test]
4928 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4929 init_test_with_editor(cx);
4930
4931 let fs = FakeFs::new(cx.executor().clone());
4932 fs.insert_tree(
4933 path!("/src"),
4934 json!({
4935 "test": {
4936 "first.rs": "// First Rust file",
4937 "second.rs": "// Second Rust file",
4938 "third.rs": "// Third Rust file",
4939 }
4940 }),
4941 )
4942 .await;
4943
4944 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
4945 let workspace =
4946 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4947 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4948 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4949
4950 toggle_expand_dir(&panel, "src/test", cx);
4951 select_path(&panel, "src/test/first.rs", cx);
4952 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4953 cx.executor().run_until_parked();
4954 assert_eq!(
4955 visible_entries_as_strings(&panel, 0..10, cx),
4956 &[
4957 "v src",
4958 " v test",
4959 " first.rs <== selected <== marked",
4960 " second.rs",
4961 " third.rs"
4962 ]
4963 );
4964 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4965
4966 select_path(&panel, "src/test/second.rs", cx);
4967 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4968 cx.executor().run_until_parked();
4969 assert_eq!(
4970 visible_entries_as_strings(&panel, 0..10, cx),
4971 &[
4972 "v src",
4973 " v test",
4974 " first.rs",
4975 " second.rs <== selected <== marked",
4976 " third.rs"
4977 ]
4978 );
4979 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4980 }
4981
4982 #[gpui::test]
4983 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4984 init_test(cx);
4985 cx.update(|cx| {
4986 cx.update_global::<SettingsStore, _>(|store, cx| {
4987 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4988 worktree_settings.file_scan_exclusions =
4989 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4990 });
4991 });
4992 });
4993
4994 let fs = FakeFs::new(cx.background_executor.clone());
4995 fs.insert_tree(
4996 "/root1",
4997 json!({
4998 ".dockerignore": "",
4999 ".git": {
5000 "HEAD": "",
5001 },
5002 "a": {
5003 "0": { "q": "", "r": "", "s": "" },
5004 "1": { "t": "", "u": "" },
5005 "2": { "v": "", "w": "", "x": "", "y": "" },
5006 },
5007 "b": {
5008 "3": { "Q": "" },
5009 "4": { "R": "", "S": "", "T": "", "U": "" },
5010 },
5011 "C": {
5012 "5": {},
5013 "6": { "V": "", "W": "" },
5014 "7": { "X": "" },
5015 "8": { "Y": {}, "Z": "" }
5016 }
5017 }),
5018 )
5019 .await;
5020 fs.insert_tree(
5021 "/root2",
5022 json!({
5023 "d": {
5024 "4": ""
5025 },
5026 "e": {}
5027 }),
5028 )
5029 .await;
5030
5031 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5032 let workspace =
5033 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5034 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5035 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5036 assert_eq!(
5037 visible_entries_as_strings(&panel, 0..50, cx),
5038 &[
5039 "v root1",
5040 " > a",
5041 " > b",
5042 " > C",
5043 " .dockerignore",
5044 "v root2",
5045 " > d",
5046 " > e",
5047 ]
5048 );
5049
5050 toggle_expand_dir(&panel, "root1/b", cx);
5051 assert_eq!(
5052 visible_entries_as_strings(&panel, 0..50, cx),
5053 &[
5054 "v root1",
5055 " > a",
5056 " v b <== selected",
5057 " > 3",
5058 " > C",
5059 " .dockerignore",
5060 "v root2",
5061 " > d",
5062 " > e",
5063 ]
5064 );
5065
5066 toggle_expand_dir(&panel, "root2/d", cx);
5067 assert_eq!(
5068 visible_entries_as_strings(&panel, 0..50, cx),
5069 &[
5070 "v root1",
5071 " > a",
5072 " v b",
5073 " > 3",
5074 " > C",
5075 " .dockerignore",
5076 "v root2",
5077 " v d <== selected",
5078 " > e",
5079 ]
5080 );
5081
5082 toggle_expand_dir(&panel, "root2/e", cx);
5083 assert_eq!(
5084 visible_entries_as_strings(&panel, 0..50, cx),
5085 &[
5086 "v root1",
5087 " > a",
5088 " v b",
5089 " > 3",
5090 " > C",
5091 " .dockerignore",
5092 "v root2",
5093 " v d",
5094 " v e <== selected",
5095 ]
5096 );
5097 }
5098
5099 #[gpui::test]
5100 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
5101 init_test(cx);
5102
5103 let fs = FakeFs::new(cx.executor().clone());
5104 fs.insert_tree(
5105 path!("/root1"),
5106 json!({
5107 "dir_1": {
5108 "nested_dir_1": {
5109 "nested_dir_2": {
5110 "nested_dir_3": {
5111 "file_a.java": "// File contents",
5112 "file_b.java": "// File contents",
5113 "file_c.java": "// File contents",
5114 "nested_dir_4": {
5115 "nested_dir_5": {
5116 "file_d.java": "// File contents",
5117 }
5118 }
5119 }
5120 }
5121 }
5122 }
5123 }),
5124 )
5125 .await;
5126 fs.insert_tree(
5127 path!("/root2"),
5128 json!({
5129 "dir_2": {
5130 "file_1.java": "// File contents",
5131 }
5132 }),
5133 )
5134 .await;
5135
5136 let project = Project::test(
5137 fs.clone(),
5138 [path!("/root1").as_ref(), path!("/root2").as_ref()],
5139 cx,
5140 )
5141 .await;
5142 let workspace =
5143 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5144 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5145 cx.update(|_, cx| {
5146 let settings = *ProjectPanelSettings::get_global(cx);
5147 ProjectPanelSettings::override_global(
5148 ProjectPanelSettings {
5149 auto_fold_dirs: true,
5150 ..settings
5151 },
5152 cx,
5153 );
5154 });
5155 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5156 assert_eq!(
5157 visible_entries_as_strings(&panel, 0..10, cx),
5158 &[
5159 separator!("v root1"),
5160 separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5161 separator!("v root2"),
5162 separator!(" > dir_2"),
5163 ]
5164 );
5165
5166 toggle_expand_dir(
5167 &panel,
5168 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5169 cx,
5170 );
5171 assert_eq!(
5172 visible_entries_as_strings(&panel, 0..10, cx),
5173 &[
5174 separator!("v root1"),
5175 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"),
5176 separator!(" > nested_dir_4/nested_dir_5"),
5177 separator!(" file_a.java"),
5178 separator!(" file_b.java"),
5179 separator!(" file_c.java"),
5180 separator!("v root2"),
5181 separator!(" > dir_2"),
5182 ]
5183 );
5184
5185 toggle_expand_dir(
5186 &panel,
5187 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
5188 cx,
5189 );
5190 assert_eq!(
5191 visible_entries_as_strings(&panel, 0..10, cx),
5192 &[
5193 separator!("v root1"),
5194 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5195 separator!(" v nested_dir_4/nested_dir_5 <== selected"),
5196 separator!(" file_d.java"),
5197 separator!(" file_a.java"),
5198 separator!(" file_b.java"),
5199 separator!(" file_c.java"),
5200 separator!("v root2"),
5201 separator!(" > dir_2"),
5202 ]
5203 );
5204 toggle_expand_dir(&panel, "root2/dir_2", cx);
5205 assert_eq!(
5206 visible_entries_as_strings(&panel, 0..10, cx),
5207 &[
5208 separator!("v root1"),
5209 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5210 separator!(" v nested_dir_4/nested_dir_5"),
5211 separator!(" file_d.java"),
5212 separator!(" file_a.java"),
5213 separator!(" file_b.java"),
5214 separator!(" file_c.java"),
5215 separator!("v root2"),
5216 separator!(" v dir_2 <== selected"),
5217 separator!(" file_1.java"),
5218 ]
5219 );
5220 }
5221
5222 #[gpui::test(iterations = 30)]
5223 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
5224 init_test(cx);
5225
5226 let fs = FakeFs::new(cx.executor().clone());
5227 fs.insert_tree(
5228 "/root1",
5229 json!({
5230 ".dockerignore": "",
5231 ".git": {
5232 "HEAD": "",
5233 },
5234 "a": {
5235 "0": { "q": "", "r": "", "s": "" },
5236 "1": { "t": "", "u": "" },
5237 "2": { "v": "", "w": "", "x": "", "y": "" },
5238 },
5239 "b": {
5240 "3": { "Q": "" },
5241 "4": { "R": "", "S": "", "T": "", "U": "" },
5242 },
5243 "C": {
5244 "5": {},
5245 "6": { "V": "", "W": "" },
5246 "7": { "X": "" },
5247 "8": { "Y": {}, "Z": "" }
5248 }
5249 }),
5250 )
5251 .await;
5252 fs.insert_tree(
5253 "/root2",
5254 json!({
5255 "d": {
5256 "9": ""
5257 },
5258 "e": {}
5259 }),
5260 )
5261 .await;
5262
5263 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5264 let workspace =
5265 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5266 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5267 let panel = workspace
5268 .update(cx, |workspace, window, cx| {
5269 let panel = ProjectPanel::new(workspace, window, cx);
5270 workspace.add_panel(panel.clone(), window, cx);
5271 panel
5272 })
5273 .unwrap();
5274
5275 select_path(&panel, "root1", cx);
5276 assert_eq!(
5277 visible_entries_as_strings(&panel, 0..10, cx),
5278 &[
5279 "v root1 <== selected",
5280 " > .git",
5281 " > a",
5282 " > b",
5283 " > C",
5284 " .dockerignore",
5285 "v root2",
5286 " > d",
5287 " > e",
5288 ]
5289 );
5290
5291 // Add a file with the root folder selected. The filename editor is placed
5292 // before the first file in the root folder.
5293 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5294 panel.update_in(cx, |panel, window, cx| {
5295 assert!(panel.filename_editor.read(cx).is_focused(window));
5296 });
5297 assert_eq!(
5298 visible_entries_as_strings(&panel, 0..10, cx),
5299 &[
5300 "v root1",
5301 " > .git",
5302 " > a",
5303 " > b",
5304 " > C",
5305 " [EDITOR: ''] <== selected",
5306 " .dockerignore",
5307 "v root2",
5308 " > d",
5309 " > e",
5310 ]
5311 );
5312
5313 let confirm = panel.update_in(cx, |panel, window, cx| {
5314 panel.filename_editor.update(cx, |editor, cx| {
5315 editor.set_text("the-new-filename", window, cx)
5316 });
5317 panel.confirm_edit(window, cx).unwrap()
5318 });
5319 assert_eq!(
5320 visible_entries_as_strings(&panel, 0..10, cx),
5321 &[
5322 "v root1",
5323 " > .git",
5324 " > a",
5325 " > b",
5326 " > C",
5327 " [PROCESSING: 'the-new-filename'] <== selected",
5328 " .dockerignore",
5329 "v root2",
5330 " > d",
5331 " > e",
5332 ]
5333 );
5334
5335 confirm.await.unwrap();
5336 assert_eq!(
5337 visible_entries_as_strings(&panel, 0..10, cx),
5338 &[
5339 "v root1",
5340 " > .git",
5341 " > a",
5342 " > b",
5343 " > C",
5344 " .dockerignore",
5345 " the-new-filename <== selected <== marked",
5346 "v root2",
5347 " > d",
5348 " > e",
5349 ]
5350 );
5351
5352 select_path(&panel, "root1/b", cx);
5353 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5354 assert_eq!(
5355 visible_entries_as_strings(&panel, 0..10, cx),
5356 &[
5357 "v root1",
5358 " > .git",
5359 " > a",
5360 " v b",
5361 " > 3",
5362 " > 4",
5363 " [EDITOR: ''] <== selected",
5364 " > C",
5365 " .dockerignore",
5366 " the-new-filename",
5367 ]
5368 );
5369
5370 panel
5371 .update_in(cx, |panel, window, cx| {
5372 panel.filename_editor.update(cx, |editor, cx| {
5373 editor.set_text("another-filename.txt", window, cx)
5374 });
5375 panel.confirm_edit(window, cx).unwrap()
5376 })
5377 .await
5378 .unwrap();
5379 assert_eq!(
5380 visible_entries_as_strings(&panel, 0..10, cx),
5381 &[
5382 "v root1",
5383 " > .git",
5384 " > a",
5385 " v b",
5386 " > 3",
5387 " > 4",
5388 " another-filename.txt <== selected <== marked",
5389 " > C",
5390 " .dockerignore",
5391 " the-new-filename",
5392 ]
5393 );
5394
5395 select_path(&panel, "root1/b/another-filename.txt", cx);
5396 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5397 assert_eq!(
5398 visible_entries_as_strings(&panel, 0..10, cx),
5399 &[
5400 "v root1",
5401 " > .git",
5402 " > a",
5403 " v b",
5404 " > 3",
5405 " > 4",
5406 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
5407 " > C",
5408 " .dockerignore",
5409 " the-new-filename",
5410 ]
5411 );
5412
5413 let confirm = panel.update_in(cx, |panel, window, cx| {
5414 panel.filename_editor.update(cx, |editor, cx| {
5415 let file_name_selections = editor.selections.all::<usize>(cx);
5416 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5417 let file_name_selection = &file_name_selections[0];
5418 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5419 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
5420
5421 editor.set_text("a-different-filename.tar.gz", window, cx)
5422 });
5423 panel.confirm_edit(window, cx).unwrap()
5424 });
5425 assert_eq!(
5426 visible_entries_as_strings(&panel, 0..10, cx),
5427 &[
5428 "v root1",
5429 " > .git",
5430 " > a",
5431 " v b",
5432 " > 3",
5433 " > 4",
5434 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
5435 " > C",
5436 " .dockerignore",
5437 " the-new-filename",
5438 ]
5439 );
5440
5441 confirm.await.unwrap();
5442 assert_eq!(
5443 visible_entries_as_strings(&panel, 0..10, cx),
5444 &[
5445 "v root1",
5446 " > .git",
5447 " > a",
5448 " v b",
5449 " > 3",
5450 " > 4",
5451 " a-different-filename.tar.gz <== selected",
5452 " > C",
5453 " .dockerignore",
5454 " the-new-filename",
5455 ]
5456 );
5457
5458 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5459 assert_eq!(
5460 visible_entries_as_strings(&panel, 0..10, cx),
5461 &[
5462 "v root1",
5463 " > .git",
5464 " > a",
5465 " v b",
5466 " > 3",
5467 " > 4",
5468 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5469 " > C",
5470 " .dockerignore",
5471 " the-new-filename",
5472 ]
5473 );
5474
5475 panel.update_in(cx, |panel, window, cx| {
5476 panel.filename_editor.update(cx, |editor, cx| {
5477 let file_name_selections = editor.selections.all::<usize>(cx);
5478 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5479 let file_name_selection = &file_name_selections[0];
5480 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5481 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
5482
5483 });
5484 panel.cancel(&menu::Cancel, window, cx)
5485 });
5486
5487 panel.update_in(cx, |panel, window, cx| {
5488 panel.new_directory(&NewDirectory, window, cx)
5489 });
5490 assert_eq!(
5491 visible_entries_as_strings(&panel, 0..10, cx),
5492 &[
5493 "v root1",
5494 " > .git",
5495 " > a",
5496 " v b",
5497 " > 3",
5498 " > 4",
5499 " > [EDITOR: ''] <== selected",
5500 " a-different-filename.tar.gz",
5501 " > C",
5502 " .dockerignore",
5503 ]
5504 );
5505
5506 let confirm = panel.update_in(cx, |panel, window, cx| {
5507 panel
5508 .filename_editor
5509 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
5510 panel.confirm_edit(window, cx).unwrap()
5511 });
5512 panel.update_in(cx, |panel, window, cx| {
5513 panel.select_next(&Default::default(), window, cx)
5514 });
5515 assert_eq!(
5516 visible_entries_as_strings(&panel, 0..10, cx),
5517 &[
5518 "v root1",
5519 " > .git",
5520 " > a",
5521 " v b",
5522 " > 3",
5523 " > 4",
5524 " > [PROCESSING: 'new-dir']",
5525 " a-different-filename.tar.gz <== selected",
5526 " > C",
5527 " .dockerignore",
5528 ]
5529 );
5530
5531 confirm.await.unwrap();
5532 assert_eq!(
5533 visible_entries_as_strings(&panel, 0..10, cx),
5534 &[
5535 "v root1",
5536 " > .git",
5537 " > a",
5538 " v b",
5539 " > 3",
5540 " > 4",
5541 " > new-dir",
5542 " a-different-filename.tar.gz <== selected",
5543 " > C",
5544 " .dockerignore",
5545 ]
5546 );
5547
5548 panel.update_in(cx, |panel, window, cx| {
5549 panel.rename(&Default::default(), window, cx)
5550 });
5551 assert_eq!(
5552 visible_entries_as_strings(&panel, 0..10, cx),
5553 &[
5554 "v root1",
5555 " > .git",
5556 " > a",
5557 " v b",
5558 " > 3",
5559 " > 4",
5560 " > new-dir",
5561 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5562 " > C",
5563 " .dockerignore",
5564 ]
5565 );
5566
5567 // Dismiss the rename editor when it loses focus.
5568 workspace.update(cx, |_, window, _| window.blur()).unwrap();
5569 assert_eq!(
5570 visible_entries_as_strings(&panel, 0..10, cx),
5571 &[
5572 "v root1",
5573 " > .git",
5574 " > a",
5575 " v b",
5576 " > 3",
5577 " > 4",
5578 " > new-dir",
5579 " a-different-filename.tar.gz <== selected",
5580 " > C",
5581 " .dockerignore",
5582 ]
5583 );
5584 }
5585
5586 #[gpui::test(iterations = 10)]
5587 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5588 init_test(cx);
5589
5590 let fs = FakeFs::new(cx.executor().clone());
5591 fs.insert_tree(
5592 "/root1",
5593 json!({
5594 ".dockerignore": "",
5595 ".git": {
5596 "HEAD": "",
5597 },
5598 "a": {
5599 "0": { "q": "", "r": "", "s": "" },
5600 "1": { "t": "", "u": "" },
5601 "2": { "v": "", "w": "", "x": "", "y": "" },
5602 },
5603 "b": {
5604 "3": { "Q": "" },
5605 "4": { "R": "", "S": "", "T": "", "U": "" },
5606 },
5607 "C": {
5608 "5": {},
5609 "6": { "V": "", "W": "" },
5610 "7": { "X": "" },
5611 "8": { "Y": {}, "Z": "" }
5612 }
5613 }),
5614 )
5615 .await;
5616 fs.insert_tree(
5617 "/root2",
5618 json!({
5619 "d": {
5620 "9": ""
5621 },
5622 "e": {}
5623 }),
5624 )
5625 .await;
5626
5627 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5628 let workspace =
5629 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5630 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5631 let panel = workspace
5632 .update(cx, |workspace, window, cx| {
5633 let panel = ProjectPanel::new(workspace, window, cx);
5634 workspace.add_panel(panel.clone(), window, cx);
5635 panel
5636 })
5637 .unwrap();
5638
5639 select_path(&panel, "root1", cx);
5640 assert_eq!(
5641 visible_entries_as_strings(&panel, 0..10, cx),
5642 &[
5643 "v root1 <== selected",
5644 " > .git",
5645 " > a",
5646 " > b",
5647 " > C",
5648 " .dockerignore",
5649 "v root2",
5650 " > d",
5651 " > e",
5652 ]
5653 );
5654
5655 // Add a file with the root folder selected. The filename editor is placed
5656 // before the first file in the root folder.
5657 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5658 panel.update_in(cx, |panel, window, cx| {
5659 assert!(panel.filename_editor.read(cx).is_focused(window));
5660 });
5661 assert_eq!(
5662 visible_entries_as_strings(&panel, 0..10, cx),
5663 &[
5664 "v root1",
5665 " > .git",
5666 " > a",
5667 " > b",
5668 " > C",
5669 " [EDITOR: ''] <== selected",
5670 " .dockerignore",
5671 "v root2",
5672 " > d",
5673 " > e",
5674 ]
5675 );
5676
5677 let confirm = panel.update_in(cx, |panel, window, cx| {
5678 panel.filename_editor.update(cx, |editor, cx| {
5679 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
5680 });
5681 panel.confirm_edit(window, cx).unwrap()
5682 });
5683
5684 assert_eq!(
5685 visible_entries_as_strings(&panel, 0..10, cx),
5686 &[
5687 "v root1",
5688 " > .git",
5689 " > a",
5690 " > b",
5691 " > C",
5692 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5693 " .dockerignore",
5694 "v root2",
5695 " > d",
5696 " > e",
5697 ]
5698 );
5699
5700 confirm.await.unwrap();
5701 assert_eq!(
5702 visible_entries_as_strings(&panel, 0..13, cx),
5703 &[
5704 "v root1",
5705 " > .git",
5706 " > a",
5707 " > b",
5708 " v bdir1",
5709 " v dir2",
5710 " the-new-filename <== selected <== marked",
5711 " > C",
5712 " .dockerignore",
5713 "v root2",
5714 " > d",
5715 " > e",
5716 ]
5717 );
5718 }
5719
5720 #[gpui::test]
5721 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5722 init_test(cx);
5723
5724 let fs = FakeFs::new(cx.executor().clone());
5725 fs.insert_tree(
5726 path!("/root1"),
5727 json!({
5728 ".dockerignore": "",
5729 ".git": {
5730 "HEAD": "",
5731 },
5732 }),
5733 )
5734 .await;
5735
5736 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
5737 let workspace =
5738 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5739 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5740 let panel = workspace
5741 .update(cx, |workspace, window, cx| {
5742 let panel = ProjectPanel::new(workspace, window, cx);
5743 workspace.add_panel(panel.clone(), window, cx);
5744 panel
5745 })
5746 .unwrap();
5747
5748 select_path(&panel, "root1", cx);
5749 assert_eq!(
5750 visible_entries_as_strings(&panel, 0..10, cx),
5751 &["v root1 <== selected", " > .git", " .dockerignore",]
5752 );
5753
5754 // Add a file with the root folder selected. The filename editor is placed
5755 // before the first file in the root folder.
5756 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5757 panel.update_in(cx, |panel, window, cx| {
5758 assert!(panel.filename_editor.read(cx).is_focused(window));
5759 });
5760 assert_eq!(
5761 visible_entries_as_strings(&panel, 0..10, cx),
5762 &[
5763 "v root1",
5764 " > .git",
5765 " [EDITOR: ''] <== selected",
5766 " .dockerignore",
5767 ]
5768 );
5769
5770 let confirm = panel.update_in(cx, |panel, window, cx| {
5771 // If we want to create a subdirectory, there should be no prefix slash.
5772 panel
5773 .filename_editor
5774 .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
5775 panel.confirm_edit(window, cx).unwrap()
5776 });
5777
5778 assert_eq!(
5779 visible_entries_as_strings(&panel, 0..10, cx),
5780 &[
5781 "v root1",
5782 " > .git",
5783 " [PROCESSING: 'new_dir/'] <== selected",
5784 " .dockerignore",
5785 ]
5786 );
5787
5788 confirm.await.unwrap();
5789 assert_eq!(
5790 visible_entries_as_strings(&panel, 0..10, cx),
5791 &[
5792 "v root1",
5793 " > .git",
5794 " v new_dir <== selected",
5795 " .dockerignore",
5796 ]
5797 );
5798
5799 // Test filename with whitespace
5800 select_path(&panel, "root1", cx);
5801 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5802 let confirm = panel.update_in(cx, |panel, window, cx| {
5803 // If we want to create a subdirectory, there should be no prefix slash.
5804 panel
5805 .filename_editor
5806 .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
5807 panel.confirm_edit(window, cx).unwrap()
5808 });
5809 confirm.await.unwrap();
5810 assert_eq!(
5811 visible_entries_as_strings(&panel, 0..10, cx),
5812 &[
5813 "v root1",
5814 " > .git",
5815 " v new dir 2 <== selected",
5816 " v new_dir",
5817 " .dockerignore",
5818 ]
5819 );
5820
5821 // Test filename ends with "\"
5822 #[cfg(target_os = "windows")]
5823 {
5824 select_path(&panel, "root1", cx);
5825 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5826 let confirm = panel.update_in(cx, |panel, window, cx| {
5827 // If we want to create a subdirectory, there should be no prefix slash.
5828 panel
5829 .filename_editor
5830 .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
5831 panel.confirm_edit(window, cx).unwrap()
5832 });
5833 confirm.await.unwrap();
5834 assert_eq!(
5835 visible_entries_as_strings(&panel, 0..10, cx),
5836 &[
5837 "v root1",
5838 " > .git",
5839 " v new dir 2",
5840 " v new_dir",
5841 " v new_dir_3 <== selected",
5842 " .dockerignore",
5843 ]
5844 );
5845 }
5846 }
5847
5848 #[gpui::test]
5849 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5850 init_test(cx);
5851
5852 let fs = FakeFs::new(cx.executor().clone());
5853 fs.insert_tree(
5854 "/root1",
5855 json!({
5856 "one.two.txt": "",
5857 "one.txt": ""
5858 }),
5859 )
5860 .await;
5861
5862 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5863 let workspace =
5864 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5865 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5866 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5867
5868 panel.update_in(cx, |panel, window, cx| {
5869 panel.select_next(&Default::default(), window, cx);
5870 panel.select_next(&Default::default(), window, cx);
5871 });
5872
5873 assert_eq!(
5874 visible_entries_as_strings(&panel, 0..50, cx),
5875 &[
5876 //
5877 "v root1",
5878 " one.txt <== selected",
5879 " one.two.txt",
5880 ]
5881 );
5882
5883 // Regression test - file name is created correctly when
5884 // the copied file's name contains multiple dots.
5885 panel.update_in(cx, |panel, window, cx| {
5886 panel.copy(&Default::default(), window, cx);
5887 panel.paste(&Default::default(), window, cx);
5888 });
5889 cx.executor().run_until_parked();
5890
5891 assert_eq!(
5892 visible_entries_as_strings(&panel, 0..50, cx),
5893 &[
5894 //
5895 "v root1",
5896 " one.txt",
5897 " [EDITOR: 'one copy.txt'] <== selected <== marked",
5898 " one.two.txt",
5899 ]
5900 );
5901
5902 panel.update_in(cx, |panel, window, cx| {
5903 panel.filename_editor.update(cx, |editor, cx| {
5904 let file_name_selections = editor.selections.all::<usize>(cx);
5905 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5906 let file_name_selection = &file_name_selections[0];
5907 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5908 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5909 });
5910 assert!(panel.confirm_edit(window, cx).is_none());
5911 });
5912
5913 panel.update_in(cx, |panel, window, cx| {
5914 panel.paste(&Default::default(), window, cx);
5915 });
5916 cx.executor().run_until_parked();
5917
5918 assert_eq!(
5919 visible_entries_as_strings(&panel, 0..50, cx),
5920 &[
5921 //
5922 "v root1",
5923 " one.txt",
5924 " one copy.txt",
5925 " [EDITOR: 'one copy 1.txt'] <== selected <== marked",
5926 " one.two.txt",
5927 ]
5928 );
5929
5930 panel.update_in(cx, |panel, window, cx| {
5931 assert!(panel.confirm_edit(window, cx).is_none())
5932 });
5933 }
5934
5935 #[gpui::test]
5936 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5937 init_test(cx);
5938
5939 let fs = FakeFs::new(cx.executor().clone());
5940 fs.insert_tree(
5941 "/root1",
5942 json!({
5943 "one.txt": "",
5944 "two.txt": "",
5945 "three.txt": "",
5946 "a": {
5947 "0": { "q": "", "r": "", "s": "" },
5948 "1": { "t": "", "u": "" },
5949 "2": { "v": "", "w": "", "x": "", "y": "" },
5950 },
5951 }),
5952 )
5953 .await;
5954
5955 fs.insert_tree(
5956 "/root2",
5957 json!({
5958 "one.txt": "",
5959 "two.txt": "",
5960 "four.txt": "",
5961 "b": {
5962 "3": { "Q": "" },
5963 "4": { "R": "", "S": "", "T": "", "U": "" },
5964 },
5965 }),
5966 )
5967 .await;
5968
5969 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5970 let workspace =
5971 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5972 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5973 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5974
5975 select_path(&panel, "root1/three.txt", cx);
5976 panel.update_in(cx, |panel, window, cx| {
5977 panel.cut(&Default::default(), window, cx);
5978 });
5979
5980 select_path(&panel, "root2/one.txt", cx);
5981 panel.update_in(cx, |panel, window, cx| {
5982 panel.select_next(&Default::default(), window, cx);
5983 panel.paste(&Default::default(), window, cx);
5984 });
5985 cx.executor().run_until_parked();
5986 assert_eq!(
5987 visible_entries_as_strings(&panel, 0..50, cx),
5988 &[
5989 //
5990 "v root1",
5991 " > a",
5992 " one.txt",
5993 " two.txt",
5994 "v root2",
5995 " > b",
5996 " four.txt",
5997 " one.txt",
5998 " three.txt <== selected <== marked",
5999 " two.txt",
6000 ]
6001 );
6002
6003 select_path(&panel, "root1/a", cx);
6004 panel.update_in(cx, |panel, window, cx| {
6005 panel.cut(&Default::default(), window, cx);
6006 });
6007 select_path(&panel, "root2/two.txt", cx);
6008 panel.update_in(cx, |panel, window, cx| {
6009 panel.select_next(&Default::default(), window, cx);
6010 panel.paste(&Default::default(), window, cx);
6011 });
6012
6013 cx.executor().run_until_parked();
6014 assert_eq!(
6015 visible_entries_as_strings(&panel, 0..50, cx),
6016 &[
6017 //
6018 "v root1",
6019 " one.txt",
6020 " two.txt",
6021 "v root2",
6022 " > a <== selected",
6023 " > b",
6024 " four.txt",
6025 " one.txt",
6026 " three.txt <== marked",
6027 " two.txt",
6028 ]
6029 );
6030 }
6031
6032 #[gpui::test]
6033 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
6034 init_test(cx);
6035
6036 let fs = FakeFs::new(cx.executor().clone());
6037 fs.insert_tree(
6038 "/root1",
6039 json!({
6040 "one.txt": "",
6041 "two.txt": "",
6042 "three.txt": "",
6043 "a": {
6044 "0": { "q": "", "r": "", "s": "" },
6045 "1": { "t": "", "u": "" },
6046 "2": { "v": "", "w": "", "x": "", "y": "" },
6047 },
6048 }),
6049 )
6050 .await;
6051
6052 fs.insert_tree(
6053 "/root2",
6054 json!({
6055 "one.txt": "",
6056 "two.txt": "",
6057 "four.txt": "",
6058 "b": {
6059 "3": { "Q": "" },
6060 "4": { "R": "", "S": "", "T": "", "U": "" },
6061 },
6062 }),
6063 )
6064 .await;
6065
6066 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6067 let workspace =
6068 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6069 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6070 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6071
6072 select_path(&panel, "root1/three.txt", cx);
6073 panel.update_in(cx, |panel, window, cx| {
6074 panel.copy(&Default::default(), window, cx);
6075 });
6076
6077 select_path(&panel, "root2/one.txt", cx);
6078 panel.update_in(cx, |panel, window, cx| {
6079 panel.select_next(&Default::default(), window, cx);
6080 panel.paste(&Default::default(), window, cx);
6081 });
6082 cx.executor().run_until_parked();
6083 assert_eq!(
6084 visible_entries_as_strings(&panel, 0..50, cx),
6085 &[
6086 //
6087 "v root1",
6088 " > a",
6089 " one.txt",
6090 " three.txt",
6091 " two.txt",
6092 "v root2",
6093 " > b",
6094 " four.txt",
6095 " one.txt",
6096 " three.txt <== selected <== marked",
6097 " two.txt",
6098 ]
6099 );
6100
6101 select_path(&panel, "root1/three.txt", cx);
6102 panel.update_in(cx, |panel, window, cx| {
6103 panel.copy(&Default::default(), window, cx);
6104 });
6105 select_path(&panel, "root2/two.txt", cx);
6106 panel.update_in(cx, |panel, window, cx| {
6107 panel.select_next(&Default::default(), window, cx);
6108 panel.paste(&Default::default(), window, cx);
6109 });
6110
6111 cx.executor().run_until_parked();
6112 assert_eq!(
6113 visible_entries_as_strings(&panel, 0..50, cx),
6114 &[
6115 //
6116 "v root1",
6117 " > a",
6118 " one.txt",
6119 " three.txt",
6120 " two.txt",
6121 "v root2",
6122 " > b",
6123 " four.txt",
6124 " one.txt",
6125 " three.txt",
6126 " [EDITOR: 'three copy.txt'] <== selected <== marked",
6127 " two.txt",
6128 ]
6129 );
6130
6131 panel.update_in(cx, |panel, window, cx| {
6132 panel.cancel(&menu::Cancel {}, window, cx)
6133 });
6134 cx.executor().run_until_parked();
6135
6136 select_path(&panel, "root1/a", cx);
6137 panel.update_in(cx, |panel, window, cx| {
6138 panel.copy(&Default::default(), window, cx);
6139 });
6140 select_path(&panel, "root2/two.txt", cx);
6141 panel.update_in(cx, |panel, window, cx| {
6142 panel.select_next(&Default::default(), window, cx);
6143 panel.paste(&Default::default(), window, cx);
6144 });
6145
6146 cx.executor().run_until_parked();
6147 assert_eq!(
6148 visible_entries_as_strings(&panel, 0..50, cx),
6149 &[
6150 //
6151 "v root1",
6152 " > a",
6153 " one.txt",
6154 " three.txt",
6155 " two.txt",
6156 "v root2",
6157 " > a <== selected",
6158 " > b",
6159 " four.txt",
6160 " one.txt",
6161 " three.txt",
6162 " three copy.txt",
6163 " two.txt",
6164 ]
6165 );
6166 }
6167
6168 #[gpui::test]
6169 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
6170 init_test(cx);
6171
6172 let fs = FakeFs::new(cx.executor().clone());
6173 fs.insert_tree(
6174 "/root",
6175 json!({
6176 "a": {
6177 "one.txt": "",
6178 "two.txt": "",
6179 "inner_dir": {
6180 "three.txt": "",
6181 "four.txt": "",
6182 }
6183 },
6184 "b": {}
6185 }),
6186 )
6187 .await;
6188
6189 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6190 let workspace =
6191 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6192 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6193 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6194
6195 select_path(&panel, "root/a", cx);
6196 panel.update_in(cx, |panel, window, cx| {
6197 panel.copy(&Default::default(), window, cx);
6198 panel.select_next(&Default::default(), window, cx);
6199 panel.paste(&Default::default(), window, cx);
6200 });
6201 cx.executor().run_until_parked();
6202
6203 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
6204 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
6205
6206 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
6207 assert_ne!(
6208 pasted_dir_file, None,
6209 "Pasted directory file should have an entry"
6210 );
6211
6212 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
6213 assert_ne!(
6214 pasted_dir_inner_dir, None,
6215 "Directories inside pasted directory should have an entry"
6216 );
6217
6218 toggle_expand_dir(&panel, "root/b/a", cx);
6219 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
6220
6221 assert_eq!(
6222 visible_entries_as_strings(&panel, 0..50, cx),
6223 &[
6224 //
6225 "v root",
6226 " > a",
6227 " v b",
6228 " v a",
6229 " v inner_dir <== selected",
6230 " four.txt",
6231 " three.txt",
6232 " one.txt",
6233 " two.txt",
6234 ]
6235 );
6236
6237 select_path(&panel, "root", cx);
6238 panel.update_in(cx, |panel, window, cx| {
6239 panel.paste(&Default::default(), window, cx)
6240 });
6241 cx.executor().run_until_parked();
6242 assert_eq!(
6243 visible_entries_as_strings(&panel, 0..50, cx),
6244 &[
6245 //
6246 "v root",
6247 " > a",
6248 " > [EDITOR: 'a copy'] <== selected",
6249 " v b",
6250 " v a",
6251 " v inner_dir",
6252 " four.txt",
6253 " three.txt",
6254 " one.txt",
6255 " two.txt"
6256 ]
6257 );
6258
6259 let confirm = panel.update_in(cx, |panel, window, cx| {
6260 panel
6261 .filename_editor
6262 .update(cx, |editor, cx| editor.set_text("c", window, cx));
6263 panel.confirm_edit(window, cx).unwrap()
6264 });
6265 assert_eq!(
6266 visible_entries_as_strings(&panel, 0..50, cx),
6267 &[
6268 //
6269 "v root",
6270 " > a",
6271 " > [PROCESSING: 'c'] <== selected",
6272 " v b",
6273 " v a",
6274 " v inner_dir",
6275 " four.txt",
6276 " three.txt",
6277 " one.txt",
6278 " two.txt"
6279 ]
6280 );
6281
6282 confirm.await.unwrap();
6283
6284 panel.update_in(cx, |panel, window, cx| {
6285 panel.paste(&Default::default(), window, cx)
6286 });
6287 cx.executor().run_until_parked();
6288 assert_eq!(
6289 visible_entries_as_strings(&panel, 0..50, cx),
6290 &[
6291 //
6292 "v root",
6293 " > a",
6294 " v b",
6295 " v a",
6296 " v inner_dir",
6297 " four.txt",
6298 " three.txt",
6299 " one.txt",
6300 " two.txt",
6301 " v c",
6302 " > a <== selected",
6303 " > inner_dir",
6304 " one.txt",
6305 " two.txt",
6306 ]
6307 );
6308 }
6309
6310 #[gpui::test]
6311 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
6312 init_test(cx);
6313
6314 let fs = FakeFs::new(cx.executor().clone());
6315 fs.insert_tree(
6316 "/test",
6317 json!({
6318 "dir1": {
6319 "a.txt": "",
6320 "b.txt": "",
6321 },
6322 "dir2": {},
6323 "c.txt": "",
6324 "d.txt": "",
6325 }),
6326 )
6327 .await;
6328
6329 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6330 let workspace =
6331 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6332 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6333 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6334
6335 toggle_expand_dir(&panel, "test/dir1", cx);
6336
6337 cx.simulate_modifiers_change(gpui::Modifiers {
6338 control: true,
6339 ..Default::default()
6340 });
6341
6342 select_path_with_mark(&panel, "test/dir1", cx);
6343 select_path_with_mark(&panel, "test/c.txt", cx);
6344
6345 assert_eq!(
6346 visible_entries_as_strings(&panel, 0..15, cx),
6347 &[
6348 "v test",
6349 " v dir1 <== marked",
6350 " a.txt",
6351 " b.txt",
6352 " > dir2",
6353 " c.txt <== selected <== marked",
6354 " d.txt",
6355 ],
6356 "Initial state before copying dir1 and c.txt"
6357 );
6358
6359 panel.update_in(cx, |panel, window, cx| {
6360 panel.copy(&Default::default(), window, cx);
6361 });
6362 select_path(&panel, "test/dir2", cx);
6363 panel.update_in(cx, |panel, window, cx| {
6364 panel.paste(&Default::default(), window, cx);
6365 });
6366 cx.executor().run_until_parked();
6367
6368 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6369
6370 assert_eq!(
6371 visible_entries_as_strings(&panel, 0..15, cx),
6372 &[
6373 "v test",
6374 " v dir1 <== marked",
6375 " a.txt",
6376 " b.txt",
6377 " v dir2",
6378 " v dir1 <== selected",
6379 " a.txt",
6380 " b.txt",
6381 " c.txt",
6382 " c.txt <== marked",
6383 " d.txt",
6384 ],
6385 "Should copy dir1 as well as c.txt into dir2"
6386 );
6387
6388 // Disambiguating multiple files should not open the rename editor.
6389 select_path(&panel, "test/dir2", cx);
6390 panel.update_in(cx, |panel, window, cx| {
6391 panel.paste(&Default::default(), window, cx);
6392 });
6393 cx.executor().run_until_parked();
6394
6395 assert_eq!(
6396 visible_entries_as_strings(&panel, 0..15, cx),
6397 &[
6398 "v test",
6399 " v dir1 <== marked",
6400 " a.txt",
6401 " b.txt",
6402 " v dir2",
6403 " v dir1",
6404 " a.txt",
6405 " b.txt",
6406 " > dir1 copy <== selected",
6407 " c.txt",
6408 " c copy.txt",
6409 " c.txt <== marked",
6410 " d.txt",
6411 ],
6412 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
6413 );
6414 }
6415
6416 #[gpui::test]
6417 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
6418 init_test(cx);
6419
6420 let fs = FakeFs::new(cx.executor().clone());
6421 fs.insert_tree(
6422 "/test",
6423 json!({
6424 "dir1": {
6425 "a.txt": "",
6426 "b.txt": "",
6427 },
6428 "dir2": {},
6429 "c.txt": "",
6430 "d.txt": "",
6431 }),
6432 )
6433 .await;
6434
6435 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6436 let workspace =
6437 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6438 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6439 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6440
6441 toggle_expand_dir(&panel, "test/dir1", cx);
6442
6443 cx.simulate_modifiers_change(gpui::Modifiers {
6444 control: true,
6445 ..Default::default()
6446 });
6447
6448 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
6449 select_path_with_mark(&panel, "test/dir1", cx);
6450 select_path_with_mark(&panel, "test/c.txt", cx);
6451
6452 assert_eq!(
6453 visible_entries_as_strings(&panel, 0..15, cx),
6454 &[
6455 "v test",
6456 " v dir1 <== marked",
6457 " a.txt <== marked",
6458 " b.txt",
6459 " > dir2",
6460 " c.txt <== selected <== marked",
6461 " d.txt",
6462 ],
6463 "Initial state before copying a.txt, dir1 and c.txt"
6464 );
6465
6466 panel.update_in(cx, |panel, window, cx| {
6467 panel.copy(&Default::default(), window, cx);
6468 });
6469 select_path(&panel, "test/dir2", cx);
6470 panel.update_in(cx, |panel, window, cx| {
6471 panel.paste(&Default::default(), window, cx);
6472 });
6473 cx.executor().run_until_parked();
6474
6475 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6476
6477 assert_eq!(
6478 visible_entries_as_strings(&panel, 0..20, cx),
6479 &[
6480 "v test",
6481 " v dir1 <== marked",
6482 " a.txt <== marked",
6483 " b.txt",
6484 " v dir2",
6485 " v dir1 <== selected",
6486 " a.txt",
6487 " b.txt",
6488 " c.txt",
6489 " c.txt <== marked",
6490 " d.txt",
6491 ],
6492 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
6493 );
6494 }
6495
6496 #[gpui::test]
6497 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
6498 init_test_with_editor(cx);
6499
6500 let fs = FakeFs::new(cx.executor().clone());
6501 fs.insert_tree(
6502 path!("/src"),
6503 json!({
6504 "test": {
6505 "first.rs": "// First Rust file",
6506 "second.rs": "// Second Rust file",
6507 "third.rs": "// Third Rust file",
6508 }
6509 }),
6510 )
6511 .await;
6512
6513 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
6514 let workspace =
6515 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6516 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6517 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6518
6519 toggle_expand_dir(&panel, "src/test", cx);
6520 select_path(&panel, "src/test/first.rs", cx);
6521 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6522 cx.executor().run_until_parked();
6523 assert_eq!(
6524 visible_entries_as_strings(&panel, 0..10, cx),
6525 &[
6526 "v src",
6527 " v test",
6528 " first.rs <== selected <== marked",
6529 " second.rs",
6530 " third.rs"
6531 ]
6532 );
6533 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
6534
6535 submit_deletion(&panel, cx);
6536 assert_eq!(
6537 visible_entries_as_strings(&panel, 0..10, cx),
6538 &[
6539 "v src",
6540 " v test",
6541 " second.rs <== selected",
6542 " third.rs"
6543 ],
6544 "Project panel should have no deleted file, no other file is selected in it"
6545 );
6546 ensure_no_open_items_and_panes(&workspace, cx);
6547
6548 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6549 cx.executor().run_until_parked();
6550 assert_eq!(
6551 visible_entries_as_strings(&panel, 0..10, cx),
6552 &[
6553 "v src",
6554 " v test",
6555 " second.rs <== selected <== marked",
6556 " third.rs"
6557 ]
6558 );
6559 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6560
6561 workspace
6562 .update(cx, |workspace, window, cx| {
6563 let active_items = workspace
6564 .panes()
6565 .iter()
6566 .filter_map(|pane| pane.read(cx).active_item())
6567 .collect::<Vec<_>>();
6568 assert_eq!(active_items.len(), 1);
6569 let open_editor = active_items
6570 .into_iter()
6571 .next()
6572 .unwrap()
6573 .downcast::<Editor>()
6574 .expect("Open item should be an editor");
6575 open_editor.update(cx, |editor, cx| {
6576 editor.set_text("Another text!", window, cx)
6577 });
6578 })
6579 .unwrap();
6580 submit_deletion_skipping_prompt(&panel, cx);
6581 assert_eq!(
6582 visible_entries_as_strings(&panel, 0..10, cx),
6583 &["v src", " v test", " third.rs <== selected"],
6584 "Project panel should have no deleted file, with one last file remaining"
6585 );
6586 ensure_no_open_items_and_panes(&workspace, cx);
6587 }
6588
6589 #[gpui::test]
6590 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6591 init_test_with_editor(cx);
6592
6593 let fs = FakeFs::new(cx.executor().clone());
6594 fs.insert_tree(
6595 "/src",
6596 json!({
6597 "test": {
6598 "first.rs": "// First Rust file",
6599 "second.rs": "// Second Rust file",
6600 "third.rs": "// Third Rust file",
6601 }
6602 }),
6603 )
6604 .await;
6605
6606 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6607 let workspace =
6608 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6609 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6610 let panel = workspace
6611 .update(cx, |workspace, window, cx| {
6612 let panel = ProjectPanel::new(workspace, window, cx);
6613 workspace.add_panel(panel.clone(), window, cx);
6614 panel
6615 })
6616 .unwrap();
6617
6618 select_path(&panel, "src/", cx);
6619 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6620 cx.executor().run_until_parked();
6621 assert_eq!(
6622 visible_entries_as_strings(&panel, 0..10, cx),
6623 &[
6624 //
6625 "v src <== selected",
6626 " > test"
6627 ]
6628 );
6629 panel.update_in(cx, |panel, window, cx| {
6630 panel.new_directory(&NewDirectory, window, cx)
6631 });
6632 panel.update_in(cx, |panel, window, cx| {
6633 assert!(panel.filename_editor.read(cx).is_focused(window));
6634 });
6635 assert_eq!(
6636 visible_entries_as_strings(&panel, 0..10, cx),
6637 &[
6638 //
6639 "v src",
6640 " > [EDITOR: ''] <== selected",
6641 " > test"
6642 ]
6643 );
6644 panel.update_in(cx, |panel, window, cx| {
6645 panel
6646 .filename_editor
6647 .update(cx, |editor, cx| editor.set_text("test", window, cx));
6648 assert!(
6649 panel.confirm_edit(window, cx).is_none(),
6650 "Should not allow to confirm on conflicting new directory name"
6651 )
6652 });
6653 assert_eq!(
6654 visible_entries_as_strings(&panel, 0..10, cx),
6655 &[
6656 //
6657 "v src",
6658 " > test"
6659 ],
6660 "File list should be unchanged after failed folder create confirmation"
6661 );
6662
6663 select_path(&panel, "src/test/", cx);
6664 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6665 cx.executor().run_until_parked();
6666 assert_eq!(
6667 visible_entries_as_strings(&panel, 0..10, cx),
6668 &[
6669 //
6670 "v src",
6671 " > test <== selected"
6672 ]
6673 );
6674 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6675 panel.update_in(cx, |panel, window, cx| {
6676 assert!(panel.filename_editor.read(cx).is_focused(window));
6677 });
6678 assert_eq!(
6679 visible_entries_as_strings(&panel, 0..10, cx),
6680 &[
6681 "v src",
6682 " v test",
6683 " [EDITOR: ''] <== selected",
6684 " first.rs",
6685 " second.rs",
6686 " third.rs"
6687 ]
6688 );
6689 panel.update_in(cx, |panel, window, cx| {
6690 panel
6691 .filename_editor
6692 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
6693 assert!(
6694 panel.confirm_edit(window, cx).is_none(),
6695 "Should not allow to confirm on conflicting new file name"
6696 )
6697 });
6698 assert_eq!(
6699 visible_entries_as_strings(&panel, 0..10, cx),
6700 &[
6701 "v src",
6702 " v test",
6703 " first.rs",
6704 " second.rs",
6705 " third.rs"
6706 ],
6707 "File list should be unchanged after failed file create confirmation"
6708 );
6709
6710 select_path(&panel, "src/test/first.rs", cx);
6711 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6712 cx.executor().run_until_parked();
6713 assert_eq!(
6714 visible_entries_as_strings(&panel, 0..10, cx),
6715 &[
6716 "v src",
6717 " v test",
6718 " first.rs <== selected",
6719 " second.rs",
6720 " third.rs"
6721 ],
6722 );
6723 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
6724 panel.update_in(cx, |panel, window, cx| {
6725 assert!(panel.filename_editor.read(cx).is_focused(window));
6726 });
6727 assert_eq!(
6728 visible_entries_as_strings(&panel, 0..10, cx),
6729 &[
6730 "v src",
6731 " v test",
6732 " [EDITOR: 'first.rs'] <== selected",
6733 " second.rs",
6734 " third.rs"
6735 ]
6736 );
6737 panel.update_in(cx, |panel, window, cx| {
6738 panel
6739 .filename_editor
6740 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
6741 assert!(
6742 panel.confirm_edit(window, cx).is_none(),
6743 "Should not allow to confirm on conflicting file rename"
6744 )
6745 });
6746 assert_eq!(
6747 visible_entries_as_strings(&panel, 0..10, cx),
6748 &[
6749 "v src",
6750 " v test",
6751 " first.rs <== selected",
6752 " second.rs",
6753 " third.rs"
6754 ],
6755 "File list should be unchanged after failed rename confirmation"
6756 );
6757 }
6758
6759 #[gpui::test]
6760 async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
6761 use git::status::{FileStatus, StatusCode, TrackedStatus};
6762 use std::path::Path;
6763
6764 init_test_with_editor(cx);
6765
6766 let fs = FakeFs::new(cx.executor().clone());
6767 fs.insert_tree(
6768 "/root",
6769 json!({
6770 "tree1": {
6771 ".git": {},
6772 "dir1": {
6773 "modified1.txt": "",
6774 "unmodified1.txt": "",
6775 "modified2.txt": "",
6776 },
6777 "dir2": {
6778 "modified3.txt": "",
6779 "unmodified2.txt": "",
6780 },
6781 "modified4.txt": "",
6782 "unmodified3.txt": "",
6783 },
6784 "tree2": {
6785 ".git": {},
6786 "dir3": {
6787 "modified5.txt": "",
6788 "unmodified4.txt": "",
6789 },
6790 "modified6.txt": "",
6791 "unmodified5.txt": "",
6792 }
6793 }),
6794 )
6795 .await;
6796
6797 // Mark files as git modified
6798 let tree1_modified_files = [
6799 "dir1/modified1.txt",
6800 "dir1/modified2.txt",
6801 "modified4.txt",
6802 "dir2/modified3.txt",
6803 ];
6804
6805 let tree2_modified_files = ["dir3/modified5.txt", "modified6.txt"];
6806
6807 let root1_dot_git = Path::new("/root/tree1/.git");
6808 let root2_dot_git = Path::new("/root/tree2/.git");
6809 let set_value = FileStatus::Tracked(TrackedStatus {
6810 index_status: StatusCode::Modified,
6811 worktree_status: StatusCode::Modified,
6812 });
6813
6814 fs.with_git_state(&root1_dot_git, true, |git_repo_state| {
6815 for file_path in tree1_modified_files {
6816 git_repo_state.statuses.insert(file_path.into(), set_value);
6817 }
6818 });
6819
6820 fs.with_git_state(&root2_dot_git, true, |git_repo_state| {
6821 for file_path in tree2_modified_files {
6822 git_repo_state.statuses.insert(file_path.into(), set_value);
6823 }
6824 });
6825
6826 let project = Project::test(
6827 fs.clone(),
6828 ["/root/tree1".as_ref(), "/root/tree2".as_ref()],
6829 cx,
6830 )
6831 .await;
6832 let workspace =
6833 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6834 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6835 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6836
6837 // Check initial state
6838 assert_eq!(
6839 visible_entries_as_strings(&panel, 0..15, cx),
6840 &[
6841 "v tree1",
6842 " > .git",
6843 " > dir1",
6844 " > dir2",
6845 " modified4.txt",
6846 " unmodified3.txt",
6847 "v tree2",
6848 " > .git",
6849 " > dir3",
6850 " modified6.txt",
6851 " unmodified5.txt"
6852 ],
6853 );
6854
6855 // Test selecting next modified entry
6856 panel.update_in(cx, |panel, window, cx| {
6857 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6858 });
6859
6860 assert_eq!(
6861 visible_entries_as_strings(&panel, 0..6, cx),
6862 &[
6863 "v tree1",
6864 " > .git",
6865 " v dir1",
6866 " modified1.txt <== selected",
6867 " modified2.txt",
6868 " unmodified1.txt",
6869 ],
6870 );
6871
6872 panel.update_in(cx, |panel, window, cx| {
6873 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6874 });
6875
6876 assert_eq!(
6877 visible_entries_as_strings(&panel, 0..6, cx),
6878 &[
6879 "v tree1",
6880 " > .git",
6881 " v dir1",
6882 " modified1.txt",
6883 " modified2.txt <== selected",
6884 " unmodified1.txt",
6885 ],
6886 );
6887
6888 panel.update_in(cx, |panel, window, cx| {
6889 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6890 });
6891
6892 assert_eq!(
6893 visible_entries_as_strings(&panel, 6..9, cx),
6894 &[
6895 " v dir2",
6896 " modified3.txt <== selected",
6897 " unmodified2.txt",
6898 ],
6899 );
6900
6901 panel.update_in(cx, |panel, window, cx| {
6902 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6903 });
6904
6905 assert_eq!(
6906 visible_entries_as_strings(&panel, 9..11, cx),
6907 &[" modified4.txt <== selected", " unmodified3.txt",],
6908 );
6909
6910 panel.update_in(cx, |panel, window, cx| {
6911 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6912 });
6913
6914 assert_eq!(
6915 visible_entries_as_strings(&panel, 13..16, cx),
6916 &[
6917 " v dir3",
6918 " modified5.txt <== selected",
6919 " unmodified4.txt",
6920 ],
6921 );
6922
6923 panel.update_in(cx, |panel, window, cx| {
6924 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6925 });
6926
6927 assert_eq!(
6928 visible_entries_as_strings(&panel, 16..18, cx),
6929 &[" modified6.txt <== selected", " unmodified5.txt",],
6930 );
6931
6932 // Wraps around to first modified file
6933 panel.update_in(cx, |panel, window, cx| {
6934 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6935 });
6936
6937 assert_eq!(
6938 visible_entries_as_strings(&panel, 0..18, cx),
6939 &[
6940 "v tree1",
6941 " > .git",
6942 " v dir1",
6943 " modified1.txt <== selected",
6944 " modified2.txt",
6945 " unmodified1.txt",
6946 " v dir2",
6947 " modified3.txt",
6948 " unmodified2.txt",
6949 " modified4.txt",
6950 " unmodified3.txt",
6951 "v tree2",
6952 " > .git",
6953 " v dir3",
6954 " modified5.txt",
6955 " unmodified4.txt",
6956 " modified6.txt",
6957 " unmodified5.txt",
6958 ],
6959 );
6960
6961 // Wraps around again to last modified file
6962 panel.update_in(cx, |panel, window, cx| {
6963 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6964 });
6965
6966 assert_eq!(
6967 visible_entries_as_strings(&panel, 16..18, cx),
6968 &[" modified6.txt <== selected", " unmodified5.txt",],
6969 );
6970
6971 panel.update_in(cx, |panel, window, cx| {
6972 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6973 });
6974
6975 assert_eq!(
6976 visible_entries_as_strings(&panel, 13..16, cx),
6977 &[
6978 " v dir3",
6979 " modified5.txt <== selected",
6980 " unmodified4.txt",
6981 ],
6982 );
6983
6984 panel.update_in(cx, |panel, window, cx| {
6985 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6986 });
6987
6988 assert_eq!(
6989 visible_entries_as_strings(&panel, 9..11, cx),
6990 &[" modified4.txt <== selected", " unmodified3.txt",],
6991 );
6992
6993 panel.update_in(cx, |panel, window, cx| {
6994 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6995 });
6996
6997 assert_eq!(
6998 visible_entries_as_strings(&panel, 6..9, cx),
6999 &[
7000 " v dir2",
7001 " modified3.txt <== selected",
7002 " unmodified2.txt",
7003 ],
7004 );
7005
7006 panel.update_in(cx, |panel, window, cx| {
7007 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
7008 });
7009
7010 assert_eq!(
7011 visible_entries_as_strings(&panel, 0..6, cx),
7012 &[
7013 "v tree1",
7014 " > .git",
7015 " v dir1",
7016 " modified1.txt",
7017 " modified2.txt <== selected",
7018 " unmodified1.txt",
7019 ],
7020 );
7021
7022 panel.update_in(cx, |panel, window, cx| {
7023 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
7024 });
7025
7026 assert_eq!(
7027 visible_entries_as_strings(&panel, 0..6, cx),
7028 &[
7029 "v tree1",
7030 " > .git",
7031 " v dir1",
7032 " modified1.txt <== selected",
7033 " modified2.txt",
7034 " unmodified1.txt",
7035 ],
7036 );
7037 }
7038
7039 #[gpui::test]
7040 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
7041 init_test_with_editor(cx);
7042
7043 let fs = FakeFs::new(cx.executor().clone());
7044 fs.insert_tree(
7045 "/project_root",
7046 json!({
7047 "dir_1": {
7048 "nested_dir": {
7049 "file_a.py": "# File contents",
7050 }
7051 },
7052 "file_1.py": "# File contents",
7053 "dir_2": {
7054
7055 },
7056 "dir_3": {
7057
7058 },
7059 "file_2.py": "# File contents",
7060 "dir_4": {
7061
7062 },
7063 }),
7064 )
7065 .await;
7066
7067 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7068 let workspace =
7069 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7070 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7071 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7072
7073 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7074 cx.executor().run_until_parked();
7075 select_path(&panel, "project_root/dir_1", cx);
7076 cx.executor().run_until_parked();
7077 assert_eq!(
7078 visible_entries_as_strings(&panel, 0..10, cx),
7079 &[
7080 "v project_root",
7081 " > dir_1 <== selected",
7082 " > dir_2",
7083 " > dir_3",
7084 " > dir_4",
7085 " file_1.py",
7086 " file_2.py",
7087 ]
7088 );
7089 panel.update_in(cx, |panel, window, cx| {
7090 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7091 });
7092
7093 assert_eq!(
7094 visible_entries_as_strings(&panel, 0..10, cx),
7095 &[
7096 "v project_root <== selected",
7097 " > dir_1",
7098 " > dir_2",
7099 " > dir_3",
7100 " > dir_4",
7101 " file_1.py",
7102 " file_2.py",
7103 ]
7104 );
7105
7106 panel.update_in(cx, |panel, window, cx| {
7107 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7108 });
7109
7110 assert_eq!(
7111 visible_entries_as_strings(&panel, 0..10, cx),
7112 &[
7113 "v project_root",
7114 " > dir_1",
7115 " > dir_2",
7116 " > dir_3",
7117 " > dir_4 <== selected",
7118 " file_1.py",
7119 " file_2.py",
7120 ]
7121 );
7122
7123 panel.update_in(cx, |panel, window, cx| {
7124 panel.select_next_directory(&SelectNextDirectory, window, cx)
7125 });
7126
7127 assert_eq!(
7128 visible_entries_as_strings(&panel, 0..10, cx),
7129 &[
7130 "v project_root <== selected",
7131 " > dir_1",
7132 " > dir_2",
7133 " > dir_3",
7134 " > dir_4",
7135 " file_1.py",
7136 " file_2.py",
7137 ]
7138 );
7139 }
7140 #[gpui::test]
7141 async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
7142 init_test_with_editor(cx);
7143
7144 let fs = FakeFs::new(cx.executor().clone());
7145 fs.insert_tree(
7146 "/project_root",
7147 json!({
7148 "dir_1": {
7149 "nested_dir": {
7150 "file_a.py": "# File contents",
7151 }
7152 },
7153 "file_1.py": "# File contents",
7154 "file_2.py": "# File contents",
7155 "zdir_2": {
7156 "nested_dir2": {
7157 "file_b.py": "# File contents",
7158 }
7159 },
7160 }),
7161 )
7162 .await;
7163
7164 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7165 let workspace =
7166 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7167 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7168 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7169
7170 assert_eq!(
7171 visible_entries_as_strings(&panel, 0..10, cx),
7172 &[
7173 "v project_root",
7174 " > dir_1",
7175 " > zdir_2",
7176 " file_1.py",
7177 " file_2.py",
7178 ]
7179 );
7180 panel.update_in(cx, |panel, window, cx| {
7181 panel.select_first(&SelectFirst, window, cx)
7182 });
7183
7184 assert_eq!(
7185 visible_entries_as_strings(&panel, 0..10, cx),
7186 &[
7187 "v project_root <== selected",
7188 " > dir_1",
7189 " > zdir_2",
7190 " file_1.py",
7191 " file_2.py",
7192 ]
7193 );
7194
7195 panel.update_in(cx, |panel, window, cx| {
7196 panel.select_last(&SelectLast, window, cx)
7197 });
7198
7199 assert_eq!(
7200 visible_entries_as_strings(&panel, 0..10, cx),
7201 &[
7202 "v project_root",
7203 " > dir_1",
7204 " > zdir_2",
7205 " file_1.py",
7206 " file_2.py <== selected",
7207 ]
7208 );
7209 }
7210
7211 #[gpui::test]
7212 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
7213 init_test_with_editor(cx);
7214
7215 let fs = FakeFs::new(cx.executor().clone());
7216 fs.insert_tree(
7217 "/project_root",
7218 json!({
7219 "dir_1": {
7220 "nested_dir": {
7221 "file_a.py": "# File contents",
7222 }
7223 },
7224 "file_1.py": "# File contents",
7225 }),
7226 )
7227 .await;
7228
7229 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7230 let workspace =
7231 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7232 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7233 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7234
7235 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7236 cx.executor().run_until_parked();
7237 select_path(&panel, "project_root/dir_1", cx);
7238 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7239 select_path(&panel, "project_root/dir_1/nested_dir", cx);
7240 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7241 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7242 cx.executor().run_until_parked();
7243 assert_eq!(
7244 visible_entries_as_strings(&panel, 0..10, cx),
7245 &[
7246 "v project_root",
7247 " v dir_1",
7248 " > nested_dir <== selected",
7249 " file_1.py",
7250 ]
7251 );
7252 }
7253
7254 #[gpui::test]
7255 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
7256 init_test_with_editor(cx);
7257
7258 let fs = FakeFs::new(cx.executor().clone());
7259 fs.insert_tree(
7260 "/project_root",
7261 json!({
7262 "dir_1": {
7263 "nested_dir": {
7264 "file_a.py": "# File contents",
7265 "file_b.py": "# File contents",
7266 "file_c.py": "# File contents",
7267 },
7268 "file_1.py": "# File contents",
7269 "file_2.py": "# File contents",
7270 "file_3.py": "# File contents",
7271 },
7272 "dir_2": {
7273 "file_1.py": "# File contents",
7274 "file_2.py": "# File contents",
7275 "file_3.py": "# File contents",
7276 }
7277 }),
7278 )
7279 .await;
7280
7281 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7282 let workspace =
7283 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7284 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7285 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7286
7287 panel.update_in(cx, |panel, window, cx| {
7288 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
7289 });
7290 cx.executor().run_until_parked();
7291 assert_eq!(
7292 visible_entries_as_strings(&panel, 0..10, cx),
7293 &["v project_root", " > dir_1", " > dir_2",]
7294 );
7295
7296 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
7297 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7298 cx.executor().run_until_parked();
7299 assert_eq!(
7300 visible_entries_as_strings(&panel, 0..10, cx),
7301 &[
7302 "v project_root",
7303 " v dir_1 <== selected",
7304 " > nested_dir",
7305 " file_1.py",
7306 " file_2.py",
7307 " file_3.py",
7308 " > dir_2",
7309 ]
7310 );
7311 }
7312
7313 #[gpui::test]
7314 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
7315 init_test(cx);
7316
7317 let fs = FakeFs::new(cx.executor().clone());
7318 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
7319 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
7320 let workspace =
7321 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7322 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7323 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7324
7325 // Make a new buffer with no backing file
7326 workspace
7327 .update(cx, |workspace, window, cx| {
7328 Editor::new_file(workspace, &Default::default(), window, cx)
7329 })
7330 .unwrap();
7331
7332 cx.executor().run_until_parked();
7333
7334 // "Save as" the buffer, creating a new backing file for it
7335 let save_task = workspace
7336 .update(cx, |workspace, window, cx| {
7337 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7338 })
7339 .unwrap();
7340
7341 cx.executor().run_until_parked();
7342 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
7343 save_task.await.unwrap();
7344
7345 // Rename the file
7346 select_path(&panel, "root/new", cx);
7347 assert_eq!(
7348 visible_entries_as_strings(&panel, 0..10, cx),
7349 &["v root", " new <== selected <== marked"]
7350 );
7351 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7352 panel.update_in(cx, |panel, window, cx| {
7353 panel
7354 .filename_editor
7355 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
7356 });
7357 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7358
7359 cx.executor().run_until_parked();
7360 assert_eq!(
7361 visible_entries_as_strings(&panel, 0..10, cx),
7362 &["v root", " newer <== selected"]
7363 );
7364
7365 workspace
7366 .update(cx, |workspace, window, cx| {
7367 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7368 })
7369 .unwrap()
7370 .await
7371 .unwrap();
7372
7373 cx.executor().run_until_parked();
7374 // assert that saving the file doesn't restore "new"
7375 assert_eq!(
7376 visible_entries_as_strings(&panel, 0..10, cx),
7377 &["v root", " newer <== selected"]
7378 );
7379 }
7380
7381 #[gpui::test]
7382 #[cfg_attr(target_os = "windows", ignore)]
7383 async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
7384 init_test_with_editor(cx);
7385
7386 let fs = FakeFs::new(cx.executor().clone());
7387 fs.insert_tree(
7388 "/root1",
7389 json!({
7390 "dir1": {
7391 "file1.txt": "content 1",
7392 },
7393 }),
7394 )
7395 .await;
7396
7397 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7398 let workspace =
7399 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7400 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7401 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7402
7403 toggle_expand_dir(&panel, "root1/dir1", cx);
7404
7405 assert_eq!(
7406 visible_entries_as_strings(&panel, 0..20, cx),
7407 &["v root1", " v dir1 <== selected", " file1.txt",],
7408 "Initial state with worktrees"
7409 );
7410
7411 select_path(&panel, "root1", cx);
7412 assert_eq!(
7413 visible_entries_as_strings(&panel, 0..20, cx),
7414 &["v root1 <== selected", " v dir1", " file1.txt",],
7415 );
7416
7417 // Rename root1 to new_root1
7418 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7419
7420 assert_eq!(
7421 visible_entries_as_strings(&panel, 0..20, cx),
7422 &[
7423 "v [EDITOR: 'root1'] <== selected",
7424 " v dir1",
7425 " file1.txt",
7426 ],
7427 );
7428
7429 let confirm = panel.update_in(cx, |panel, window, cx| {
7430 panel
7431 .filename_editor
7432 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
7433 panel.confirm_edit(window, cx).unwrap()
7434 });
7435 confirm.await.unwrap();
7436 assert_eq!(
7437 visible_entries_as_strings(&panel, 0..20, cx),
7438 &[
7439 "v new_root1 <== selected",
7440 " v dir1",
7441 " file1.txt",
7442 ],
7443 "Should update worktree name"
7444 );
7445
7446 // Ensure internal paths have been updated
7447 select_path(&panel, "new_root1/dir1/file1.txt", cx);
7448 assert_eq!(
7449 visible_entries_as_strings(&panel, 0..20, cx),
7450 &[
7451 "v new_root1",
7452 " v dir1",
7453 " file1.txt <== selected",
7454 ],
7455 "Files in renamed worktree are selectable"
7456 );
7457 }
7458
7459 #[gpui::test]
7460 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
7461 init_test_with_editor(cx);
7462 let fs = FakeFs::new(cx.executor().clone());
7463 fs.insert_tree(
7464 "/project_root",
7465 json!({
7466 "dir_1": {
7467 "nested_dir": {
7468 "file_a.py": "# File contents",
7469 }
7470 },
7471 "file_1.py": "# File contents",
7472 }),
7473 )
7474 .await;
7475
7476 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7477 let worktree_id =
7478 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7479 let workspace =
7480 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7481 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7482 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7483 cx.update(|window, cx| {
7484 panel.update(cx, |this, cx| {
7485 this.select_next(&Default::default(), window, cx);
7486 this.expand_selected_entry(&Default::default(), window, cx);
7487 this.expand_selected_entry(&Default::default(), window, cx);
7488 this.select_next(&Default::default(), window, cx);
7489 this.expand_selected_entry(&Default::default(), window, cx);
7490 this.select_next(&Default::default(), window, cx);
7491 })
7492 });
7493 assert_eq!(
7494 visible_entries_as_strings(&panel, 0..10, cx),
7495 &[
7496 "v project_root",
7497 " v dir_1",
7498 " v nested_dir",
7499 " file_a.py <== selected",
7500 " file_1.py",
7501 ]
7502 );
7503 let modifiers_with_shift = gpui::Modifiers {
7504 shift: true,
7505 ..Default::default()
7506 };
7507 cx.simulate_modifiers_change(modifiers_with_shift);
7508 cx.update(|window, cx| {
7509 panel.update(cx, |this, cx| {
7510 this.select_next(&Default::default(), window, cx);
7511 })
7512 });
7513 assert_eq!(
7514 visible_entries_as_strings(&panel, 0..10, cx),
7515 &[
7516 "v project_root",
7517 " v dir_1",
7518 " v nested_dir",
7519 " file_a.py",
7520 " file_1.py <== selected <== marked",
7521 ]
7522 );
7523 cx.update(|window, cx| {
7524 panel.update(cx, |this, cx| {
7525 this.select_prev(&Default::default(), window, cx);
7526 })
7527 });
7528 assert_eq!(
7529 visible_entries_as_strings(&panel, 0..10, cx),
7530 &[
7531 "v project_root",
7532 " v dir_1",
7533 " v nested_dir",
7534 " file_a.py <== selected <== marked",
7535 " file_1.py <== marked",
7536 ]
7537 );
7538 cx.update(|window, cx| {
7539 panel.update(cx, |this, cx| {
7540 let drag = DraggedSelection {
7541 active_selection: this.selection.unwrap(),
7542 marked_selections: Arc::new(this.marked_entries.clone()),
7543 };
7544 let target_entry = this
7545 .project
7546 .read(cx)
7547 .entry_for_path(&(worktree_id, "").into(), cx)
7548 .unwrap();
7549 this.drag_onto(&drag, target_entry.id, false, window, cx);
7550 });
7551 });
7552 cx.run_until_parked();
7553 assert_eq!(
7554 visible_entries_as_strings(&panel, 0..10, cx),
7555 &[
7556 "v project_root",
7557 " v dir_1",
7558 " v nested_dir",
7559 " file_1.py <== marked",
7560 " file_a.py <== selected <== marked",
7561 ]
7562 );
7563 // ESC clears out all marks
7564 cx.update(|window, cx| {
7565 panel.update(cx, |this, cx| {
7566 this.cancel(&menu::Cancel, window, cx);
7567 })
7568 });
7569 assert_eq!(
7570 visible_entries_as_strings(&panel, 0..10, cx),
7571 &[
7572 "v project_root",
7573 " v dir_1",
7574 " v nested_dir",
7575 " file_1.py",
7576 " file_a.py <== selected",
7577 ]
7578 );
7579 // ESC clears out all marks
7580 cx.update(|window, cx| {
7581 panel.update(cx, |this, cx| {
7582 this.select_prev(&SelectPrev, window, cx);
7583 this.select_next(&SelectNext, window, cx);
7584 })
7585 });
7586 assert_eq!(
7587 visible_entries_as_strings(&panel, 0..10, cx),
7588 &[
7589 "v project_root",
7590 " v dir_1",
7591 " v nested_dir",
7592 " file_1.py <== marked",
7593 " file_a.py <== selected <== marked",
7594 ]
7595 );
7596 cx.simulate_modifiers_change(Default::default());
7597 cx.update(|window, cx| {
7598 panel.update(cx, |this, cx| {
7599 this.cut(&Cut, window, cx);
7600 this.select_prev(&SelectPrev, window, cx);
7601 this.select_prev(&SelectPrev, window, cx);
7602
7603 this.paste(&Paste, window, cx);
7604 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7605 })
7606 });
7607 cx.run_until_parked();
7608 assert_eq!(
7609 visible_entries_as_strings(&panel, 0..10, cx),
7610 &[
7611 "v project_root",
7612 " v dir_1",
7613 " v nested_dir",
7614 " file_1.py <== marked",
7615 " file_a.py <== selected <== marked",
7616 ]
7617 );
7618 cx.simulate_modifiers_change(modifiers_with_shift);
7619 cx.update(|window, cx| {
7620 panel.update(cx, |this, cx| {
7621 this.expand_selected_entry(&Default::default(), window, cx);
7622 this.select_next(&SelectNext, window, cx);
7623 this.select_next(&SelectNext, window, cx);
7624 })
7625 });
7626 submit_deletion(&panel, cx);
7627 assert_eq!(
7628 visible_entries_as_strings(&panel, 0..10, cx),
7629 &[
7630 "v project_root",
7631 " v dir_1",
7632 " v nested_dir <== selected",
7633 ]
7634 );
7635 }
7636 #[gpui::test]
7637 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7638 init_test_with_editor(cx);
7639 cx.update(|cx| {
7640 cx.update_global::<SettingsStore, _>(|store, cx| {
7641 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7642 worktree_settings.file_scan_exclusions = Some(Vec::new());
7643 });
7644 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7645 project_panel_settings.auto_reveal_entries = Some(false)
7646 });
7647 })
7648 });
7649
7650 let fs = FakeFs::new(cx.background_executor.clone());
7651 fs.insert_tree(
7652 "/project_root",
7653 json!({
7654 ".git": {},
7655 ".gitignore": "**/gitignored_dir",
7656 "dir_1": {
7657 "file_1.py": "# File 1_1 contents",
7658 "file_2.py": "# File 1_2 contents",
7659 "file_3.py": "# File 1_3 contents",
7660 "gitignored_dir": {
7661 "file_a.py": "# File contents",
7662 "file_b.py": "# File contents",
7663 "file_c.py": "# File contents",
7664 },
7665 },
7666 "dir_2": {
7667 "file_1.py": "# File 2_1 contents",
7668 "file_2.py": "# File 2_2 contents",
7669 "file_3.py": "# File 2_3 contents",
7670 }
7671 }),
7672 )
7673 .await;
7674
7675 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7676 let workspace =
7677 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7678 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7679 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7680
7681 assert_eq!(
7682 visible_entries_as_strings(&panel, 0..20, cx),
7683 &[
7684 "v project_root",
7685 " > .git",
7686 " > dir_1",
7687 " > dir_2",
7688 " .gitignore",
7689 ]
7690 );
7691
7692 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7693 .expect("dir 1 file is not ignored and should have an entry");
7694 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7695 .expect("dir 2 file is not ignored and should have an entry");
7696 let gitignored_dir_file =
7697 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7698 assert_eq!(
7699 gitignored_dir_file, None,
7700 "File in the gitignored dir should not have an entry before its dir is toggled"
7701 );
7702
7703 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7704 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7705 cx.executor().run_until_parked();
7706 assert_eq!(
7707 visible_entries_as_strings(&panel, 0..20, cx),
7708 &[
7709 "v project_root",
7710 " > .git",
7711 " v dir_1",
7712 " v gitignored_dir <== selected",
7713 " file_a.py",
7714 " file_b.py",
7715 " file_c.py",
7716 " file_1.py",
7717 " file_2.py",
7718 " file_3.py",
7719 " > dir_2",
7720 " .gitignore",
7721 ],
7722 "Should show gitignored dir file list in the project panel"
7723 );
7724 let gitignored_dir_file =
7725 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7726 .expect("after gitignored dir got opened, a file entry should be present");
7727
7728 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7729 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7730 assert_eq!(
7731 visible_entries_as_strings(&panel, 0..20, cx),
7732 &[
7733 "v project_root",
7734 " > .git",
7735 " > dir_1 <== selected",
7736 " > dir_2",
7737 " .gitignore",
7738 ],
7739 "Should hide all dir contents again and prepare for the auto reveal test"
7740 );
7741
7742 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7743 panel.update(cx, |panel, cx| {
7744 panel.project.update(cx, |_, cx| {
7745 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7746 })
7747 });
7748 cx.run_until_parked();
7749 assert_eq!(
7750 visible_entries_as_strings(&panel, 0..20, cx),
7751 &[
7752 "v project_root",
7753 " > .git",
7754 " > dir_1 <== selected",
7755 " > dir_2",
7756 " .gitignore",
7757 ],
7758 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7759 );
7760 }
7761
7762 cx.update(|_, cx| {
7763 cx.update_global::<SettingsStore, _>(|store, cx| {
7764 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7765 project_panel_settings.auto_reveal_entries = Some(true)
7766 });
7767 })
7768 });
7769
7770 panel.update(cx, |panel, cx| {
7771 panel.project.update(cx, |_, cx| {
7772 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7773 })
7774 });
7775 cx.run_until_parked();
7776 assert_eq!(
7777 visible_entries_as_strings(&panel, 0..20, cx),
7778 &[
7779 "v project_root",
7780 " > .git",
7781 " v dir_1",
7782 " > gitignored_dir",
7783 " file_1.py <== selected <== marked",
7784 " file_2.py",
7785 " file_3.py",
7786 " > dir_2",
7787 " .gitignore",
7788 ],
7789 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7790 );
7791
7792 panel.update(cx, |panel, cx| {
7793 panel.project.update(cx, |_, cx| {
7794 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7795 })
7796 });
7797 cx.run_until_parked();
7798 assert_eq!(
7799 visible_entries_as_strings(&panel, 0..20, cx),
7800 &[
7801 "v project_root",
7802 " > .git",
7803 " v dir_1",
7804 " > gitignored_dir",
7805 " file_1.py",
7806 " file_2.py",
7807 " file_3.py",
7808 " v dir_2",
7809 " file_1.py <== selected <== marked",
7810 " file_2.py",
7811 " file_3.py",
7812 " .gitignore",
7813 ],
7814 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7815 );
7816
7817 panel.update(cx, |panel, cx| {
7818 panel.project.update(cx, |_, cx| {
7819 cx.emit(project::Event::ActiveEntryChanged(Some(
7820 gitignored_dir_file,
7821 )))
7822 })
7823 });
7824 cx.run_until_parked();
7825 assert_eq!(
7826 visible_entries_as_strings(&panel, 0..20, cx),
7827 &[
7828 "v project_root",
7829 " > .git",
7830 " v dir_1",
7831 " > gitignored_dir",
7832 " file_1.py",
7833 " file_2.py",
7834 " file_3.py",
7835 " v dir_2",
7836 " file_1.py <== selected <== marked",
7837 " file_2.py",
7838 " file_3.py",
7839 " .gitignore",
7840 ],
7841 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7842 );
7843
7844 panel.update(cx, |panel, cx| {
7845 panel.project.update(cx, |_, cx| {
7846 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7847 })
7848 });
7849 cx.run_until_parked();
7850 assert_eq!(
7851 visible_entries_as_strings(&panel, 0..20, cx),
7852 &[
7853 "v project_root",
7854 " > .git",
7855 " v dir_1",
7856 " v gitignored_dir",
7857 " file_a.py <== selected <== marked",
7858 " file_b.py",
7859 " file_c.py",
7860 " file_1.py",
7861 " file_2.py",
7862 " file_3.py",
7863 " v dir_2",
7864 " file_1.py",
7865 " file_2.py",
7866 " file_3.py",
7867 " .gitignore",
7868 ],
7869 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7870 );
7871 }
7872
7873 #[gpui::test]
7874 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7875 init_test_with_editor(cx);
7876 cx.update(|cx| {
7877 cx.update_global::<SettingsStore, _>(|store, cx| {
7878 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7879 worktree_settings.file_scan_exclusions = Some(Vec::new());
7880 });
7881 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7882 project_panel_settings.auto_reveal_entries = Some(false)
7883 });
7884 })
7885 });
7886
7887 let fs = FakeFs::new(cx.background_executor.clone());
7888 fs.insert_tree(
7889 "/project_root",
7890 json!({
7891 ".git": {},
7892 ".gitignore": "**/gitignored_dir",
7893 "dir_1": {
7894 "file_1.py": "# File 1_1 contents",
7895 "file_2.py": "# File 1_2 contents",
7896 "file_3.py": "# File 1_3 contents",
7897 "gitignored_dir": {
7898 "file_a.py": "# File contents",
7899 "file_b.py": "# File contents",
7900 "file_c.py": "# File contents",
7901 },
7902 },
7903 "dir_2": {
7904 "file_1.py": "# File 2_1 contents",
7905 "file_2.py": "# File 2_2 contents",
7906 "file_3.py": "# File 2_3 contents",
7907 }
7908 }),
7909 )
7910 .await;
7911
7912 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7913 let workspace =
7914 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7915 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7916 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7917
7918 assert_eq!(
7919 visible_entries_as_strings(&panel, 0..20, cx),
7920 &[
7921 "v project_root",
7922 " > .git",
7923 " > dir_1",
7924 " > dir_2",
7925 " .gitignore",
7926 ]
7927 );
7928
7929 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7930 .expect("dir 1 file is not ignored and should have an entry");
7931 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7932 .expect("dir 2 file is not ignored and should have an entry");
7933 let gitignored_dir_file =
7934 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7935 assert_eq!(
7936 gitignored_dir_file, None,
7937 "File in the gitignored dir should not have an entry before its dir is toggled"
7938 );
7939
7940 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7941 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7942 cx.run_until_parked();
7943 assert_eq!(
7944 visible_entries_as_strings(&panel, 0..20, cx),
7945 &[
7946 "v project_root",
7947 " > .git",
7948 " v dir_1",
7949 " v gitignored_dir <== selected",
7950 " file_a.py",
7951 " file_b.py",
7952 " file_c.py",
7953 " file_1.py",
7954 " file_2.py",
7955 " file_3.py",
7956 " > dir_2",
7957 " .gitignore",
7958 ],
7959 "Should show gitignored dir file list in the project panel"
7960 );
7961 let gitignored_dir_file =
7962 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7963 .expect("after gitignored dir got opened, a file entry should be present");
7964
7965 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7966 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7967 assert_eq!(
7968 visible_entries_as_strings(&panel, 0..20, cx),
7969 &[
7970 "v project_root",
7971 " > .git",
7972 " > dir_1 <== selected",
7973 " > dir_2",
7974 " .gitignore",
7975 ],
7976 "Should hide all dir contents again and prepare for the explicit reveal test"
7977 );
7978
7979 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7980 panel.update(cx, |panel, cx| {
7981 panel.project.update(cx, |_, cx| {
7982 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7983 })
7984 });
7985 cx.run_until_parked();
7986 assert_eq!(
7987 visible_entries_as_strings(&panel, 0..20, cx),
7988 &[
7989 "v project_root",
7990 " > .git",
7991 " > dir_1 <== selected",
7992 " > dir_2",
7993 " .gitignore",
7994 ],
7995 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7996 );
7997 }
7998
7999 panel.update(cx, |panel, cx| {
8000 panel.project.update(cx, |_, cx| {
8001 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
8002 })
8003 });
8004 cx.run_until_parked();
8005 assert_eq!(
8006 visible_entries_as_strings(&panel, 0..20, cx),
8007 &[
8008 "v project_root",
8009 " > .git",
8010 " v dir_1",
8011 " > gitignored_dir",
8012 " file_1.py <== selected <== marked",
8013 " file_2.py",
8014 " file_3.py",
8015 " > dir_2",
8016 " .gitignore",
8017 ],
8018 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
8019 );
8020
8021 panel.update(cx, |panel, cx| {
8022 panel.project.update(cx, |_, cx| {
8023 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
8024 })
8025 });
8026 cx.run_until_parked();
8027 assert_eq!(
8028 visible_entries_as_strings(&panel, 0..20, cx),
8029 &[
8030 "v project_root",
8031 " > .git",
8032 " v dir_1",
8033 " > gitignored_dir",
8034 " file_1.py",
8035 " file_2.py",
8036 " file_3.py",
8037 " v dir_2",
8038 " file_1.py <== selected <== marked",
8039 " file_2.py",
8040 " file_3.py",
8041 " .gitignore",
8042 ],
8043 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
8044 );
8045
8046 panel.update(cx, |panel, cx| {
8047 panel.project.update(cx, |_, cx| {
8048 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
8049 })
8050 });
8051 cx.run_until_parked();
8052 assert_eq!(
8053 visible_entries_as_strings(&panel, 0..20, cx),
8054 &[
8055 "v project_root",
8056 " > .git",
8057 " v dir_1",
8058 " v gitignored_dir",
8059 " file_a.py <== selected <== marked",
8060 " file_b.py",
8061 " file_c.py",
8062 " file_1.py",
8063 " file_2.py",
8064 " file_3.py",
8065 " v dir_2",
8066 " file_1.py",
8067 " file_2.py",
8068 " file_3.py",
8069 " .gitignore",
8070 ],
8071 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
8072 );
8073 }
8074
8075 #[gpui::test]
8076 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
8077 init_test(cx);
8078 cx.update(|cx| {
8079 cx.update_global::<SettingsStore, _>(|store, cx| {
8080 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
8081 project_settings.file_scan_exclusions =
8082 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
8083 });
8084 });
8085 });
8086
8087 cx.update(|cx| {
8088 register_project_item::<TestProjectItemView>(cx);
8089 });
8090
8091 let fs = FakeFs::new(cx.executor().clone());
8092 fs.insert_tree(
8093 "/root1",
8094 json!({
8095 ".dockerignore": "",
8096 ".git": {
8097 "HEAD": "",
8098 },
8099 }),
8100 )
8101 .await;
8102
8103 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8104 let workspace =
8105 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8106 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8107 let panel = workspace
8108 .update(cx, |workspace, window, cx| {
8109 let panel = ProjectPanel::new(workspace, window, cx);
8110 workspace.add_panel(panel.clone(), window, cx);
8111 panel
8112 })
8113 .unwrap();
8114
8115 select_path(&panel, "root1", cx);
8116 assert_eq!(
8117 visible_entries_as_strings(&panel, 0..10, cx),
8118 &["v root1 <== selected", " .dockerignore",]
8119 );
8120 workspace
8121 .update(cx, |workspace, _, cx| {
8122 assert!(
8123 workspace.active_item(cx).is_none(),
8124 "Should have no active items in the beginning"
8125 );
8126 })
8127 .unwrap();
8128
8129 let excluded_file_path = ".git/COMMIT_EDITMSG";
8130 let excluded_dir_path = "excluded_dir";
8131
8132 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
8133 panel.update_in(cx, |panel, window, cx| {
8134 assert!(panel.filename_editor.read(cx).is_focused(window));
8135 });
8136 panel
8137 .update_in(cx, |panel, window, cx| {
8138 panel.filename_editor.update(cx, |editor, cx| {
8139 editor.set_text(excluded_file_path, window, cx)
8140 });
8141 panel.confirm_edit(window, cx).unwrap()
8142 })
8143 .await
8144 .unwrap();
8145
8146 assert_eq!(
8147 visible_entries_as_strings(&panel, 0..13, cx),
8148 &["v root1", " .dockerignore"],
8149 "Excluded dir should not be shown after opening a file in it"
8150 );
8151 panel.update_in(cx, |panel, window, cx| {
8152 assert!(
8153 !panel.filename_editor.read(cx).is_focused(window),
8154 "Should have closed the file name editor"
8155 );
8156 });
8157 workspace
8158 .update(cx, |workspace, _, cx| {
8159 let active_entry_path = workspace
8160 .active_item(cx)
8161 .expect("should have opened and activated the excluded item")
8162 .act_as::<TestProjectItemView>(cx)
8163 .expect(
8164 "should have opened the corresponding project item for the excluded item",
8165 )
8166 .read(cx)
8167 .path
8168 .clone();
8169 assert_eq!(
8170 active_entry_path.path.as_ref(),
8171 Path::new(excluded_file_path),
8172 "Should open the excluded file"
8173 );
8174
8175 assert!(
8176 workspace.notification_ids().is_empty(),
8177 "Should have no notifications after opening an excluded file"
8178 );
8179 })
8180 .unwrap();
8181 assert!(
8182 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
8183 "Should have created the excluded file"
8184 );
8185
8186 select_path(&panel, "root1", cx);
8187 panel.update_in(cx, |panel, window, cx| {
8188 panel.new_directory(&NewDirectory, window, cx)
8189 });
8190 panel.update_in(cx, |panel, window, cx| {
8191 assert!(panel.filename_editor.read(cx).is_focused(window));
8192 });
8193 panel
8194 .update_in(cx, |panel, window, cx| {
8195 panel.filename_editor.update(cx, |editor, cx| {
8196 editor.set_text(excluded_file_path, window, cx)
8197 });
8198 panel.confirm_edit(window, cx).unwrap()
8199 })
8200 .await
8201 .unwrap();
8202
8203 assert_eq!(
8204 visible_entries_as_strings(&panel, 0..13, cx),
8205 &["v root1", " .dockerignore"],
8206 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
8207 );
8208 panel.update_in(cx, |panel, window, cx| {
8209 assert!(
8210 !panel.filename_editor.read(cx).is_focused(window),
8211 "Should have closed the file name editor"
8212 );
8213 });
8214 workspace
8215 .update(cx, |workspace, _, cx| {
8216 let notifications = workspace.notification_ids();
8217 assert_eq!(
8218 notifications.len(),
8219 1,
8220 "Should receive one notification with the error message"
8221 );
8222 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8223 assert!(workspace.notification_ids().is_empty());
8224 })
8225 .unwrap();
8226
8227 select_path(&panel, "root1", cx);
8228 panel.update_in(cx, |panel, window, cx| {
8229 panel.new_directory(&NewDirectory, window, cx)
8230 });
8231 panel.update_in(cx, |panel, window, cx| {
8232 assert!(panel.filename_editor.read(cx).is_focused(window));
8233 });
8234 panel
8235 .update_in(cx, |panel, window, cx| {
8236 panel.filename_editor.update(cx, |editor, cx| {
8237 editor.set_text(excluded_dir_path, window, cx)
8238 });
8239 panel.confirm_edit(window, cx).unwrap()
8240 })
8241 .await
8242 .unwrap();
8243
8244 assert_eq!(
8245 visible_entries_as_strings(&panel, 0..13, cx),
8246 &["v root1", " .dockerignore"],
8247 "Should not change the project panel after trying to create an excluded directory"
8248 );
8249 panel.update_in(cx, |panel, window, cx| {
8250 assert!(
8251 !panel.filename_editor.read(cx).is_focused(window),
8252 "Should have closed the file name editor"
8253 );
8254 });
8255 workspace
8256 .update(cx, |workspace, _, cx| {
8257 let notifications = workspace.notification_ids();
8258 assert_eq!(
8259 notifications.len(),
8260 1,
8261 "Should receive one notification explaining that no directory is actually shown"
8262 );
8263 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8264 assert!(workspace.notification_ids().is_empty());
8265 })
8266 .unwrap();
8267 assert!(
8268 fs.is_dir(Path::new("/root1/excluded_dir")).await,
8269 "Should have created the excluded directory"
8270 );
8271 }
8272
8273 #[gpui::test]
8274 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
8275 init_test_with_editor(cx);
8276
8277 let fs = FakeFs::new(cx.executor().clone());
8278 fs.insert_tree(
8279 "/src",
8280 json!({
8281 "test": {
8282 "first.rs": "// First Rust file",
8283 "second.rs": "// Second Rust file",
8284 "third.rs": "// Third Rust file",
8285 }
8286 }),
8287 )
8288 .await;
8289
8290 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
8291 let workspace =
8292 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8293 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8294 let panel = workspace
8295 .update(cx, |workspace, window, cx| {
8296 let panel = ProjectPanel::new(workspace, window, cx);
8297 workspace.add_panel(panel.clone(), window, cx);
8298 panel
8299 })
8300 .unwrap();
8301
8302 select_path(&panel, "src/", cx);
8303 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
8304 cx.executor().run_until_parked();
8305 assert_eq!(
8306 visible_entries_as_strings(&panel, 0..10, cx),
8307 &[
8308 //
8309 "v src <== selected",
8310 " > test"
8311 ]
8312 );
8313 panel.update_in(cx, |panel, window, cx| {
8314 panel.new_directory(&NewDirectory, window, cx)
8315 });
8316 panel.update_in(cx, |panel, window, cx| {
8317 assert!(panel.filename_editor.read(cx).is_focused(window));
8318 });
8319 assert_eq!(
8320 visible_entries_as_strings(&panel, 0..10, cx),
8321 &[
8322 //
8323 "v src",
8324 " > [EDITOR: ''] <== selected",
8325 " > test"
8326 ]
8327 );
8328
8329 panel.update_in(cx, |panel, window, cx| {
8330 panel.cancel(&menu::Cancel, window, cx)
8331 });
8332 assert_eq!(
8333 visible_entries_as_strings(&panel, 0..10, cx),
8334 &[
8335 //
8336 "v src <== selected",
8337 " > test"
8338 ]
8339 );
8340 }
8341
8342 #[gpui::test]
8343 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
8344 init_test_with_editor(cx);
8345
8346 let fs = FakeFs::new(cx.executor().clone());
8347 fs.insert_tree(
8348 "/root",
8349 json!({
8350 "dir1": {
8351 "subdir1": {},
8352 "file1.txt": "",
8353 "file2.txt": "",
8354 },
8355 "dir2": {
8356 "subdir2": {},
8357 "file3.txt": "",
8358 "file4.txt": "",
8359 },
8360 "file5.txt": "",
8361 "file6.txt": "",
8362 }),
8363 )
8364 .await;
8365
8366 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8367 let workspace =
8368 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8369 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8370 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8371
8372 toggle_expand_dir(&panel, "root/dir1", cx);
8373 toggle_expand_dir(&panel, "root/dir2", cx);
8374
8375 // Test Case 1: Delete middle file in directory
8376 select_path(&panel, "root/dir1/file1.txt", cx);
8377 assert_eq!(
8378 visible_entries_as_strings(&panel, 0..15, cx),
8379 &[
8380 "v root",
8381 " v dir1",
8382 " > subdir1",
8383 " file1.txt <== selected",
8384 " file2.txt",
8385 " v dir2",
8386 " > subdir2",
8387 " file3.txt",
8388 " file4.txt",
8389 " file5.txt",
8390 " file6.txt",
8391 ],
8392 "Initial state before deleting middle file"
8393 );
8394
8395 submit_deletion(&panel, cx);
8396 assert_eq!(
8397 visible_entries_as_strings(&panel, 0..15, cx),
8398 &[
8399 "v root",
8400 " v dir1",
8401 " > subdir1",
8402 " file2.txt <== selected",
8403 " v dir2",
8404 " > subdir2",
8405 " file3.txt",
8406 " file4.txt",
8407 " file5.txt",
8408 " file6.txt",
8409 ],
8410 "Should select next file after deleting middle file"
8411 );
8412
8413 // Test Case 2: Delete last file in directory
8414 submit_deletion(&panel, cx);
8415 assert_eq!(
8416 visible_entries_as_strings(&panel, 0..15, cx),
8417 &[
8418 "v root",
8419 " v dir1",
8420 " > subdir1 <== selected",
8421 " v dir2",
8422 " > subdir2",
8423 " file3.txt",
8424 " file4.txt",
8425 " file5.txt",
8426 " file6.txt",
8427 ],
8428 "Should select next directory when last file is deleted"
8429 );
8430
8431 // Test Case 3: Delete root level file
8432 select_path(&panel, "root/file6.txt", cx);
8433 assert_eq!(
8434 visible_entries_as_strings(&panel, 0..15, cx),
8435 &[
8436 "v root",
8437 " v dir1",
8438 " > subdir1",
8439 " v dir2",
8440 " > subdir2",
8441 " file3.txt",
8442 " file4.txt",
8443 " file5.txt",
8444 " file6.txt <== selected",
8445 ],
8446 "Initial state before deleting root level file"
8447 );
8448
8449 submit_deletion(&panel, cx);
8450 assert_eq!(
8451 visible_entries_as_strings(&panel, 0..15, cx),
8452 &[
8453 "v root",
8454 " v dir1",
8455 " > subdir1",
8456 " v dir2",
8457 " > subdir2",
8458 " file3.txt",
8459 " file4.txt",
8460 " file5.txt <== selected",
8461 ],
8462 "Should select prev entry at root level"
8463 );
8464 }
8465
8466 #[gpui::test]
8467 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
8468 init_test_with_editor(cx);
8469
8470 let fs = FakeFs::new(cx.executor().clone());
8471 fs.insert_tree(
8472 "/root",
8473 json!({
8474 "dir1": {
8475 "subdir1": {
8476 "a.txt": "",
8477 "b.txt": ""
8478 },
8479 "file1.txt": "",
8480 },
8481 "dir2": {
8482 "subdir2": {
8483 "c.txt": "",
8484 "d.txt": ""
8485 },
8486 "file2.txt": "",
8487 },
8488 "file3.txt": "",
8489 }),
8490 )
8491 .await;
8492
8493 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8494 let workspace =
8495 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8496 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8497 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8498
8499 toggle_expand_dir(&panel, "root/dir1", cx);
8500 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8501 toggle_expand_dir(&panel, "root/dir2", cx);
8502 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8503
8504 // Test Case 1: Select and delete nested directory with parent
8505 cx.simulate_modifiers_change(gpui::Modifiers {
8506 control: true,
8507 ..Default::default()
8508 });
8509 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8510 select_path_with_mark(&panel, "root/dir1", cx);
8511
8512 assert_eq!(
8513 visible_entries_as_strings(&panel, 0..15, cx),
8514 &[
8515 "v root",
8516 " v dir1 <== selected <== marked",
8517 " v subdir1 <== marked",
8518 " a.txt",
8519 " b.txt",
8520 " file1.txt",
8521 " v dir2",
8522 " v subdir2",
8523 " c.txt",
8524 " d.txt",
8525 " file2.txt",
8526 " file3.txt",
8527 ],
8528 "Initial state before deleting nested directory with parent"
8529 );
8530
8531 submit_deletion(&panel, cx);
8532 assert_eq!(
8533 visible_entries_as_strings(&panel, 0..15, cx),
8534 &[
8535 "v root",
8536 " v dir2 <== selected",
8537 " v subdir2",
8538 " c.txt",
8539 " d.txt",
8540 " file2.txt",
8541 " file3.txt",
8542 ],
8543 "Should select next directory after deleting directory with parent"
8544 );
8545
8546 // Test Case 2: Select mixed files and directories across levels
8547 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8548 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8549 select_path_with_mark(&panel, "root/file3.txt", cx);
8550
8551 assert_eq!(
8552 visible_entries_as_strings(&panel, 0..15, cx),
8553 &[
8554 "v root",
8555 " v dir2",
8556 " v subdir2",
8557 " c.txt <== marked",
8558 " d.txt",
8559 " file2.txt <== marked",
8560 " file3.txt <== selected <== marked",
8561 ],
8562 "Initial state before deleting"
8563 );
8564
8565 submit_deletion(&panel, cx);
8566 assert_eq!(
8567 visible_entries_as_strings(&panel, 0..15, cx),
8568 &[
8569 "v root",
8570 " v dir2 <== selected",
8571 " v subdir2",
8572 " d.txt",
8573 ],
8574 "Should select sibling directory"
8575 );
8576 }
8577
8578 #[gpui::test]
8579 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8580 init_test_with_editor(cx);
8581
8582 let fs = FakeFs::new(cx.executor().clone());
8583 fs.insert_tree(
8584 "/root",
8585 json!({
8586 "dir1": {
8587 "subdir1": {
8588 "a.txt": "",
8589 "b.txt": ""
8590 },
8591 "file1.txt": "",
8592 },
8593 "dir2": {
8594 "subdir2": {
8595 "c.txt": "",
8596 "d.txt": ""
8597 },
8598 "file2.txt": "",
8599 },
8600 "file3.txt": "",
8601 "file4.txt": "",
8602 }),
8603 )
8604 .await;
8605
8606 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8607 let workspace =
8608 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8609 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8610 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8611
8612 toggle_expand_dir(&panel, "root/dir1", cx);
8613 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8614 toggle_expand_dir(&panel, "root/dir2", cx);
8615 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8616
8617 // Test Case 1: Select all root files and directories
8618 cx.simulate_modifiers_change(gpui::Modifiers {
8619 control: true,
8620 ..Default::default()
8621 });
8622 select_path_with_mark(&panel, "root/dir1", cx);
8623 select_path_with_mark(&panel, "root/dir2", cx);
8624 select_path_with_mark(&panel, "root/file3.txt", cx);
8625 select_path_with_mark(&panel, "root/file4.txt", cx);
8626 assert_eq!(
8627 visible_entries_as_strings(&panel, 0..20, cx),
8628 &[
8629 "v root",
8630 " v dir1 <== marked",
8631 " v subdir1",
8632 " a.txt",
8633 " b.txt",
8634 " file1.txt",
8635 " v dir2 <== marked",
8636 " v subdir2",
8637 " c.txt",
8638 " d.txt",
8639 " file2.txt",
8640 " file3.txt <== marked",
8641 " file4.txt <== selected <== marked",
8642 ],
8643 "State before deleting all contents"
8644 );
8645
8646 submit_deletion(&panel, cx);
8647 assert_eq!(
8648 visible_entries_as_strings(&panel, 0..20, cx),
8649 &["v root <== selected"],
8650 "Only empty root directory should remain after deleting all contents"
8651 );
8652 }
8653
8654 #[gpui::test]
8655 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8656 init_test_with_editor(cx);
8657
8658 let fs = FakeFs::new(cx.executor().clone());
8659 fs.insert_tree(
8660 "/root",
8661 json!({
8662 "dir1": {
8663 "subdir1": {
8664 "file_a.txt": "content a",
8665 "file_b.txt": "content b",
8666 },
8667 "subdir2": {
8668 "file_c.txt": "content c",
8669 },
8670 "file1.txt": "content 1",
8671 },
8672 "dir2": {
8673 "file2.txt": "content 2",
8674 },
8675 }),
8676 )
8677 .await;
8678
8679 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8680 let workspace =
8681 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8682 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8683 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8684
8685 toggle_expand_dir(&panel, "root/dir1", cx);
8686 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8687 toggle_expand_dir(&panel, "root/dir2", cx);
8688 cx.simulate_modifiers_change(gpui::Modifiers {
8689 control: true,
8690 ..Default::default()
8691 });
8692
8693 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8694 select_path_with_mark(&panel, "root/dir1", cx);
8695 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8696 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8697
8698 assert_eq!(
8699 visible_entries_as_strings(&panel, 0..20, cx),
8700 &[
8701 "v root",
8702 " v dir1 <== marked",
8703 " v subdir1 <== marked",
8704 " file_a.txt <== selected <== marked",
8705 " file_b.txt",
8706 " > subdir2",
8707 " file1.txt",
8708 " v dir2",
8709 " file2.txt",
8710 ],
8711 "State with parent dir, subdir, and file selected"
8712 );
8713 submit_deletion(&panel, cx);
8714 assert_eq!(
8715 visible_entries_as_strings(&panel, 0..20, cx),
8716 &["v root", " v dir2 <== selected", " file2.txt",],
8717 "Only dir2 should remain after deletion"
8718 );
8719 }
8720
8721 #[gpui::test]
8722 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8723 init_test_with_editor(cx);
8724
8725 let fs = FakeFs::new(cx.executor().clone());
8726 // First worktree
8727 fs.insert_tree(
8728 "/root1",
8729 json!({
8730 "dir1": {
8731 "file1.txt": "content 1",
8732 "file2.txt": "content 2",
8733 },
8734 "dir2": {
8735 "file3.txt": "content 3",
8736 },
8737 }),
8738 )
8739 .await;
8740
8741 // Second worktree
8742 fs.insert_tree(
8743 "/root2",
8744 json!({
8745 "dir3": {
8746 "file4.txt": "content 4",
8747 "file5.txt": "content 5",
8748 },
8749 "file6.txt": "content 6",
8750 }),
8751 )
8752 .await;
8753
8754 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8755 let workspace =
8756 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8757 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8758 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8759
8760 // Expand all directories for testing
8761 toggle_expand_dir(&panel, "root1/dir1", cx);
8762 toggle_expand_dir(&panel, "root1/dir2", cx);
8763 toggle_expand_dir(&panel, "root2/dir3", cx);
8764
8765 // Test Case 1: Delete files across different worktrees
8766 cx.simulate_modifiers_change(gpui::Modifiers {
8767 control: true,
8768 ..Default::default()
8769 });
8770 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8771 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8772
8773 assert_eq!(
8774 visible_entries_as_strings(&panel, 0..20, cx),
8775 &[
8776 "v root1",
8777 " v dir1",
8778 " file1.txt <== marked",
8779 " file2.txt",
8780 " v dir2",
8781 " file3.txt",
8782 "v root2",
8783 " v dir3",
8784 " file4.txt <== selected <== marked",
8785 " file5.txt",
8786 " file6.txt",
8787 ],
8788 "Initial state with files selected from different worktrees"
8789 );
8790
8791 submit_deletion(&panel, cx);
8792 assert_eq!(
8793 visible_entries_as_strings(&panel, 0..20, cx),
8794 &[
8795 "v root1",
8796 " v dir1",
8797 " file2.txt",
8798 " v dir2",
8799 " file3.txt",
8800 "v root2",
8801 " v dir3",
8802 " file5.txt <== selected",
8803 " file6.txt",
8804 ],
8805 "Should select next file in the last worktree after deletion"
8806 );
8807
8808 // Test Case 2: Delete directories from different worktrees
8809 select_path_with_mark(&panel, "root1/dir1", cx);
8810 select_path_with_mark(&panel, "root2/dir3", cx);
8811
8812 assert_eq!(
8813 visible_entries_as_strings(&panel, 0..20, cx),
8814 &[
8815 "v root1",
8816 " v dir1 <== marked",
8817 " file2.txt",
8818 " v dir2",
8819 " file3.txt",
8820 "v root2",
8821 " v dir3 <== selected <== marked",
8822 " file5.txt",
8823 " file6.txt",
8824 ],
8825 "State with directories marked from different worktrees"
8826 );
8827
8828 submit_deletion(&panel, cx);
8829 assert_eq!(
8830 visible_entries_as_strings(&panel, 0..20, cx),
8831 &[
8832 "v root1",
8833 " v dir2",
8834 " file3.txt",
8835 "v root2",
8836 " file6.txt <== selected",
8837 ],
8838 "Should select remaining file in last worktree after directory deletion"
8839 );
8840
8841 // Test Case 4: Delete all remaining files except roots
8842 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8843 select_path_with_mark(&panel, "root2/file6.txt", cx);
8844
8845 assert_eq!(
8846 visible_entries_as_strings(&panel, 0..20, cx),
8847 &[
8848 "v root1",
8849 " v dir2",
8850 " file3.txt <== marked",
8851 "v root2",
8852 " file6.txt <== selected <== marked",
8853 ],
8854 "State with all remaining files marked"
8855 );
8856
8857 submit_deletion(&panel, cx);
8858 assert_eq!(
8859 visible_entries_as_strings(&panel, 0..20, cx),
8860 &["v root1", " v dir2", "v root2 <== selected"],
8861 "Second parent root should be selected after deleting"
8862 );
8863 }
8864
8865 #[gpui::test]
8866 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8867 init_test_with_editor(cx);
8868
8869 let fs = FakeFs::new(cx.executor().clone());
8870 fs.insert_tree(
8871 "/root",
8872 json!({
8873 "dir1": {
8874 "file1.txt": "",
8875 "file2.txt": "",
8876 "file3.txt": "",
8877 },
8878 "dir2": {
8879 "file4.txt": "",
8880 "file5.txt": "",
8881 },
8882 }),
8883 )
8884 .await;
8885
8886 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8887 let workspace =
8888 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8889 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8890 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8891
8892 toggle_expand_dir(&panel, "root/dir1", cx);
8893 toggle_expand_dir(&panel, "root/dir2", cx);
8894
8895 cx.simulate_modifiers_change(gpui::Modifiers {
8896 control: true,
8897 ..Default::default()
8898 });
8899
8900 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
8901 select_path(&panel, "root/dir1/file1.txt", cx);
8902
8903 assert_eq!(
8904 visible_entries_as_strings(&panel, 0..15, cx),
8905 &[
8906 "v root",
8907 " v dir1",
8908 " file1.txt <== selected",
8909 " file2.txt <== marked",
8910 " file3.txt",
8911 " v dir2",
8912 " file4.txt",
8913 " file5.txt",
8914 ],
8915 "Initial state with one marked entry and different selection"
8916 );
8917
8918 // Delete should operate on the selected entry (file1.txt)
8919 submit_deletion(&panel, cx);
8920 assert_eq!(
8921 visible_entries_as_strings(&panel, 0..15, cx),
8922 &[
8923 "v root",
8924 " v dir1",
8925 " file2.txt <== selected <== marked",
8926 " file3.txt",
8927 " v dir2",
8928 " file4.txt",
8929 " file5.txt",
8930 ],
8931 "Should delete selected file, not marked file"
8932 );
8933
8934 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
8935 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
8936 select_path(&panel, "root/dir2/file5.txt", cx);
8937
8938 assert_eq!(
8939 visible_entries_as_strings(&panel, 0..15, cx),
8940 &[
8941 "v root",
8942 " v dir1",
8943 " file2.txt <== marked",
8944 " file3.txt <== marked",
8945 " v dir2",
8946 " file4.txt <== marked",
8947 " file5.txt <== selected",
8948 ],
8949 "Initial state with multiple marked entries and different selection"
8950 );
8951
8952 // Delete should operate on all marked entries, ignoring the selection
8953 submit_deletion(&panel, cx);
8954 assert_eq!(
8955 visible_entries_as_strings(&panel, 0..15, cx),
8956 &[
8957 "v root",
8958 " v dir1",
8959 " v dir2",
8960 " file5.txt <== selected",
8961 ],
8962 "Should delete all marked files, leaving only the selected file"
8963 );
8964 }
8965
8966 #[gpui::test]
8967 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
8968 init_test_with_editor(cx);
8969
8970 let fs = FakeFs::new(cx.executor().clone());
8971 fs.insert_tree(
8972 "/root_b",
8973 json!({
8974 "dir1": {
8975 "file1.txt": "content 1",
8976 "file2.txt": "content 2",
8977 },
8978 }),
8979 )
8980 .await;
8981
8982 fs.insert_tree(
8983 "/root_c",
8984 json!({
8985 "dir2": {},
8986 }),
8987 )
8988 .await;
8989
8990 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
8991 let workspace =
8992 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8993 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8994 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8995
8996 toggle_expand_dir(&panel, "root_b/dir1", cx);
8997 toggle_expand_dir(&panel, "root_c/dir2", cx);
8998
8999 cx.simulate_modifiers_change(gpui::Modifiers {
9000 control: true,
9001 ..Default::default()
9002 });
9003 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
9004 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
9005
9006 assert_eq!(
9007 visible_entries_as_strings(&panel, 0..20, cx),
9008 &[
9009 "v root_b",
9010 " v dir1",
9011 " file1.txt <== marked",
9012 " file2.txt <== selected <== marked",
9013 "v root_c",
9014 " v dir2",
9015 ],
9016 "Initial state with files marked in root_b"
9017 );
9018
9019 submit_deletion(&panel, cx);
9020 assert_eq!(
9021 visible_entries_as_strings(&panel, 0..20, cx),
9022 &[
9023 "v root_b",
9024 " v dir1 <== selected",
9025 "v root_c",
9026 " v dir2",
9027 ],
9028 "After deletion in root_b as it's last deletion, selection should be in root_b"
9029 );
9030
9031 select_path_with_mark(&panel, "root_c/dir2", cx);
9032
9033 submit_deletion(&panel, cx);
9034 assert_eq!(
9035 visible_entries_as_strings(&panel, 0..20, cx),
9036 &["v root_b", " v dir1", "v root_c <== selected",],
9037 "After deleting from root_c, it should remain in root_c"
9038 );
9039 }
9040
9041 fn toggle_expand_dir(
9042 panel: &Entity<ProjectPanel>,
9043 path: impl AsRef<Path>,
9044 cx: &mut VisualTestContext,
9045 ) {
9046 let path = path.as_ref();
9047 panel.update_in(cx, |panel, window, cx| {
9048 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9049 let worktree = worktree.read(cx);
9050 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9051 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9052 panel.toggle_expanded(entry_id, window, cx);
9053 return;
9054 }
9055 }
9056 panic!("no worktree for path {:?}", path);
9057 });
9058 }
9059
9060 #[gpui::test]
9061 async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
9062 init_test_with_editor(cx);
9063
9064 let fs = FakeFs::new(cx.executor().clone());
9065 fs.insert_tree(
9066 path!("/root"),
9067 json!({
9068 ".gitignore": "**/ignored_dir\n**/ignored_nested",
9069 "dir1": {
9070 "empty1": {
9071 "empty2": {
9072 "empty3": {
9073 "file.txt": ""
9074 }
9075 }
9076 },
9077 "subdir1": {
9078 "file1.txt": "",
9079 "file2.txt": "",
9080 "ignored_nested": {
9081 "ignored_file.txt": ""
9082 }
9083 },
9084 "ignored_dir": {
9085 "subdir": {
9086 "deep_file.txt": ""
9087 }
9088 }
9089 }
9090 }),
9091 )
9092 .await;
9093
9094 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9095 let workspace =
9096 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9097 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9098
9099 // Test 1: When auto-fold is enabled
9100 cx.update(|_, cx| {
9101 let settings = *ProjectPanelSettings::get_global(cx);
9102 ProjectPanelSettings::override_global(
9103 ProjectPanelSettings {
9104 auto_fold_dirs: true,
9105 ..settings
9106 },
9107 cx,
9108 );
9109 });
9110
9111 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9112
9113 assert_eq!(
9114 visible_entries_as_strings(&panel, 0..20, cx),
9115 &["v root", " > dir1", " .gitignore",],
9116 "Initial state should show collapsed root structure"
9117 );
9118
9119 toggle_expand_dir(&panel, "root/dir1", cx);
9120 assert_eq!(
9121 visible_entries_as_strings(&panel, 0..20, cx),
9122 &[
9123 separator!("v root"),
9124 separator!(" v dir1 <== selected"),
9125 separator!(" > empty1/empty2/empty3"),
9126 separator!(" > ignored_dir"),
9127 separator!(" > subdir1"),
9128 separator!(" .gitignore"),
9129 ],
9130 "Should show first level with auto-folded dirs and ignored dir visible"
9131 );
9132
9133 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9134 panel.update(cx, |panel, cx| {
9135 let project = panel.project.read(cx);
9136 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9137 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9138 panel.update_visible_entries(None, cx);
9139 });
9140 cx.run_until_parked();
9141
9142 assert_eq!(
9143 visible_entries_as_strings(&panel, 0..20, cx),
9144 &[
9145 separator!("v root"),
9146 separator!(" v dir1 <== selected"),
9147 separator!(" v empty1"),
9148 separator!(" v empty2"),
9149 separator!(" v empty3"),
9150 separator!(" file.txt"),
9151 separator!(" > ignored_dir"),
9152 separator!(" v subdir1"),
9153 separator!(" > ignored_nested"),
9154 separator!(" file1.txt"),
9155 separator!(" file2.txt"),
9156 separator!(" .gitignore"),
9157 ],
9158 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
9159 );
9160
9161 // Test 2: When auto-fold is disabled
9162 cx.update(|_, cx| {
9163 let settings = *ProjectPanelSettings::get_global(cx);
9164 ProjectPanelSettings::override_global(
9165 ProjectPanelSettings {
9166 auto_fold_dirs: false,
9167 ..settings
9168 },
9169 cx,
9170 );
9171 });
9172
9173 panel.update_in(cx, |panel, window, cx| {
9174 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9175 });
9176
9177 toggle_expand_dir(&panel, "root/dir1", cx);
9178 assert_eq!(
9179 visible_entries_as_strings(&panel, 0..20, cx),
9180 &[
9181 separator!("v root"),
9182 separator!(" v dir1 <== selected"),
9183 separator!(" > empty1"),
9184 separator!(" > ignored_dir"),
9185 separator!(" > subdir1"),
9186 separator!(" .gitignore"),
9187 ],
9188 "With auto-fold disabled: should show all directories separately"
9189 );
9190
9191 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9192 panel.update(cx, |panel, cx| {
9193 let project = panel.project.read(cx);
9194 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9195 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9196 panel.update_visible_entries(None, cx);
9197 });
9198 cx.run_until_parked();
9199
9200 assert_eq!(
9201 visible_entries_as_strings(&panel, 0..20, cx),
9202 &[
9203 separator!("v root"),
9204 separator!(" v dir1 <== selected"),
9205 separator!(" v empty1"),
9206 separator!(" v empty2"),
9207 separator!(" v empty3"),
9208 separator!(" file.txt"),
9209 separator!(" > ignored_dir"),
9210 separator!(" v subdir1"),
9211 separator!(" > ignored_nested"),
9212 separator!(" file1.txt"),
9213 separator!(" file2.txt"),
9214 separator!(" .gitignore"),
9215 ],
9216 "After expand_all without auto-fold: should expand all dirs normally, \
9217 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
9218 );
9219
9220 // Test 3: When explicitly called on ignored directory
9221 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
9222 panel.update(cx, |panel, cx| {
9223 let project = panel.project.read(cx);
9224 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9225 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
9226 panel.update_visible_entries(None, cx);
9227 });
9228 cx.run_until_parked();
9229
9230 assert_eq!(
9231 visible_entries_as_strings(&panel, 0..20, cx),
9232 &[
9233 separator!("v root"),
9234 separator!(" v dir1 <== selected"),
9235 separator!(" v empty1"),
9236 separator!(" v empty2"),
9237 separator!(" v empty3"),
9238 separator!(" file.txt"),
9239 separator!(" v ignored_dir"),
9240 separator!(" v subdir"),
9241 separator!(" deep_file.txt"),
9242 separator!(" v subdir1"),
9243 separator!(" > ignored_nested"),
9244 separator!(" file1.txt"),
9245 separator!(" file2.txt"),
9246 separator!(" .gitignore"),
9247 ],
9248 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
9249 );
9250 }
9251
9252 #[gpui::test]
9253 async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
9254 init_test(cx);
9255
9256 let fs = FakeFs::new(cx.executor().clone());
9257 fs.insert_tree(
9258 path!("/root"),
9259 json!({
9260 "dir1": {
9261 "subdir1": {
9262 "nested1": {
9263 "file1.txt": "",
9264 "file2.txt": ""
9265 },
9266 },
9267 "subdir2": {
9268 "file4.txt": ""
9269 }
9270 },
9271 "dir2": {
9272 "single_file": {
9273 "file5.txt": ""
9274 }
9275 }
9276 }),
9277 )
9278 .await;
9279
9280 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9281 let workspace =
9282 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9283 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9284
9285 // Test 1: Basic collapsing
9286 {
9287 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9288
9289 toggle_expand_dir(&panel, "root/dir1", cx);
9290 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9291 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9292 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
9293
9294 assert_eq!(
9295 visible_entries_as_strings(&panel, 0..20, cx),
9296 &[
9297 separator!("v root"),
9298 separator!(" v dir1"),
9299 separator!(" v subdir1"),
9300 separator!(" v nested1"),
9301 separator!(" file1.txt"),
9302 separator!(" file2.txt"),
9303 separator!(" v subdir2 <== selected"),
9304 separator!(" file4.txt"),
9305 separator!(" > dir2"),
9306 ],
9307 "Initial state with everything expanded"
9308 );
9309
9310 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9311 panel.update(cx, |panel, cx| {
9312 let project = panel.project.read(cx);
9313 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9314 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9315 panel.update_visible_entries(None, cx);
9316 });
9317
9318 assert_eq!(
9319 visible_entries_as_strings(&panel, 0..20, cx),
9320 &["v root", " > dir1", " > dir2",],
9321 "All subdirs under dir1 should be collapsed"
9322 );
9323 }
9324
9325 // Test 2: With auto-fold enabled
9326 {
9327 cx.update(|_, cx| {
9328 let settings = *ProjectPanelSettings::get_global(cx);
9329 ProjectPanelSettings::override_global(
9330 ProjectPanelSettings {
9331 auto_fold_dirs: true,
9332 ..settings
9333 },
9334 cx,
9335 );
9336 });
9337
9338 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9339
9340 toggle_expand_dir(&panel, "root/dir1", cx);
9341 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9342 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9343
9344 assert_eq!(
9345 visible_entries_as_strings(&panel, 0..20, cx),
9346 &[
9347 separator!("v root"),
9348 separator!(" v dir1"),
9349 separator!(" v subdir1/nested1 <== selected"),
9350 separator!(" file1.txt"),
9351 separator!(" file2.txt"),
9352 separator!(" > subdir2"),
9353 separator!(" > dir2/single_file"),
9354 ],
9355 "Initial state with some dirs expanded"
9356 );
9357
9358 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9359 panel.update(cx, |panel, cx| {
9360 let project = panel.project.read(cx);
9361 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9362 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9363 });
9364
9365 toggle_expand_dir(&panel, "root/dir1", cx);
9366
9367 assert_eq!(
9368 visible_entries_as_strings(&panel, 0..20, cx),
9369 &[
9370 separator!("v root"),
9371 separator!(" v dir1 <== selected"),
9372 separator!(" > subdir1/nested1"),
9373 separator!(" > subdir2"),
9374 separator!(" > dir2/single_file"),
9375 ],
9376 "Subdirs should be collapsed and folded with auto-fold enabled"
9377 );
9378 }
9379
9380 // Test 3: With auto-fold disabled
9381 {
9382 cx.update(|_, cx| {
9383 let settings = *ProjectPanelSettings::get_global(cx);
9384 ProjectPanelSettings::override_global(
9385 ProjectPanelSettings {
9386 auto_fold_dirs: false,
9387 ..settings
9388 },
9389 cx,
9390 );
9391 });
9392
9393 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9394
9395 toggle_expand_dir(&panel, "root/dir1", cx);
9396 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9397 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9398
9399 assert_eq!(
9400 visible_entries_as_strings(&panel, 0..20, cx),
9401 &[
9402 separator!("v root"),
9403 separator!(" v dir1"),
9404 separator!(" v subdir1"),
9405 separator!(" v nested1 <== selected"),
9406 separator!(" file1.txt"),
9407 separator!(" file2.txt"),
9408 separator!(" > subdir2"),
9409 separator!(" > dir2"),
9410 ],
9411 "Initial state with some dirs expanded and auto-fold disabled"
9412 );
9413
9414 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9415 panel.update(cx, |panel, cx| {
9416 let project = panel.project.read(cx);
9417 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9418 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9419 });
9420
9421 toggle_expand_dir(&panel, "root/dir1", cx);
9422
9423 assert_eq!(
9424 visible_entries_as_strings(&panel, 0..20, cx),
9425 &[
9426 separator!("v root"),
9427 separator!(" v dir1 <== selected"),
9428 separator!(" > subdir1"),
9429 separator!(" > subdir2"),
9430 separator!(" > dir2"),
9431 ],
9432 "Subdirs should be collapsed but not folded with auto-fold disabled"
9433 );
9434 }
9435 }
9436
9437 fn select_path(
9438 panel: &Entity<ProjectPanel>,
9439 path: impl AsRef<Path>,
9440 cx: &mut VisualTestContext,
9441 ) {
9442 let path = path.as_ref();
9443 panel.update(cx, |panel, cx| {
9444 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9445 let worktree = worktree.read(cx);
9446 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9447 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9448 panel.selection = Some(crate::SelectedEntry {
9449 worktree_id: worktree.id(),
9450 entry_id,
9451 });
9452 return;
9453 }
9454 }
9455 panic!("no worktree for path {:?}", path);
9456 });
9457 }
9458
9459 fn select_path_with_mark(
9460 panel: &Entity<ProjectPanel>,
9461 path: impl AsRef<Path>,
9462 cx: &mut VisualTestContext,
9463 ) {
9464 let path = path.as_ref();
9465 panel.update(cx, |panel, cx| {
9466 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9467 let worktree = worktree.read(cx);
9468 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9469 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9470 let entry = crate::SelectedEntry {
9471 worktree_id: worktree.id(),
9472 entry_id,
9473 };
9474 if !panel.marked_entries.contains(&entry) {
9475 panel.marked_entries.insert(entry);
9476 }
9477 panel.selection = Some(entry);
9478 return;
9479 }
9480 }
9481 panic!("no worktree for path {:?}", path);
9482 });
9483 }
9484
9485 fn find_project_entry(
9486 panel: &Entity<ProjectPanel>,
9487 path: impl AsRef<Path>,
9488 cx: &mut VisualTestContext,
9489 ) -> Option<ProjectEntryId> {
9490 let path = path.as_ref();
9491 panel.update(cx, |panel, cx| {
9492 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9493 let worktree = worktree.read(cx);
9494 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9495 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9496 }
9497 }
9498 panic!("no worktree for path {path:?}");
9499 })
9500 }
9501
9502 fn visible_entries_as_strings(
9503 panel: &Entity<ProjectPanel>,
9504 range: Range<usize>,
9505 cx: &mut VisualTestContext,
9506 ) -> Vec<String> {
9507 let mut result = Vec::new();
9508 let mut project_entries = HashSet::default();
9509 let mut has_editor = false;
9510
9511 panel.update_in(cx, |panel, window, cx| {
9512 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
9513 if details.is_editing {
9514 assert!(!has_editor, "duplicate editor entry");
9515 has_editor = true;
9516 } else {
9517 assert!(
9518 project_entries.insert(project_entry),
9519 "duplicate project entry {:?} {:?}",
9520 project_entry,
9521 details
9522 );
9523 }
9524
9525 let indent = " ".repeat(details.depth);
9526 let icon = if details.kind.is_dir() {
9527 if details.is_expanded {
9528 "v "
9529 } else {
9530 "> "
9531 }
9532 } else {
9533 " "
9534 };
9535 let name = if details.is_editing {
9536 format!("[EDITOR: '{}']", details.filename)
9537 } else if details.is_processing {
9538 format!("[PROCESSING: '{}']", details.filename)
9539 } else {
9540 details.filename.clone()
9541 };
9542 let selected = if details.is_selected {
9543 " <== selected"
9544 } else {
9545 ""
9546 };
9547 let marked = if details.is_marked {
9548 " <== marked"
9549 } else {
9550 ""
9551 };
9552
9553 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9554 });
9555 });
9556
9557 result
9558 }
9559
9560 fn init_test(cx: &mut TestAppContext) {
9561 cx.update(|cx| {
9562 let settings_store = SettingsStore::test(cx);
9563 cx.set_global(settings_store);
9564 init_settings(cx);
9565 theme::init(theme::LoadThemes::JustBase, cx);
9566 language::init(cx);
9567 editor::init_settings(cx);
9568 crate::init(cx);
9569 workspace::init_settings(cx);
9570 client::init_settings(cx);
9571 Project::init_settings(cx);
9572
9573 cx.update_global::<SettingsStore, _>(|store, cx| {
9574 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9575 project_panel_settings.auto_fold_dirs = Some(false);
9576 });
9577 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9578 worktree_settings.file_scan_exclusions = Some(Vec::new());
9579 });
9580 });
9581 });
9582 }
9583
9584 fn init_test_with_editor(cx: &mut TestAppContext) {
9585 cx.update(|cx| {
9586 let app_state = AppState::test(cx);
9587 theme::init(theme::LoadThemes::JustBase, cx);
9588 init_settings(cx);
9589 language::init(cx);
9590 editor::init(cx);
9591 crate::init(cx);
9592 workspace::init(app_state.clone(), cx);
9593 Project::init_settings(cx);
9594
9595 cx.update_global::<SettingsStore, _>(|store, cx| {
9596 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9597 project_panel_settings.auto_fold_dirs = Some(false);
9598 });
9599 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9600 worktree_settings.file_scan_exclusions = Some(Vec::new());
9601 });
9602 });
9603 });
9604 }
9605
9606 fn ensure_single_file_is_opened(
9607 window: &WindowHandle<Workspace>,
9608 expected_path: &str,
9609 cx: &mut TestAppContext,
9610 ) {
9611 window
9612 .update(cx, |workspace, _, cx| {
9613 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9614 assert_eq!(worktrees.len(), 1);
9615 let worktree_id = worktrees[0].read(cx).id();
9616
9617 let open_project_paths = workspace
9618 .panes()
9619 .iter()
9620 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9621 .collect::<Vec<_>>();
9622 assert_eq!(
9623 open_project_paths,
9624 vec![ProjectPath {
9625 worktree_id,
9626 path: Arc::from(Path::new(expected_path))
9627 }],
9628 "Should have opened file, selected in project panel"
9629 );
9630 })
9631 .unwrap();
9632 }
9633
9634 fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9635 assert!(
9636 !cx.has_pending_prompt(),
9637 "Should have no prompts before the deletion"
9638 );
9639 panel.update_in(cx, |panel, window, cx| {
9640 panel.delete(&Delete { skip_prompt: false }, window, cx)
9641 });
9642 assert!(
9643 cx.has_pending_prompt(),
9644 "Should have a prompt after the deletion"
9645 );
9646 cx.simulate_prompt_answer("Delete");
9647 assert!(
9648 !cx.has_pending_prompt(),
9649 "Should have no prompts after prompt was replied to"
9650 );
9651 cx.executor().run_until_parked();
9652 }
9653
9654 fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9655 assert!(
9656 !cx.has_pending_prompt(),
9657 "Should have no prompts before the deletion"
9658 );
9659 panel.update_in(cx, |panel, window, cx| {
9660 panel.delete(&Delete { skip_prompt: true }, window, cx)
9661 });
9662 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9663 cx.executor().run_until_parked();
9664 }
9665
9666 fn ensure_no_open_items_and_panes(
9667 workspace: &WindowHandle<Workspace>,
9668 cx: &mut VisualTestContext,
9669 ) {
9670 assert!(
9671 !cx.has_pending_prompt(),
9672 "Should have no prompts after deletion operation closes the file"
9673 );
9674 workspace
9675 .read_with(cx, |workspace, cx| {
9676 let open_project_paths = workspace
9677 .panes()
9678 .iter()
9679 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9680 .collect::<Vec<_>>();
9681 assert!(
9682 open_project_paths.is_empty(),
9683 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9684 );
9685 })
9686 .unwrap();
9687 }
9688
9689 struct TestProjectItemView {
9690 focus_handle: FocusHandle,
9691 path: ProjectPath,
9692 }
9693
9694 struct TestProjectItem {
9695 path: ProjectPath,
9696 }
9697
9698 impl project::ProjectItem for TestProjectItem {
9699 fn try_open(
9700 _project: &Entity<Project>,
9701 path: &ProjectPath,
9702 cx: &mut App,
9703 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9704 let path = path.clone();
9705 Some(cx.spawn(|mut cx| async move { cx.new(|_| Self { path }) }))
9706 }
9707
9708 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9709 None
9710 }
9711
9712 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9713 Some(self.path.clone())
9714 }
9715
9716 fn is_dirty(&self) -> bool {
9717 false
9718 }
9719 }
9720
9721 impl ProjectItem for TestProjectItemView {
9722 type Item = TestProjectItem;
9723
9724 fn for_project_item(
9725 _: Entity<Project>,
9726 project_item: Entity<Self::Item>,
9727 _: &mut Window,
9728 cx: &mut Context<Self>,
9729 ) -> Self
9730 where
9731 Self: Sized,
9732 {
9733 Self {
9734 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9735 focus_handle: cx.focus_handle(),
9736 }
9737 }
9738 }
9739
9740 impl Item for TestProjectItemView {
9741 type Event = ();
9742 }
9743
9744 impl EventEmitter<()> for TestProjectItemView {}
9745
9746 impl Focusable for TestProjectItemView {
9747 fn focus_handle(&self, _: &App) -> FocusHandle {
9748 self.focus_handle.clone()
9749 }
9750 }
9751
9752 impl Render for TestProjectItemView {
9753 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9754 Empty
9755 }
9756 }
9757}