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